From f36c1bd2b50f83f8b12908d81d6356ceafafb7c0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 25 Jul 2025 13:39:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E5=90=88=E4=B8=BB=E9=A2=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=99=A8=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 4 + src/layouts/components/UserProfile.vue | 25 +- src/styles/{custom.scss => common.scss} | 312 ++++++++---------------- src/styles/main.scss | 2 +- src/styles/themes/transparent.scss | 110 +++++++++ src/utils/themeManager.ts | 212 ++++++++++++++++ 6 files changed, 450 insertions(+), 215 deletions(-) rename src/styles/{custom.scss => common.scss} (67%) create mode 100644 src/styles/themes/transparent.scss create mode 100644 src/utils/themeManager.ts diff --git a/src/App.vue b/src/App.vue index be0c9ef8..38f873a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import { preloadImage } from './@core/utils/image' import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue' +import { themeManager } from '@/utils/themeManager' // 生效主题 const { global: globalTheme } = useTheme() @@ -212,6 +213,9 @@ onMounted(async () => { // 初始化data-theme属性 updateHtmlThemeAttribute(globalTheme.name.value) + // 初始化主题管理器 - 统一处理主题初始化 + await themeManager.setTheme(themeValue) + // 监听主题变化 watch( () => globalTheme.name.value, diff --git a/src/layouts/components/UserProfile.vue b/src/layouts/components/UserProfile.vue index 8d0efae5..df6a994f 100644 --- a/src/layouts/components/UserProfile.vue +++ b/src/layouts/components/UserProfile.vue @@ -14,6 +14,7 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n' import { saveLocalTheme } from '@/@core/utils/theme' import type { ThemeSwitcherTheme } from '@layouts/types' import { useConfirm } from '@/composables/useConfirm' +import { themeManager } from '@/utils/themeManager' // 认证 Store const authStore = useAuthStore() @@ -226,22 +227,30 @@ const themes: ThemeSwitcherTheme[] = [ const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai')) // 更新主题 -function updateTheme() { +async function updateTheme() { const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light' const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value + + // 设置Vuetify主题 globalTheme.name.value = theme + + // 统一处理主题切换 - 主题管理器会自动处理CSS加载和错误 + await themeManager.setTheme(currentThemeName.value) + // 保存原始主题设置,而不是计算后的值 savedTheme.value = currentThemeName.value // 保存主题到本地 saveLocalTheme(currentThemeName.value, globalTheme) - // 刷新页面 - location.reload() } // 切换主题 -function changeTheme(theme: string) { +async function changeTheme(theme: string) { currentThemeName.value = theme showThemeMenu.value = false + + // 立即更新主题(不再刷新页面) + await updateTheme() + // 保存主题到服务端 try { api.post('/user/config/Layout', { @@ -288,12 +297,16 @@ async function saveCustomCSS() { // 监听主题变化 watch( () => currentThemeName.value, - () => updateTheme(), + async () => { + await updateTheme() + }, ) // 监听系统主题变化 try { - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme) + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => { + await updateTheme() + }) } catch (e) { console.error(t('theme.deviceNotSupport')) } diff --git a/src/styles/custom.scss b/src/styles/common.scss similarity index 67% rename from src/styles/custom.scss rename to src/styles/common.scss index 89a636c8..c8d09d53 100644 --- a/src/styles/custom.scss +++ b/src/styles/common.scss @@ -1,9 +1,9 @@ -// Write your overrides +// 公共样式 - 所有主题都需要 @tailwind base; @tailwind components; @tailwind utilities; - +// 基础样式 html.v-overlay-scroll-blocked { position: fixed; position: relative; @@ -30,6 +30,7 @@ body { } } +// 进度条样式 #nprogress .bar { background: rgb(var(--v-theme-primary)) !important; inset-block-start: env(safe-area-inset-top) !important; @@ -38,15 +39,17 @@ body { #nprogress .peg { box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important; inline-size: 5px; - transform: rotate(0deg) translate(0, 0); + transform: rotate(0deg) translate(0, 0); } +// 卡片高度匹配 .match-height.v-row { .v-card { block-size: 100%; } } +// Toast通知样式 .Vue-Toastification__container { z-index: 2500; margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom); @@ -64,11 +67,12 @@ body { } } +// 对话框样式 .v-dialog > .v-overlay__content > .v-card > .v-card-item { padding: 16px; } -/* router view transition fade-slide */ +// 路由过渡动画 .fade-slide-leave-active, .fade-slide-enter-active { transition: all 0.6s; @@ -84,99 +88,13 @@ body { transform: translateY(45px); } +// 网格布局样式 .grid-info-card { grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); padding-block-end: 1rem; } -.text-moviepilot { - background-clip: text; - background-image: linear-gradient(to bottom right,var(--tw-gradient-stops)); - color: transparent; - - --tw-gradient-from: #818cf8; - --tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to); - --tw-gradient-to: #c084fc; -} - -.slider-header { - position: relative; - display: flex; -} - -.slider-title { - display: inline-flex; - align-items: center; - font-size: 1.25rem; - font-weight: 700; - line-height: 1.75rem; -} - -@media (width >= 640px){ - .slider-title { - overflow: hidden; - font-size: 1.5rem; - line-height: 2.25rem; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -// 美化滚动条 -::-webkit-scrollbar { - block-size: 4px; - inline-size: 4px; - opacity: 0; - transition: opacity 0.3s; -} - -::-webkit-scrollbar-thumb { - border-radius: 2px; - background: rgb(var(--v-theme-perfect-scrollbar-thumb)); - box-shadow: inset 0 0 10px rgba(0,0,0,20%); - - @media(hover){ - &:hover{ - background: #a1a1a1; - } - } -} - -// 当鼠标悬停在可滚动元素上时显示滚动条 -*:hover::-webkit-scrollbar { - opacity: 1; -} - -// 当元素正在滚动时显示滚动条 -*:active::-webkit-scrollbar { - opacity: 1; -} - -.v-alert--variant-elevated, .v-alert--variant-flat { - background: rgb(var(--v-table-header-background)); - color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); -} - -.backdrop-blur { - --tw-backdrop-blur: blur(8px)!important; - - backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important; -} - -.v-toolbar{ - background: rgb(var(--v-table-header-background)); -} - - .v-divider { - border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity)); - opacity:0.75; - } - - .apexcharts-title-text { - color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important; - } - - .grid-site-card { +.grid-site-card { grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); padding-block-end: 1rem; } @@ -233,6 +151,98 @@ body { grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); } +// 文本样式 +.text-moviepilot { + background-clip: text; + background-image: linear-gradient(to bottom right,var(--tw-gradient-stops)); + color: transparent; + + --tw-gradient-from: #818cf8; + --tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to); + --tw-gradient-to: #c084fc; +} + +.text-shadow { + text-shadow: 1px 1px #777; +} + +// 滑块标题样式 +.slider-header { + position: relative; + display: flex; +} + +.slider-title { + display: inline-flex; + align-items: center; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.75rem; +} + +@media (width >= 640px){ + .slider-title { + overflow: hidden; + font-size: 1.5rem; + line-height: 2.25rem; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// 滚动条样式 +::-webkit-scrollbar { + block-size: 4px; + inline-size: 4px; + opacity: 0; + transition: opacity 0.3s; +} + +::-webkit-scrollbar-thumb { + border-radius: 2px; + background: rgb(var(--v-theme-perfect-scrollbar-thumb)); + box-shadow: inset 0 0 10px rgba(0,0,0,20%); + + @media(hover){ + &:hover{ + background: #a1a1a1; + } + } +} + +*:hover::-webkit-scrollbar { + opacity: 1; +} + +*:active::-webkit-scrollbar { + opacity: 1; +} + +// 组件样式 +.v-alert--variant-elevated, .v-alert--variant-flat { + background: rgb(var(--v-table-header-background)); + color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); +} + +.backdrop-blur { + --tw-backdrop-blur: blur(8px)!important; + + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important; +} + +.v-toolbar{ + background: rgb(var(--v-table-header-background)); +} + +.v-divider { + border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity)); + opacity:0.75; +} + +.apexcharts-title-text { + color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important; +} + .v-tabs:not(.v-tabs-pill).v-tabs--horizontal { border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } @@ -241,10 +251,6 @@ body { padding-block-end: env(safe-area-inset-bottom); } -.text-shadow { - text-shadow: 1px 1px #777; -} - .card-cover-blurred::before { position: absolute; backdrop-filter: blur(2px); @@ -253,6 +259,7 @@ body { inset: 0; } +// 弹出层样式 .v-overlay__content .v-list{ backdrop-filter: blur(6px); background-color: rgb(var(--v-theme-surface), 0.9) !important; @@ -308,121 +315,10 @@ body { min-inline-size: auto; } - .v-infinite-scroll__side { padding: 0; } .v-menu .v-overlay__content { box-shadow: none !important; -} - -// 透明主题下的弹出窗口样式 -html[data-theme="transparent"] { - // 先将所有全局组件定义放在前面,避免CSS优先级问题 - .v-application, .v-layout, .v-main, .layout-page-content { - background: transparent; - } - - // 侧边导航栏 - .layout-vertical-nav { - backdrop-filter: blur(16px); - background-color: rgba(var(--v-theme-surface), 0.2); - border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.05); - } - - // 列表 - .v-list { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // 卡片 - .v-card:not(.no-blur) { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - - .v-list { - backdrop-filter: none; - background-color: transparent; - } - } - - // 工具栏 - .v-toolbar { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // 表格 - .v-table { - border-radius: 0; - background-color: rgba(var(--v-theme-surface), 0); - - .v-table__wrapper > table > thead { - background-color: rgba(var(--v-theme-surface), 0.3); - } - } - - // 页脚 - .v-footer { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // Sheet - .v-sheet { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // 页面容器 - .layout-content-wrapper { - background: transparent; - } - - // 无内容区域的背景设为透明 - .page-content-container { - background: transparent; - } - - // 对话框和菜单蒙层样式 - .v-overlay__scrim { - background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)); - } - - // 折叠面板 - .v-expansion-panel { - backdrop-filter: blur(10px); - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // 加载占位 - .v-skeleton-loader { - background-color: rgba(var(--v-theme-surface), 0.3); - } - - // 输入框和搜索框 - .v-field { - background-color: rgba(var(--v-theme-surface), 0); - } - - .v-overlay__content { - border-radius: 12px !important; - backdrop-filter: blur(10px) !important; - - .v-list { - backdrop-filter: blur(10px); - background-color: rgb(var(--v-theme-surface), 0.5) !important; - } - - .v-card:not(.bg-primary) { - backdrop-filter: blur(10px); - background-color: rgb(var(--v-theme-surface), 0.5) !important; - } - - .v-table__wrapper table thead { - background-color: rgba(var(--v-theme-surface), 0.3); - } - } -} +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 6f1e17b3..5f0be75f 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -5,7 +5,7 @@ @use '@core/scss/index' as template; @use '@layouts/styles/index' as layouts; @use 'vuetify/styles' as vuetify; -@use '@styles/custom' as custom; +@use '@styles/common' as common; /* 第三方库纯CSS样式 */ @import 'vue-toastification/dist/index.css'; diff --git a/src/styles/themes/transparent.scss b/src/styles/themes/transparent.scss new file mode 100644 index 00000000..9c3238e2 --- /dev/null +++ b/src/styles/themes/transparent.scss @@ -0,0 +1,110 @@ +// 透明主题专用样式 +html[data-theme="transparent"] { + // 应用、布局、主内容区域 + .v-application, .v-layout, .v-main, .layout-page-content { + background: transparent; + } + + // 侧边导航栏 + .layout-vertical-nav { + backdrop-filter: blur(16px); + background-color: rgba(var(--v-theme-surface), 0.2); + border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.05); + } + + // 列表 + .v-list { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // 卡片 + .v-card:not(.no-blur) { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + + .v-list { + backdrop-filter: none; + background-color: transparent; + } + } + + // 工具栏 + .v-toolbar { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // 表格 + .v-table { + border-radius: 0; + background-color: rgba(var(--v-theme-surface), 0); + + .v-table__wrapper > table > thead { + background-color: rgba(var(--v-theme-surface), 0.3); + } + } + + // 页脚 + .v-footer { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // Sheet + .v-sheet { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // 页面容器 + .layout-content-wrapper { + background: transparent; + } + + // 无内容区域的背景设为透明 + .page-content-container { + background: transparent; + } + + // 对话框和菜单蒙层样式 + .v-overlay__scrim { + background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)); + } + + // 折叠面板 + .v-expansion-panel { + backdrop-filter: blur(10px); + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // 加载占位 + .v-skeleton-loader { + background-color: rgba(var(--v-theme-surface), 0.3); + } + + // 输入框和搜索框 + .v-field { + background-color: rgba(var(--v-theme-surface), 0); + } + + // 弹出层内容 + .v-overlay__content { + border-radius: 12px !important; + backdrop-filter: blur(10px) !important; + + .v-list { + backdrop-filter: blur(10px); + background-color: rgb(var(--v-theme-surface), 0.5) !important; + } + + .v-card:not(.bg-primary) { + backdrop-filter: blur(10px); + background-color: rgb(var(--v-theme-surface), 0.5) !important; + } + + .v-table__wrapper table thead { + background-color: rgba(var(--v-theme-surface), 0.3); + } + } +} diff --git a/src/utils/themeManager.ts b/src/utils/themeManager.ts new file mode 100644 index 00000000..f19477e6 --- /dev/null +++ b/src/utils/themeManager.ts @@ -0,0 +1,212 @@ +// 主题管理器 - 动态加载主题CSS +export interface ThemeConfig { + name: string + cssPath: string + isLoaded: boolean +} + +class ThemeManager { + private themes: Map = new Map() + private currentTheme: string = 'default' + private loadedLinks: Map = new Map() + + constructor() { + // 注册所有可用主题 + this.registerTheme('default', '') + this.registerTheme('light', '') + this.registerTheme('dark', '') + this.registerTheme('purple', '') + this.registerTheme('auto', '') + // 只有透明主题有特定的CSS文件 + this.registerTheme('transparent', './src/styles/themes/transparent.css') + } + + /** + * 注册主题 + */ + registerTheme(name: string, cssPath: string): void { + this.themes.set(name, { + name, + cssPath, + isLoaded: false, + }) + } + + /** + * 获取当前主题 + */ + getCurrentTheme(): string { + return this.currentTheme + } + + /** + * 设置主题 + */ + async setTheme(themeName: string): Promise { + if (!this.themes.has(themeName)) { + console.warn(`Theme "${themeName}" not found`) + return + } + + const theme = this.themes.get(themeName)! + + // 清理其他主题的CSS(除了当前要设置的主题) + this.unloadOtherThemes() + + // 如果主题有CSS文件,则加载CSS + if (theme.cssPath) { + try { + await this.loadThemeCSS(themeName, theme.cssPath) + } catch (error) { + console.error(`Failed to load CSS for theme "${themeName}":`, error) + // 即使CSS加载失败,也继续应用主题(使用默认样式) + } + } + + // 应用主题(无论是否有CSS文件) + this.applyTheme(themeName) + } + + /** + * 加载主题CSS文件 + */ + private async loadThemeCSS(themeName: string, cssPath: string): Promise { + // 如果已经加载过,直接返回 + if (this.loadedLinks.has(themeName)) { + return + } + + try { + // 动态导入CSS模块 + if (themeName === 'transparent') { + await import('@/styles/themes/transparent.scss') + this.themes.get(themeName)!.isLoaded = true + return + } + + // 对于其他主题,使用传统的link方式 + const link = document.createElement('link') + link.rel = 'stylesheet' + link.type = 'text/css' + link.href = cssPath + link.id = `theme-${themeName}` + + // 等待CSS加载完成 + await new Promise((resolve, reject) => { + link.onload = () => { + this.loadedLinks.set(themeName, link) + this.themes.get(themeName)!.isLoaded = true + resolve() + } + link.onerror = () => { + reject(new Error(`Failed to load theme CSS: ${cssPath}`)) + } + }) + + // 添加到head + document.head.appendChild(link) + } catch (error) { + console.error(`Error loading theme "${themeName}":`, error) + throw error + } + } + + /** + * 应用主题到DOM + */ + private applyTheme(themeName: string): void { + // 移除之前的主题属性 + document.documentElement.removeAttribute('data-theme') + + // 设置新主题(除了default主题) + if (themeName !== 'default') { + document.documentElement.setAttribute('data-theme', themeName) + } + + this.currentTheme = themeName + + // 触发主题变更事件 + this.dispatchThemeChangeEvent(themeName) + } + + /** + * 卸载主题CSS + */ + unloadTheme(themeName: string): void { + const theme = this.themes.get(themeName) + if (!theme) return + + // 对于动态导入的CSS,我们无法直接卸载,但可以标记为未加载 + if (themeName === 'transparent') { + theme.isLoaded = false + return + } + + // 对于传统link方式加载的CSS + const link = this.loadedLinks.get(themeName) + if (link) { + link.remove() + this.loadedLinks.delete(themeName) + theme.isLoaded = false + } + } + + /** + * 卸载所有主题CSS(除了当前主题) + */ + unloadOtherThemes(): void { + for (const [themeName] of this.themes) { + if (themeName !== this.currentTheme && this.themes.get(themeName)?.isLoaded) { + this.unloadTheme(themeName) + } + } + } + + /** + * 获取已注册的主题列表 + */ + getAvailableThemes(): string[] { + return Array.from(this.themes.keys()) + } + + /** + * 检查主题是否已加载 + */ + isThemeLoaded(themeName: string): boolean { + return this.themes.get(themeName)?.isLoaded || false + } + + /** + * 触发主题变更事件 + */ + private dispatchThemeChangeEvent(themeName: string): void { + const event = new CustomEvent('themechange', { + detail: { theme: themeName }, + }) + document.dispatchEvent(event) + } + + /** + * 监听主题变更事件 + */ + onThemeChange(callback: (theme: string) => void): void { + document.addEventListener('themechange', (event: any) => { + callback(event.detail.theme) + }) + } + + /** + * 移除主题变更监听器 + */ + offThemeChange(callback: (theme: string) => void): void { + document.removeEventListener('themechange', (event: any) => { + callback(event.detail.theme) + }) + } +} + +// 创建单例实例 +export const themeManager = new ThemeManager() + +// 导出类型 +export type { ThemeManager }