Files
BYW-VoiceHub/patches/0047-feat-site-UI.patch
T

570 lines
18 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From a82ff6747eeeea1afaac58f2c4c5e1c015c5b3ce Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 11:56:30 +0800
Subject: [PATCH 47/58] =?UTF-8?q?feat(site):=20=E5=AF=B9=E9=BD=90=E5=8F=82?=
=?UTF-8?q?=E8=80=83=E7=AB=99=E7=82=B9UI=E4=B8=8E=E4=BA=A4=E4=BA=92?=
=?UTF-8?q?=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 页脚:响应耗时文字统一为「响应耗时 XXXms」,版本链接改为 GitHub 官方仓库,默认 ICP 备案号设置为粤ICP备2025450093号
- 顶部栏:恢复主题切换按钮与语言按钮(语言暂为占位提示),放置于用户区左侧
- 主题:修复首屏内联脚本默认主题白名单校验,避免 localStorage 中残留的 light/dark/System 等非法值生效,首屏默认改为 ClassicLight(仅允许 ClassicLight/ModernLight)
- 登录页:在密码输入框下方恢复「忘记密码?」入口,新增配套的找回密码引导页 /forgot-password
- 默认配置:system-settings 兜底记录与 SYSTEM_SETTINGS_DEFAULTS 新增 icpNumber 默认值
---
app/components/Auth/LoginForm.vue | 24 ++
app/components/SiteFooter.vue | 11 +-
app/pages/forgot-password.vue | 269 +++++++++++++++++++++++
app/pages/index.vue | 85 +++++++
nuxt.config.ts | 36 ++-
server/api/site-config.get.ts | 3 +-
server/utils/system-settings-defaults.ts | 2 +
7 files changed, 415 insertions(+), 15 deletions(-)
create mode 100644 app/pages/forgot-password.vue
diff --git a/app/components/Auth/LoginForm.vue b/app/components/Auth/LoginForm.vue
index 63472c8..4ec8d57 100644
--- a/app/components/Auth/LoginForm.vue
+++ b/app/components/Auth/LoginForm.vue
@@ -74,6 +74,11 @@
</svg>
</button>
</div>
+ <div class="forgot-password-row">
+ <NuxtLink to="/forgot-password" class="forgot-password-link">
+ 忘记密码?
+ </NuxtLink>
+ </div>
</div>
<div v-if="error" class="error-container">
@@ -556,6 +561,25 @@ const handleWebAuthnLogin = async () => {
height: 20px;
}
+.forgot-password-row {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 10px;
+}
+
+.forgot-password-link {
+ font-size: 13px;
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: var(--font-medium);
+ transition: opacity 0.2s ease;
+}
+
+.forgot-password-link:hover {
+ opacity: 0.8;
+ text-decoration: underline;
+}
+
.form-footer {
margin-top: 24px;
text-align: center;
diff --git a/app/components/SiteFooter.vue b/app/components/SiteFooter.vue
index 0b3adc7..8036a69 100644
--- a/app/components/SiteFooter.vue
+++ b/app/components/SiteFooter.vue
@@ -23,11 +23,16 @@
</span>
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</span>
- <span class="footer-item">Worker in {{ responseTime }}ms</span>
+ <span class="footer-item">响应耗时 {{ responseTime }}ms</span>
<span class="footer-item">
- <NuxtLink class="voicehub-link" to="/changelog">
+ <a
+ class="voicehub-link"
+ href="https://github.com/laoshuikaixue/VoiceHub"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
{{ displayName }} v{{ systemVersion }}
- </NuxtLink>
+ </a>
</span>
<span v-if="isNetlify" class="footer-item">
<a
diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue
new file mode 100644
index 0000000..62e6ee5
--- /dev/null
+++ b/app/pages/forgot-password.vue
@@ -0,0 +1,269 @@
+<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"
+ width="96"
+ height="96"
+ decoding="async"
+ fetchpriority="high"
+ >
+ <div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider" />
+ <img
+ v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()"
+ :src="schoolLogoHomeUrl"
+ alt="学校Logo"
+ class="school-logo"
+ width="160"
+ height="160"
+ decoding="async"
+ fetchpriority="high"
+ >
+ </div>
+ <h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
+ <div class="header-divider" />
+ </div>
+ <div class="forgot-password-form">
+ <div class="page-intro">
+ <h2 class="page-heading">找回密码</h2>
+ <p class="page-subtitle">请联系管理员重置您的账号密码</p>
+ </div>
+
+ <div class="contact-card">
+ <div class="contact-card-icon">
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
+ <path 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" />
+ </svg>
+ </div>
+ <div class="contact-card-body">
+ <h3>联系管理员</h3>
+ <p>为保障账号安全,系统暂未开放自助密码找回。请通过以下方式联系管理员进行密码重置:</p>
+ <ul class="contact-tips">
+ <li>站内联系:登录有管理员权限的账号</li>
+ <li>线下联系:前往广播站办公室或指导老师</li>
+ <li>邮件联系:如有公开的管理员邮箱</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="form-actions">
+ <NuxtLink to="/login" class="back-to-login-btn">
+ 返回登录
+ </NuxtLink>
+ </div>
+ </div>
+ </div>
+ </div>
+ <SiteFooter />
+ </div>
+</template>
+
+<script setup>
+import { onMounted, computed } from 'vue'
+import logo from '~~/public/images/logo.png'
+
+const { siteTitle, initSiteConfig, schoolLogoHomeUrl } = useSiteConfig()
+const brandLogoSrc = computed(() => {
+ return logo
+})
+
+onMounted(async () => {
+ await initSiteConfig()
+ if (typeof document !== 'undefined' && siteTitle.value) {
+ document.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;
+}
+
+.auth-container {
+ width: 100%;
+ max-width: 520px;
+ 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: 40px;
+}
+
+.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: 24px;
+ width: 100%;
+}
+
+.logo-row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ margin-bottom: 12px;
+}
+
+.logo-divider {
+ width: 1px;
+ height: 96px;
+ background: var(--border-secondary);
+}
+
+.brand-logo-center {
+ width: 96px;
+ height: 96px;
+ object-fit: contain;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
+}
+
+.school-logo {
+ width: 160px;
+ height: 160px;
+ object-fit: contain;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.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%;
+}
+
+.forgot-password-form {
+ width: 100%;
+ max-width: 400px;
+}
+
+.page-intro {
+ text-align: center;
+ margin-bottom: 28px;
+}
+
+.page-heading {
+ font-size: 28px;
+ font-weight: var(--font-bold);
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.page-subtitle {
+ font-size: 14px;
+ color: var(--text-tertiary);
+ margin: 0;
+}
+
+.contact-card {
+ display: flex;
+ gap: 16px;
+ padding: 24px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-lg);
+ margin-bottom: 28px;
+}
+
+.contact-card-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: rgba(59, 130, 246, 0.12);
+ color: var(--primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.contact-card-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.contact-card-body h3 {
+ font-size: 16px;
+ font-weight: var(--font-semibold);
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.contact-card-body p {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0 0 12px 0;
+ line-height: 1.6;
+}
+
+.contact-tips {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.9;
+}
+
+.form-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.back-to-login-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ padding: 14px;
+ 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);
+ text-decoration: none;
+ transition: all 0.2s ease;
+}
+
+.back-to-login-btn:hover {
+ background: var(--btn-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+@media (max-width: 768px) {
+ .auth-layout { padding: 10px; }
+ .auth-container { border-radius: 16px; }
+ .form-section { padding: 30px 20px; }
+ .contact-card { padding: 18px; gap: 12px; }
+}
+</style>
diff --git a/app/pages/index.vue b/app/pages/index.vue
index df5d8a9..2a31f7e 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -33,6 +33,30 @@
<!-- 用户信息区域 -->
<div class="user-section">
<ClientOnly>
+ <!-- 主题切换按钮 -->
+ <button
+ class="utility-btn theme-toggle-btn"
+ type="button"
+ aria-label="主题"
+ title="切换主题"
+ @click="handleToggleTheme"
+ >
+ <Icon :name="isDark ? 'sun' : 'moon'" :size="18" />
+ <span class="utility-btn-label">主题</span>
+ </button>
+
+ <!-- 语言切换按钮(占位按钮,当前单语种) -->
+ <button
+ class="utility-btn lang-toggle-btn"
+ type="button"
+ aria-label="语言"
+ title="语言"
+ @click="handleLanguageClick"
+ >
+ <Icon name="globe" :size="18" />
+ <span class="utility-btn-label">语言</span>
+ </button>
+
<div v-if="isClientAuthenticated" class="user-info">
<div class="user-details-desktop">
<span class="user-name">{{ user?.name || '用户' }}</span>
@@ -674,12 +698,24 @@ import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useNotifications } from '~/composables/useNotifications'
import { useSiteConfig } from '~/composables/useSiteConfig'
+import { useTheme } from '~/composables/useTheme'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
// 获取运行时配置
const config = useRuntimeConfig()
const router = useRouter()
+// 主题
+const { isDark, toggleTheme } = useTheme()
+const handleToggleTheme = () => {
+ toggleTheme()
+}
+const handleLanguageClick = () => {
+ if (typeof window !== 'undefined' && window.$showNotification) {
+ window.$showNotification('当前版本暂不支持多语言切换', 'info')
+ }
+}
+
// 站点配置
const {
siteTitle,
@@ -1709,6 +1745,55 @@ if (
.user-section {
position: relative;
z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* 通用工具按钮(主题/语言) */
+.utility-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ background: var(--utility-btn-bg, rgba(255, 255, 255, 0.06));
+ color: var(--utility-btn-color, rgba(255, 255, 255, 0.78));
+ border: 1px solid var(--utility-btn-border, rgba(255, 255, 255, 0.12));
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ color 0.2s ease,
+ border-color 0.2s ease,
+ transform 0.15s ease;
+ backdrop-filter: blur(6px);
+}
+
+.utility-btn:hover {
+ background: var(--utility-btn-hover-bg, rgba(255, 255, 255, 0.12));
+ color: var(--utility-btn-hover-color, #ffffff);
+ border-color: var(--utility-btn-hover-border, rgba(255, 255, 255, 0.22));
+}
+
+.utility-btn:active {
+ transform: scale(0.97);
+}
+
+.utility-btn-label {
+ white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+ .utility-btn-label {
+ display: none;
+ }
+ .utility-btn {
+ padding: 7px 8px;
+ border-radius: 9px;
+ }
}
.user-info {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index d68f3fc..6964a0d 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -192,7 +192,11 @@ export default defineNuxtConfig({
{ name: 'theme-color', content: '#111111' },
{
name: 'x-default-theme',
- content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'System'
+ content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight'
+ },
+ {
+ name: 'x-enabled-themes',
+ content: process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight'
},
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
@@ -206,18 +210,28 @@ export default defineNuxtConfig({
tagPosition: 'head',
children: [
`(function(){try{`,
- ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null;`,
- ` try{s=localStorage.getItem(k)}catch(e){s=null}`,
- ` if(!s){`,
- ` try{`,
- ` var meta=document.querySelector('meta[name="x-default-theme"]');`,
- ` if(meta && meta.content){s=meta.content}`,
- ` }catch(e){s=null}`,
- ` if(!s){s='System'}`,
+ ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null, def=null, list=[];`,
+ ` try{`,
+ ` var mt=d.querySelector('meta[name="x-enabled-themes"]');`,
+ ` if(mt && mt.content){list=String(mt.content).split(',').map(function(x){return x.replace(/^\\s+|\\s+$/g,'')}).filter(Boolean)}`,
+ ` }catch(e){list=[]}`,
+ ` try{`,
+ ` var md=d.querySelector('meta[name="x-default-theme"]');`,
+ ` if(md && md.content){def=md.content}`,
+ ` }catch(e){def=null}`,
+ ` if(!def){def='ClassicLight'}`,
+ ` if(list.length===0){list=['ClassicLight','ModernLight']}`,
+ ` function valid(t){`,
+ ` if(list.indexOf(t)!==-1){return t}`,
+ ` if(list.indexOf(def)!==-1){return def}`,
+ ` return list[0]`,
` }`,
- ` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
+ ` try{s=localStorage.getItem(k)}catch(e){s=null}`,
+ ` if(!s){s=def}`,
+ ` else{s=valid(s)}`,
+ ` if(typeof s==='string' && (s==='System'||s==='system')){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
- ` else{eff='dark'}`,
+ ` else{eff='light'}`,
` r.setAttribute('data-theme', (typeof s==='string'?s:eff));`,
` if(eff==='dark'){r.classList.add('dark'); r.classList.remove('light'); r.style.colorScheme='dark'}`,
` else if(eff==='light'){r.classList.add('light'); r.classList.remove('dark'); r.style.colorScheme='light'}`,
diff --git a/server/api/site-config.get.ts b/server/api/site-config.get.ts
index 0b427a6..0ad6af9 100644
--- a/server/api/site-config.get.ts
+++ b/server/api/site-config.get.ts
@@ -77,7 +77,8 @@ export default defineEventHandler(async (event) => {
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: process.env.NUXT_PUBLIC_SITE_VERSION || null,
submissionGuidelines: '请遵守校园规定,提交健康向上的歌曲。',
- icpNumber: null,
+ icpNumber: '粤ICP备2025450093号',
+ gonganNumber: null,
enableSubmissionLimit: false,
dailySubmissionLimit: null,
weeklySubmissionLimit: null,
diff --git a/server/utils/system-settings-defaults.ts b/server/utils/system-settings-defaults.ts
index 2627dca..73e2251 100644
--- a/server/utils/system-settings-defaults.ts
+++ b/server/utils/system-settings-defaults.ts
@@ -1,6 +1,8 @@
export const SYSTEM_SETTINGS_DEFAULTS = {
siteTitle: '校园广播站点歌系统',
siteVersion: '1.7.1.0',
+ icpNumber: '粤ICP备2025450093号',
+ gonganNumber: '',
enableSubmissionLimit: false,
showBlacklistKeywords: false,
hideStudentInfo: true,
--
2.45.1.windows.1