feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+940
View File
@@ -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>
+105
View File
@@ -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>
+375
View File
@@ -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>
+456
View File
@@ -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>
+453
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+334
View File
@@ -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>
+282
View File
@@ -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>
+523
View File
@@ -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>
+400
View File
@@ -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>