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>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-bg-primary flex items-center justify-center p-6">
|
||||
<div class="max-w-md w-full">
|
||||
<div
|
||||
class="bg-bg-secondary-40 border border-border-secondary rounded-3xl p-8 md:p-10 shadow-2xl text-center space-y-8"
|
||||
>
|
||||
<!-- 错误图标 -->
|
||||
<div class="flex justify-center">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-24 h-24 rounded-3xl bg-error-10 border border-error-20 flex items-center justify-center text-error"
|
||||
>
|
||||
<ShieldAlert :size="48" stroke-width="1.5" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute -top-2 -right-2 w-8 h-8 bg-bg-primary border border-border-secondary rounded-full flex items-center justify-center text-error shadow-xl"
|
||||
>
|
||||
<X :size="16" stroke-width="3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误标题与信息 -->
|
||||
<div class="space-y-3">
|
||||
<h1 class="text-2xl font-black text-text-primary tracking-tight">{{ errorTitle }}</h1>
|
||||
<p class="text-sm text-text-tertiary leading-relaxed">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误详情 -->
|
||||
<div
|
||||
v-if="errorCode"
|
||||
class="p-4 bg-bg-primary-50 border border-border-secondary rounded-2xl space-y-2"
|
||||
>
|
||||
<p class="text-[10px] text-text-disabled font-black uppercase tracking-widest">{{ locale.errorCode }}</p>
|
||||
<code
|
||||
class="text-xs font-mono text-primary font-bold bg-primary-5 px-2 py-1 rounded-lg"
|
||||
>
|
||||
{{ errorCode }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="grid grid-cols-1 gap-3 pt-4">
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
class="flex items-center justify-center gap-2 py-3 bg-primary-hover hover:bg-primary text-text-primary text-sm font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
>
|
||||
<LogIn :size="18" />
|
||||
{{ primaryActionLabel }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center justify-center gap-2 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-sm font-black rounded-xl transition-all active:scale-95"
|
||||
>
|
||||
<Home :size="18" />
|
||||
{{ locale.backHome }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<p class="text-[10px] text-text-secondary font-medium uppercase tracking-widest pt-4">
|
||||
{{ locale.contactAdmin }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ShieldAlert, X, LogIn, Home } from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.authError || {})
|
||||
|
||||
const errorCode = computed(() => {
|
||||
const code = Array.isArray(route.query.code) ? route.query.code[0] : route.query.code
|
||||
return typeof code === 'string' ? code : ''
|
||||
})
|
||||
const isAggregateLoginUnavailable = computed(
|
||||
() => errorCode.value === 'AGGREGATE_LOGIN_UNAVAILABLE'
|
||||
)
|
||||
const errorTitle = computed(() =>
|
||||
isAggregateLoginUnavailable.value
|
||||
? locale.value.aggregateUnavailableTitle || '当前登录方式暂不可用'
|
||||
: locale.value.title
|
||||
)
|
||||
const primaryActionLabel = computed(() =>
|
||||
isAggregateLoginUnavailable.value
|
||||
? locale.value.aggregateUnavailableAction || '选择其他登录方式'
|
||||
: locale.value.retryLogin
|
||||
)
|
||||
const errorMessage = computed(() => {
|
||||
const message = Array.isArray(route.query.message) ? route.query.message[0] : route.query.message
|
||||
return typeof message === 'string' && message ? message : locale.value.defaultMessage
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-container">
|
||||
<!-- 左侧信息区域 -->
|
||||
<div class="info-section">
|
||||
<div class="info-content">
|
||||
<div class="logo-section">
|
||||
<img alt="VoiceHub Logo" class="brand-logo" :src="getLogo()" />
|
||||
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isFirstLogin" class="welcome-message">
|
||||
<h2>{{ locale.welcomeTitle }}</h2>
|
||||
<p>{{ locale.welcomeDesc }}</p>
|
||||
</div>
|
||||
<div v-else class="security-message">
|
||||
<h2>{{ locale.securityTitle }}</h2>
|
||||
<p>{{ locale.securityDesc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="security-tips">
|
||||
<h3>{{ locale.tipsTitle }}</h3>
|
||||
<div class="tip-list">
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
class="tip-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipMinLength }}</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
class="tip-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipCase }}</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
class="tip-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipSpecial }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单区域 -->
|
||||
<div class="form-section">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>{{ isFirstLogin ? locale.setNewPassword : locale.changePasswordTitle }}</h2>
|
||||
<p>{{ isFirstLogin ? locale.setNewPasswordDesc : locale.updatePasswordDesc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="password-form-shell">
|
||||
<ClientOnly>
|
||||
<ChangePasswordForm :is-first-login="isFirstLogin" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<NuxtLink v-if="!requirePasswordChange" class="back-link" to="/">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<polyline points="15,18 9,12 15,6" />
|
||||
</svg>
|
||||
{{ locale.backToHome }}
|
||||
</NuxtLink>
|
||||
<button v-else class="back-link logout-link" type="button" @click="auth.logout()">
|
||||
<Icon class="shrink-0" name="logout" :size="16" aria-hidden="true" />
|
||||
{{ locale.logout }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChangePasswordForm from '~/components/Auth/ChangePasswordForm.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { changePassword: locale } = useLocale()
|
||||
const { getLogo } = useThemeImage()
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isFirstLogin = computed(() => {
|
||||
const currentUser = auth.user.value
|
||||
return currentUser?.needsInitialPasswordSetup === true
|
||||
})
|
||||
const requirePasswordChange = computed(() => !!auth.user.value?.requirePasswordChange)
|
||||
|
||||
// 未登录用户重定向到登录页
|
||||
onMounted(async () => {
|
||||
// 初始化站点配置
|
||||
await initSiteConfig()
|
||||
|
||||
if (!auth.isAuthenticated.value && import.meta.client) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--panel-bg-deepest);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--panel-border-subtle);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: linear-gradient(135deg, var(--color-indigo-hover) 0%, var(--color-collab-hover) 100%);
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 160px;
|
||||
height: auto;
|
||||
margin-bottom: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.welcome-message,
|
||||
.security-message {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.welcome-message h2,
|
||||
.security-message h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.welcome-message p,
|
||||
.security-message p {
|
||||
font-size: 16px;
|
||||
color: var(--overlay-90);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.security-tips h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--overlay-10);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--overlay-20);
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tip-item span {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-form-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary-hover);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel-bg-deep);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--panel-border);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--panel-border-light);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-layout {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
border-radius: 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.info-section,
|
||||
.form-section {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div>
|
||||
<ClientOnly>
|
||||
<div class="admin-layout" @touchend="handleTouchEnd" @touchstart="handleTouchStart">
|
||||
<!-- 左侧导航栏 -->
|
||||
<AdminSidebar
|
||||
:is-open="sidebarOpen"
|
||||
:active-tab="activeTab"
|
||||
:current-user="currentUser"
|
||||
:permissions="permissions"
|
||||
:site-title="siteTitle"
|
||||
@navigate="handleNavigate"
|
||||
@close="closeSidebar"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 bg-bg-primary-60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
|
||||
@click="closeSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main
|
||||
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-bg-primary text-text-primary"
|
||||
>
|
||||
<header
|
||||
class="h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-border-secondary bg-bg-primary-60 backdrop-blur-xl z-30"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="lg:hidden p-2 text-text-tertiary hover:bg-bg-tertiary rounded-lg transition-colors"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<h1 class="text-xl font-bold tracking-tight">{{ getPageTitle() }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 这里可以添加顶部操作按钮 -->
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
|
||||
>
|
||||
<!-- 移动端返回顶部按钮 -->
|
||||
<button
|
||||
v-if="showBackToTop"
|
||||
:aria-label="locale.backToTop"
|
||||
class="fixed bottom-8 right-8 p-3 bg-primary-hover text-text-primary rounded-full shadow-lg hover:bg-primary transition-all z-50"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ChevronUp :size="24" />
|
||||
</button>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<div
|
||||
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
|
||||
</div>
|
||||
|
||||
<!-- 歌曲管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'songs' && permissions.canAccessPage('songs')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSongManagement />
|
||||
</div>
|
||||
|
||||
<!-- 排期管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'schedule' && permissions.canAccessPage('schedule')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminScheduleManager />
|
||||
</div>
|
||||
|
||||
<!-- 打印排期 -->
|
||||
<div
|
||||
v-if="activeTab === 'print' && permissions.canAccessPage('print')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminSchedulePrinter />
|
||||
</div>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<div
|
||||
v-if="activeTab === 'data-analysis' && permissions.canAccessPage('data-analysis')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminDataAnalysisPanel />
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'users' && permissions.canAccessPage('users')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminUserManager />
|
||||
</div>
|
||||
|
||||
<!-- 消息管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'notifications' && permissions.canAccessPage('notifications')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminNotificationSender />
|
||||
</div>
|
||||
|
||||
<!-- SMTP邮件配置 -->
|
||||
<div
|
||||
v-if="activeTab === 'smtp-config' && permissions.canAccessPage('smtp-config')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSmtpManager />
|
||||
</div>
|
||||
|
||||
<!-- 播出时段 -->
|
||||
<div
|
||||
v-if="activeTab === 'playtimes' && permissions.canAccessPage('playtimes')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminPlayTimeManager />
|
||||
</div>
|
||||
|
||||
<!-- 投稿管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'request-times' && permissions.canAccessPage('request-times')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminRequestTimeManager />
|
||||
</div>
|
||||
|
||||
<!-- 学期管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'semesters' && permissions.canAccessPage('semesters')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSemesterManager />
|
||||
</div>
|
||||
|
||||
<!-- 黑名单管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'blacklist' && permissions.canAccessPage('blacklist')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminBlacklistManager />
|
||||
</div>
|
||||
|
||||
<!-- 年级班级管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'grade-class' && permissions.canAccessPage('grade-class')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminGradeClassManager />
|
||||
</div>
|
||||
|
||||
<!-- 站点配置 -->
|
||||
<div
|
||||
v-if="activeTab === 'site-config' && permissions.canAccessPage('site-config')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSiteConfigManager />
|
||||
</div>
|
||||
|
||||
<!-- 音源控制 -->
|
||||
<div
|
||||
v-if="activeTab === 'music-source' && permissions.canAccessPage('music-source')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminMusicSourceController />
|
||||
</div>
|
||||
|
||||
<!-- 数据库操作 -->
|
||||
<div
|
||||
v-if="activeTab === 'database' && permissions.canAccessPage('database')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminDatabaseManager />
|
||||
</div>
|
||||
|
||||
<!-- API密钥管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'api-keys' && permissions.canAccessPage('api-keys')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminApiKeyManager />
|
||||
</div>
|
||||
<!-- 卡密管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'card-codes' && permissions.canAccessPage('card-codes')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminCardCodesManager />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { Menu, ChevronUp } from '@lucide/vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useSiteConfig } from '~/composables/useSiteConfig'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useScrollMemory } from '~/composables/useScrollMemory'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.dashboard || {})
|
||||
|
||||
// 导入组件
|
||||
|
||||
// 页面元数据
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// 各面板滚动位置独立记忆与恢复
|
||||
useScrollMemory(() => activeTab.value)
|
||||
|
||||
const currentUser = ref(null)
|
||||
const sidebarOpen = ref(false)
|
||||
const showBackToTop = ref(false)
|
||||
const beforeNavigateHooks = ref([])
|
||||
|
||||
// 提供注册导航拦截钩子的方法
|
||||
const registerBeforeNavigate = (hook) => {
|
||||
beforeNavigateHooks.value.push(hook)
|
||||
return () => {
|
||||
const index = beforeNavigateHooks.value.indexOf(hook)
|
||||
if (index > -1) {
|
||||
beforeNavigateHooks.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
provide('registerBeforeNavigate', registerBeforeNavigate)
|
||||
|
||||
// 服务
|
||||
let auth = null
|
||||
const permissions = usePermissions()
|
||||
|
||||
// 方法
|
||||
const getPageTitle = () => {
|
||||
return locale.value.tabs[activeTab.value] || locale.value.fallbackTitle
|
||||
}
|
||||
|
||||
// 动态页面标题
|
||||
const dynamicTitle = computed(() => {
|
||||
const currentPageTitle = getPageTitle()
|
||||
if (siteTitle && siteTitle.value) {
|
||||
return `${currentPageTitle} | ${siteTitle.value}`
|
||||
}
|
||||
return `${currentPageTitle} | ${locale.value.defaultSiteTitle}`
|
||||
})
|
||||
|
||||
// 监听activeTab变化,更新页面标题
|
||||
watch(
|
||||
activeTab,
|
||||
() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = dynamicTitle.value
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听siteTitle变化,更新页面标题
|
||||
watch(
|
||||
() => siteTitle?.value,
|
||||
() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = dynamicTitle.value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
return locale.value.roles[role] || role
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (auth) {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
// 导航方法
|
||||
const handleNavigate = async (tab) => {
|
||||
if (activeTab.value === tab) return
|
||||
|
||||
// 检查是否有拦截
|
||||
for (const hook of beforeNavigateHooks.value) {
|
||||
if (!(await hook(tab))) return
|
||||
}
|
||||
|
||||
activeTab.value = tab
|
||||
// 移动端点击导航后关闭侧边栏
|
||||
if (window.innerWidth <= 768) {
|
||||
closeSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏控制方法
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
// 监听窗口大小变化,大屏幕时自动关闭移动端侧边栏
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 768) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回顶部功能
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
showBackToTop.value = window.scrollY > 300
|
||||
}
|
||||
|
||||
// 触摸手势支持
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchEndX = 0
|
||||
let touchEndY = 0
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX
|
||||
touchStartY = e.changedTouches[0].screenY
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX
|
||||
touchEndY = e.changedTouches[0].screenY
|
||||
handleSwipe()
|
||||
}
|
||||
|
||||
const handleSwipe = () => {
|
||||
const deltaX = touchEndX - touchStartX
|
||||
const deltaY = touchEndY - touchStartY
|
||||
const minSwipeDistance = 50
|
||||
|
||||
// 只在移动端处理滑动手势
|
||||
if (window.innerWidth > 768) return
|
||||
|
||||
// 水平滑动距离大于垂直滑动距离,且超过最小滑动距离
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
|
||||
if (deltaX > 0 && touchStartX < 50) {
|
||||
// 从左边缘向右滑动,打开侧边栏
|
||||
sidebarOpen.value = true
|
||||
} else if (deltaX < 0 && sidebarOpen.value) {
|
||||
// 向左滑动,关闭侧边栏
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
// 初始化站点配置
|
||||
await initSiteConfig()
|
||||
|
||||
// 初始化服务
|
||||
auth = useAuth()
|
||||
|
||||
// 检查认证状态(plugin已经初始化过了)
|
||||
|
||||
if (!auth.isAuthenticated.value) {
|
||||
await navigateTo('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否有访问后台的权限
|
||||
if (!permissions.canAccessAdmin.value) {
|
||||
await navigateTo('/')
|
||||
return
|
||||
}
|
||||
|
||||
currentUser.value = auth.user.value
|
||||
|
||||
// 设置默认页面
|
||||
const userPages = permissions.getUserPages.value
|
||||
if (userPages.length > 0 && !userPages.includes(activeTab.value)) {
|
||||
activeTab.value = userPages[0]
|
||||
}
|
||||
|
||||
// 设置初始页面标题
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = dynamicTitle.value
|
||||
}
|
||||
|
||||
// 添加窗口大小监听器
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 添加双击关闭侧边栏事件
|
||||
document.addEventListener('dblclick', (e) => {
|
||||
if (window.innerWidth <= 768 && sidebarOpen.value) {
|
||||
closeSidebar()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--panel-bg-deepest);
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-main">
|
||||
<div class="auth-container">
|
||||
<div class="form-section">
|
||||
<div class="form-header">
|
||||
<h1 class="form-title">{{ locale.title }}</h1>
|
||||
<p class="form-subtitle">{{ locale.subtitle }}</p>
|
||||
<div class="header-divider" />
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-container">
|
||||
<svg class="success-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="success-message">{{ successMessage }}</p>
|
||||
<NuxtLink to="/login" class="back-link-btn">{{ locale.backLogin }}</NuxtLink>
|
||||
</div>
|
||||
|
||||
<form v-else :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleSubmit">
|
||||
<div class="form-group" v-if="step === 1">
|
||||
<label for="username">{{ locale.username }}</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
:class="{ 'input-error': error }"
|
||||
:placeholder="locale.usernamePlaceholder"
|
||||
required
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="step === 2">
|
||||
<label for="email">{{ locale.verifyEmail }}</label>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{{ locale.detectedEmailPrefix }}<strong class="text-[var(--primary)]">{{ maskedEmail }}</strong>
|
||||
</p>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
:class="{ 'input-error': error }"
|
||||
:placeholder="locale.emailPlaceholder"
|
||||
required
|
||||
type="email"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-container">
|
||||
<svg class="error-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading" class="submit-btn" type="submit">
|
||||
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" fill="none" r="10" stroke="currentColor" stroke-dasharray="31.416" stroke-dashoffset="31.416" stroke-linecap="round" stroke-width="2">
|
||||
<animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="0 31.416;15.708 15.708;0 31.416"/>
|
||||
<animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" values="0;-15.708;-31.416"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span v-if="loading">{{ step === 1 ? locale.verifying : locale.sending }}</span>
|
||||
<span v-else>{{ step === 1 ? locale.nextStep : locale.sendResetEmail }}</span>
|
||||
</button>
|
||||
|
||||
<div class="form-footer">
|
||||
<button v-if="step === 2" type="button" @click="step = 1; error = '';" class="back-link mr-4" style="background: none; border: none; cursor: pointer;">
|
||||
{{ locale.previousStep }}
|
||||
</button>
|
||||
<NuxtLink to="/login" class="back-link">{{ locale.backLogin }}</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.forgotPassword || {})
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const successMessage = ref('')
|
||||
const step = ref(1)
|
||||
const maskedEmail = ref('')
|
||||
|
||||
// 服务端错误统一通过错误码本地化,成功文案直接取本地化整句
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
|
||||
useHead({
|
||||
title: () => siteTitle.value ? `${locale.value.title} | ${siteTitle.value}` : locale.value.title
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await initSiteConfig()
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (step.value === 1) {
|
||||
if (!username.value) {
|
||||
error.value = locale.value.usernameRequired
|
||||
return
|
||||
}
|
||||
} else if (step.value === 2) {
|
||||
if (!email.value) {
|
||||
error.value = locale.value.emailRequired
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const body = step.value === 1
|
||||
? { username: username.value }
|
||||
: { username: username.value, email: email.value }
|
||||
|
||||
const response = await $fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
if (response.step === 2) {
|
||||
// 进入补全邮箱步骤
|
||||
step.value = 2
|
||||
maskedEmail.value = response.maskedEmail || ''
|
||||
} else if (response.step === 3) {
|
||||
// 成功发送邮件
|
||||
success.value = true
|
||||
successMessage.value = locale.value.serverMessages?.mailSentFull || locale.value.mailSent
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = localizeServerError(err, locale.value.requestFailed)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding-bottom: clamp(16px, 4vh, 40px);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-2xl);
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: var(--font-bold);
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background: var(--border-secondary);
|
||||
margin: 20px auto 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transform: scaleX(0.2);
|
||||
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within::after {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: 16px;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus + .input-icon,
|
||||
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-wrapper input.input-error {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-light);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--error-light);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.auth-form.has-error {
|
||||
animation: shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(6px); }
|
||||
45% { transform: translateX(-4px); }
|
||||
60% { transform: translateX(4px); }
|
||||
75% { transform: translateX(-2px); }
|
||||
90% { transform: translateX(2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.success-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--success);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.back-link-btn {
|
||||
padding: 12px 32px;
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-medium);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-layout {
|
||||
padding: 10px;
|
||||
}
|
||||
.auth-container {
|
||||
border-radius: 16px;
|
||||
}
|
||||
.form-section {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+4446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-container">
|
||||
<div class="form-section">
|
||||
<div class="form-header">
|
||||
<div class="logo-row">
|
||||
<img :src="brandLogoSrc" alt="Brand Logo" class="brand-logo-center" />
|
||||
<div v-if="schoolLogoHomeDisplayUrl" class="logo-divider" />
|
||||
<img
|
||||
v-if="schoolLogoHomeDisplayUrl"
|
||||
:src="schoolLogoHomeDisplayUrl"
|
||||
:alt="locale.schoolLogoAlt"
|
||||
class="school-logo"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
|
||||
<div class="header-divider" />
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<LoginForm />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 页脚信息显示 -->
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import LoginForm from '~/components/Auth/LoginForm.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
|
||||
// 使用站点配置
|
||||
const {
|
||||
siteTitle,
|
||||
initSiteConfig,
|
||||
logoUrl,
|
||||
schoolLogoHomeDisplayUrl,
|
||||
icp: icpNumber
|
||||
} = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.login || {})
|
||||
// 主品牌Logo优先使用SVG,其次使用站点配置中非ICO的地址
|
||||
const { getLogo } = useThemeImage()
|
||||
const brandLogoSrc = computed(() => {
|
||||
const url = logoUrl.value
|
||||
if (url && !url.endsWith('.ico')) return url
|
||||
return getLogo()
|
||||
})
|
||||
|
||||
// 在组件挂载后初始化站点配置
|
||||
onMounted(async () => {
|
||||
// 初始化站点配置
|
||||
await initSiteConfig()
|
||||
|
||||
// 设置页面标题
|
||||
if (typeof document !== 'undefined' && siteTitle.value) {
|
||||
document.title = `${locale.value.title} | ${siteTitle.value}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 20px;
|
||||
/* 响应式尺寸变量 */
|
||||
--color-brand-logo-size: clamp(48px, 8vw, 96px);
|
||||
--school-logo-size: clamp(96px, 16vw, 160px);
|
||||
--logo-gap: clamp(12px, 2vw, 24px);
|
||||
--divider-height: clamp(32px, 10vw, 96px);
|
||||
--content-footer-gap: clamp(16px, 4vh, 40px);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-2xl);
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
margin: auto 0; /* 在纵向布局中居中 */
|
||||
margin-bottom: var(--content-footer-gap); /* 与底部footer保持最小距离 */
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.brand-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 160px;
|
||||
height: auto;
|
||||
margin-bottom: 24px;
|
||||
object-fit: contain;
|
||||
transition:
|
||||
transform var(--transition-slow),
|
||||
filter var(--transition-slow);
|
||||
}
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
filter: drop-shadow(0 10px 20px var(--mask-25));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 36px;
|
||||
font-weight: var(--font-bold);
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-description {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 40px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: none;
|
||||
border: 1px solid var(--border-secondary);
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
background var(--transition-normal),
|
||||
transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.feature-item:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--card-hover-border);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-item span {
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--logo-gap);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.logo-divider {
|
||||
width: 1px;
|
||||
height: var(--divider-height);
|
||||
background: var(--border-secondary);
|
||||
}
|
||||
|
||||
.brand-logo-center {
|
||||
width: var(--color-brand-logo-size);
|
||||
height: var(--color-brand-logo-size);
|
||||
margin: 0;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 2px 8px var(--mask-15));
|
||||
}
|
||||
|
||||
.school-logo {
|
||||
width: var(--school-logo-size);
|
||||
height: var(--school-logo-size);
|
||||
margin: 0;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 2px 8px var(--mask-15));
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: var(--font-bold);
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background: var(--border-secondary);
|
||||
margin: 14px auto 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 隐藏品牌区的纹理伪元素以保持简约视觉 */
|
||||
.brand-section::before {
|
||||
background: none;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.auth-container {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 36px 26px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-layout {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
border-radius: 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
.form-section {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.icp-link,
|
||||
.voicehub-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.icp-link:hover,
|
||||
.voicehub-link:hover {
|
||||
color: var(--overlay-80);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-footer {
|
||||
padding: 15px 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer-item:not(:last-child)::after {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<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-[1000px] 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>
|
||||
|
||||
<button
|
||||
:disabled="loading || saving"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
|
||||
@click="saveSettings"
|
||||
>
|
||||
<template v-if="saving"> <Loader2 :size="14" class="animate-spin" /> {{ locale.saving }} </template>
|
||||
<template v-else> <Save :size="14" /> {{ locale.saveSettings }} </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[1000px] mx-auto px-4">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-32">
|
||||
<Loader2 :size="32" class="text-primary animate-spin mb-4" />
|
||||
<p class="text-text-tertiary text-sm font-medium">{{ locale.loading }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<!-- 站内通知设置 -->
|
||||
<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">
|
||||
<Bell :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-black text-text-primary">{{ locale.inAppTitle }}</h2>
|
||||
<p class="text-xs text-text-tertiary mt-0.5">{{ locale.inAppDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 歌曲被选中消息 -->
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.songSelectedTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.songSelectedDesc }}</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songSelectedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲已播放消息 -->
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.songPlayedTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.songPlayedDesc }}</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songPlayedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲获得投票消息 -->
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.songVotedTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.songVotedDesc }}</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songVotedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统通知 -->
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.systemTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.systemDesc }}</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.systemNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投票阈值设置 -->
|
||||
<div :class="[itemClass, 'md:col-span-1']">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.voteThresholdTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.voteThresholdDesc }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="localSettings.songVotedThreshold"
|
||||
type="number"
|
||||
max="100"
|
||||
min="1"
|
||||
class="w-16 bg-bg-primary border border-border-secondary rounded-lg px-2 py-1.5 text-xs text-text-primary text-center focus:outline-none focus:border-primary-30"
|
||||
>
|
||||
<span class="text-[10px] font-black text-text-disabled uppercase">{{ locale.voteUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知刷新间隔 -->
|
||||
<div :class="[itemClass, 'md:col-span-1']">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-text-primary">{{ locale.refreshTitle }}</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">{{ locale.refreshDesc }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="localSettings.refreshInterval"
|
||||
type="range"
|
||||
max="300"
|
||||
min="30"
|
||||
step="30"
|
||||
class="w-24 h-1.5 bg-bg-tertiary rounded-full appearance-none cursor-pointer"
|
||||
>
|
||||
<span class="text-[11px] font-bold text-primary min-w-[40px] text-right"
|
||||
>{{ localSettings.refreshInterval }}s</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Bell, ArrowLeft, Save, Loader2 } from '@lucide/vue'
|
||||
import { useSiteConfig } from '~/composables/useSiteConfig'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { showToast } = useToast()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.notificationSettings || {})
|
||||
|
||||
// 样式类常量
|
||||
const sectionClass = 'bg-bg-secondary-40 border border-border-secondary rounded-3xl p-6 md:p-8 shadow-2xl'
|
||||
const cardClass =
|
||||
'bg-bg-primary-40 border border-border-secondary-50 rounded-2xl p-5 transition-all hover:border-border-tertiary-50'
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-bg-primary-30 border border-border-secondary rounded-2xl hover:bg-bg-secondary-50 transition-all group'
|
||||
|
||||
// 页面状态
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
// 通知设置
|
||||
const localSettings = ref({
|
||||
songSelectedNotify: true,
|
||||
songPlayedNotify: true,
|
||||
songVotedNotify: true,
|
||||
songVotedThreshold: 5,
|
||||
systemNotify: true,
|
||||
refreshInterval: 60
|
||||
})
|
||||
|
||||
// 通知显示函数
|
||||
const showNotification = (message, type = 'info') => {
|
||||
showToast(message, type)
|
||||
}
|
||||
|
||||
// 返回主页
|
||||
const goBack = () => {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(async () => {
|
||||
await initSiteConfig()
|
||||
|
||||
// 设置页面标题
|
||||
if (typeof document !== 'undefined' && siteTitle.value) {
|
||||
document.title = `${locale.value.title} | ${siteTitle.value}`
|
||||
}
|
||||
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await $fetch('/api/notifications/settings')
|
||||
|
||||
if (response.success) {
|
||||
localSettings.value = {
|
||||
songSelectedNotify: response.data.songSelectedNotify || false,
|
||||
songPlayedNotify: response.data.songPlayedNotify || false,
|
||||
songVotedNotify: response.data.songVotedNotify || false,
|
||||
songVotedThreshold: response.data.songVotedThreshold || 5,
|
||||
systemNotify: response.data.systemNotify || true,
|
||||
refreshInterval: response.data.refreshInterval || 60
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(locale.value.loadFailedLog, err)
|
||||
showNotification(locale.value.loadFailed, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
const response = await $fetch('/api/notifications/settings', {
|
||||
method: 'POST',
|
||||
body: localSettings.value
|
||||
})
|
||||
if (response.success) {
|
||||
showNotification(locale.value.saveSuccess, 'success')
|
||||
} else {
|
||||
showNotification(response.message || locale.value.saveFailed, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.data?.message || locale.value.saveFailed, 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-main">
|
||||
<div class="auth-container">
|
||||
<div class="form-section">
|
||||
<div class="form-header">
|
||||
<h1 class="form-title">{{ locale.title }}</h1>
|
||||
<p class="form-subtitle">{{ locale.subtitle }}</p>
|
||||
<div class="header-divider" />
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-container">
|
||||
<svg class="success-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="success-message">{{ successMessage }}</p>
|
||||
<NuxtLink to="/login" class="back-link-btn">{{ locale.goLogin }}</NuxtLink>
|
||||
</div>
|
||||
|
||||
<form v-else :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleSubmit">
|
||||
<div v-if="!token" class="error-container" style="margin-bottom: 20px;">
|
||||
<svg class="error-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ locale.invalidTokenFull }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 密码字段 -->
|
||||
<div class="form-group">
|
||||
<label for="password">{{ locale.newPassword }}</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="locale.passwordPlaceholder"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
|
||||
<svg v-if="showPassword" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" x2="23" y1="1" y2="23" />
|
||||
</svg>
|
||||
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="password" class="px-1 pt-1 space-y-2 mt-1">
|
||||
<div class="h-1 w-full bg-[var(--input-border)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="passwordStrength.colorClass"
|
||||
:style="{ width: passwordStrength.width }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-[var(--text-tertiary)]">{{ locale.passwordStrength }}</span>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest" :class="passwordStrength.textColorClass">
|
||||
{{ passwordStrength.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码字段 -->
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">{{ locale.confirmPassword }}</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
:placeholder="locale.confirmPasswordPlaceholder"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button class="password-toggle" type="button" @click="showConfirmPassword = !showConfirmPassword">
|
||||
<svg v-if="showConfirmPassword" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" x2="23" y1="1" y2="23" />
|
||||
</svg>
|
||||
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-container">
|
||||
<svg class="error-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading || !token" class="submit-btn" type="submit">
|
||||
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" fill="none" r="10" stroke="currentColor" stroke-dasharray="31.416" stroke-dashoffset="31.416" stroke-linecap="round" stroke-width="2">
|
||||
<animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="0 31.416;15.708 15.708;0 31.416"/>
|
||||
<animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" values="0;-15.708;-31.416"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span v-if="loading">{{ locale.resetting }}</span>
|
||||
<span v-else>{{ locale.submit }}</span>
|
||||
</button>
|
||||
|
||||
<div class="form-footer">
|
||||
<NuxtLink to="/login" class="back-link">{{ locale.backLogin }}</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
import { usePasswordStrength } from '~/composables/usePasswordStrength'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
|
||||
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const route = useRoute()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.resetPassword || {})
|
||||
|
||||
const token = computed(() => route.query.token || '')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const showPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const successMessage = ref('')
|
||||
|
||||
const passwordStrength = usePasswordStrength(password)
|
||||
|
||||
useHead({
|
||||
title: () => siteTitle.value ? `${locale.value.title} | ${siteTitle.value}` : locale.value.title
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await initSiteConfig()
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!token.value) {
|
||||
error.value = locale.value.invalidToken
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = locale.value.passwordMismatch
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value.length < PASSWORD_MIN_LENGTH) {
|
||||
error.value = locale.value.passwordTooShort
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
token: token.value,
|
||||
newPassword: password.value
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
success.value = true
|
||||
successMessage.value = response.message || locale.value.success
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.data?.message || err.message || locale.value.failed
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding-bottom: clamp(16px, 4vh, 40px);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-2xl);
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: var(--font-bold);
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background: var(--border-secondary);
|
||||
margin: 20px auto 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transform: scaleX(0.2);
|
||||
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within::after {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: 16px;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus + .input-icon,
|
||||
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-wrapper input.input-error {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-light);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, transform var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.password-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.password-toggle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--error-light);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.auth-form.has-error {
|
||||
animation: shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(6px); }
|
||||
45% { transform: translateX(-4px); }
|
||||
60% { transform: translateX(4px); }
|
||||
75% { transform: translateX(-2px); }
|
||||
90% { transform: translateX(2px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.success-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--success);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.back-link-btn {
|
||||
padding: 12px 32px;
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-medium);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-layout {
|
||||
padding: 10px;
|
||||
}
|
||||
.auth-container {
|
||||
border-radius: 16px;
|
||||
}
|
||||
.form-section {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<Head>
|
||||
<Title>{{ locale.pageTitle }}</Title>
|
||||
<Meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="state-screen loading">
|
||||
<div class="spinner" />
|
||||
<p class="loading-text">{{ locale.loading }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="state-screen error">
|
||||
<div class="icon-warning">⚠️</div>
|
||||
<div class="text-center">
|
||||
<h2 class="error-title">{{ locale.errorTitle }}</h2>
|
||||
<p class="error-desc">{{ locale.errorDesc }}</p>
|
||||
</div>
|
||||
<button class="retry-btn" @click="refresh"><span>🔄</span> {{ locale.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="isEmpty" class="state-screen empty">
|
||||
<div class="empty-icon-circle">
|
||||
<span class="empty-icon">🎵</span>
|
||||
</div>
|
||||
<h2 class="empty-title">{{ locale.emptyTitle }}</h2>
|
||||
<p class="empty-desc">{{ locale.emptyDesc }}</p>
|
||||
<NuxtLink to="/" class="home-btn">{{ locale.goRequest }}</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
v-else
|
||||
class="slides-container"
|
||||
@wheel="handleWheel"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<!-- Progress Indicators -->
|
||||
<div class="indicators">
|
||||
<button
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="slide.id"
|
||||
class="indicator-dot"
|
||||
:class="{ active: idx === currentIndex }"
|
||||
@click="goToSlide(idx)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slide Transition -->
|
||||
<Transition :name="transitionName">
|
||||
<component
|
||||
:is="currentSlideComponent"
|
||||
:key="currentIndex"
|
||||
:data="data"
|
||||
:active="!isAnimating"
|
||||
class="slide-wrapper"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Scroll Hint -->
|
||||
<Transition name="fade">
|
||||
<div v-if="currentIndex === 0" class="scroll-hint">
|
||||
<span>{{ locale.scrollHint }}</span>
|
||||
<svg
|
||||
class="animate-bounce"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IntroSlide from '~/components/year-review/IntroSlide.vue'
|
||||
import FirstSongSlide from '~/components/year-review/FirstSongSlide.vue'
|
||||
import StatsSlide from '~/components/year-review/StatsSlide.vue'
|
||||
import ArtistSlide from '~/components/year-review/ArtistSlide.vue'
|
||||
import MiscSlide from '~/components/year-review/MiscSlide.vue'
|
||||
import OutroSlide from '~/components/year-review/OutroSlide.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
// 导入全局样式
|
||||
import '~/assets/css/year-review.css'
|
||||
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.yearReview || {})
|
||||
|
||||
const { data: responseData, pending, error, refresh } = await useFetch('/api/user/year-review')
|
||||
|
||||
const data = computed(() => {
|
||||
if (!responseData.value) return null
|
||||
return responseData.value.data || responseData.value
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (!data.value) return true
|
||||
return data.value.totalRequests === 0 && data.value.totalVotes === 0
|
||||
})
|
||||
|
||||
// --- 幻灯片配置 ---
|
||||
const slides = computed(() => {
|
||||
if (!data.value) return []
|
||||
|
||||
const list = [
|
||||
{ id: 'intro', component: IntroSlide },
|
||||
...(data.value.firstSong ? [{ id: 'first', component: FirstSongSlide }] : []),
|
||||
{ id: 'stats', component: StatsSlide },
|
||||
...(data.value.topArtist ? [{ id: 'artist', component: ArtistSlide }] : []),
|
||||
{ id: 'misc', component: MiscSlide },
|
||||
{ id: 'outro', component: OutroSlide }
|
||||
]
|
||||
return list
|
||||
})
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const direction = ref(1) // 1 for down (next), -1 for up (prev)
|
||||
const isAnimating = ref(false)
|
||||
const transitionName = ref('slide-up')
|
||||
|
||||
const currentSlideComponent = computed(() => {
|
||||
return slides.value[currentIndex.value]?.component
|
||||
})
|
||||
|
||||
// --- 导航逻辑 ---
|
||||
const goToSlide = (index) => {
|
||||
if (isAnimating.value || index === currentIndex.value) return
|
||||
if (index < 0 || index >= slides.value.length) return
|
||||
|
||||
const newDir = index > currentIndex.value ? 1 : -1
|
||||
direction.value = newDir
|
||||
transitionName.value = newDir > 0 ? 'slide-up' : 'slide-down'
|
||||
|
||||
isAnimating.value = true
|
||||
currentIndex.value = index
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, 600) // Match transition duration
|
||||
}
|
||||
|
||||
const nextSlide = () => goToSlide(currentIndex.value + 1)
|
||||
const prevSlide = () => goToSlide(currentIndex.value - 1)
|
||||
|
||||
// --- 事件监听 ---
|
||||
|
||||
// 键盘
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
nextSlide()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
prevSlide()
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮(防抖)
|
||||
let wheelTimeout = null
|
||||
const handleWheel = (e) => {
|
||||
if (Math.abs(e.deltaY) > 20 && !isAnimating.value) {
|
||||
if (wheelTimeout) clearTimeout(wheelTimeout)
|
||||
wheelTimeout = setTimeout(() => {
|
||||
if (e.deltaY > 0) nextSlide()
|
||||
else prevSlide()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸
|
||||
let touchStartY = 0
|
||||
const handleTouchStart = (e) => {
|
||||
touchStartY = e.touches[0].clientY
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
const touchEndY = e.changedTouches[0].clientY
|
||||
const diff = touchStartY - touchEndY
|
||||
|
||||
if (Math.abs(diff) > 50) {
|
||||
// Threshold
|
||||
if (diff > 0)
|
||||
nextSlide() // Swipe Up -> Next
|
||||
else prevSlide() // Swipe Down -> Prev
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
z-index: 9999;
|
||||
font-family:
|
||||
'MiSans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 状态屏幕 */
|
||||
.state-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-primary);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.loading .spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 4px solid var(--overlay-20);
|
||||
border-top-color: var(--color-collab); /* brand-purple */
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.error .icon-warning {
|
||||
font-size: 3rem;
|
||||
color: var(--color-error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-desc {
|
||||
color: var(--year-review-text-tertiary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--overlay-10);
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--overlay-20);
|
||||
}
|
||||
|
||||
.empty .empty-icon-circle {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
background: var(--panel-bg-dark);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
color: var(--year-review-text-tertiary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.home-btn {
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--color-collab);
|
||||
border-radius: 9999px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10px 15px -3px var(--year-review-home-btn-shadow);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.home-btn:hover {
|
||||
background: var(--color-collab-hover);
|
||||
}
|
||||
|
||||
/* 幻灯片容器 */
|
||||
.slides-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 指示器 */
|
||||
.indicators {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--overlay-30);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.indicator-dot:hover {
|
||||
background: var(--overlay-60);
|
||||
}
|
||||
|
||||
.indicator-dot.active {
|
||||
height: 1.5rem;
|
||||
background: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 滚动提示 */
|
||||
.scroll-hint {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
color: var(--overlay-40);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user