feat: add glass theme

This commit is contained in:
jxxghp
2026-07-22 15:00:11 +08:00
parent 5cf0dc116a
commit 17cdfe6885
14 changed files with 706 additions and 64 deletions
+11 -1
View File
@@ -296,6 +296,7 @@
}
}
// 安全读取本地存储,隐私模式或存储不可用时返回空值。
function getLocalStorageValue(key) {
try {
return localStorage.getItem(key)
@@ -322,12 +323,18 @@
background: '#1C1C1C',
primary: '#A370F7',
},
glass: {
background: '#0B1322',
primary: '#E4B863',
},
}
// 读取已保存的主题偏好,缺省时跟随系统。
function getSavedThemePreference() {
return getLocalStorageValue('theme') || 'auto'
}
// 将启动阶段的主题偏好解析为实际主题。
function resolveLaunchTheme(themePreference) {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
@@ -340,16 +347,19 @@
return launchThemePalettes[themePreference] ? themePreference : 'light'
}
// 返回启动阶段浏览器原生控件应使用的明暗配色。
function getLaunchColorScheme(themeName) {
return ['dark', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light'
return ['dark', 'glass', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light'
}
// 批量同步匹配选择器的 meta 内容。
function setMetaContent(selector, content) {
document.querySelectorAll(selector).forEach(meta => {
meta.setAttribute('content', content)
})
}
// 更新或创建浏览器主题色 meta 标签。
function syncThemeColorMeta(themeColor) {
const metas = document.querySelectorAll('meta[name="theme-color"]')
+25 -5
View File
@@ -73,10 +73,13 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
)
const transparentBackgroundBlur = ref(16)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
@@ -434,7 +437,7 @@ function startBackgroundRotation() {
}
}
// 停止登录页透明主题背景图加载、重试和轮播。
// 停止登录页透明主题或玻璃主题背景图加载、重试和轮播。
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
@@ -597,7 +600,7 @@ onMounted(async () => {
stopBackgroundLoading()
if (shouldLoad) {
loadBackgroundImages()
} else if (!isTransparentTheme.value) {
} else if (!isBackdropTheme.value) {
backgroundImages.value = []
}
},
@@ -652,12 +655,13 @@ onUnmounted(() => {
<template>
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
<!-- 透明主题背景 -->
<!-- 登录页透明主题和玻璃主题共用动态壁纸场景 -->
<div
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
class="background-container"
:class="{
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
'is-glass-theme': shouldUseGlassBackgroundTreatment,
'is-transparent-glass-lightweight':
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
}"
@@ -737,6 +741,22 @@ onUnmounted(() => {
opacity: var(--transparent-background-poster-opacity, 1);
}
.background-container.is-glass-theme .background-image.active,
.background-container.is-glass-theme .background-image.previous {
filter: brightness(0.78) saturate(0.9);
}
.background-container.is-glass-theme .background-image.active {
opacity: 0.88;
}
.background-container.is-glass-theme .background-image.active::after,
.background-container.is-glass-theme .background-image.previous::after {
background:
linear-gradient(rgba(6, 10, 19, 26%) 0%, rgba(6, 10, 19, 55%) 100%),
rgba(11, 19, 34, 8%);
}
.background-container.is-transparent-glass-lightweight .background-image.active,
.background-container.is-transparent-glass-lightweight .background-image.previous {
filter: blur(var(--transparent-background-blur, 16px));
+6
View File
@@ -81,6 +81,7 @@ const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeC
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
{ title: t('theme.glass'), value: 'glass', icon: 'mdi-blur-radial' },
])
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
@@ -127,6 +128,9 @@ const layoutOptions = computed<Array<{ icon: string; title: string; value: Theme
const showLayoutSection = computed(() => !appMode.value)
// 玻璃主题使用成套的外阴影与内高光,不提供不完整的 Vuetify elevation 调整。
const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
const hasAppModeCustomization = computed(() => {
return (
settings.value.primaryColor !== defaultPrimaryColor ||
@@ -325,6 +329,7 @@ async function handleResetSettings() {
</div>
</div>
<template v-if="showShadowSection">
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
@@ -365,6 +370,7 @@ async function handleResetSettings() {
<span>24</span>
</div>
</div>
</template>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
+36 -10
View File
@@ -57,7 +57,7 @@ export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
export type ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
export interface ThemeCustomizerSettings {
layout: ThemeCustomizerLayout
@@ -72,11 +72,12 @@ export interface ThemeCustomizerSettings {
type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const glassDefaultPrimaryColor = '#E4B863'
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent', 'glass']
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
high: '24',
low: '6',
@@ -86,14 +87,17 @@ const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
let themeApplyVersion = 0
/** 判断当前代码是否运行在浏览器环境。 */
function isBrowser() {
return typeof window !== 'undefined'
}
/** 校验主题主色是否为完整的六位十六进制颜色。 */
function isHexColor(color: unknown): color is string {
return typeof color === 'string' && /^#[\da-f]{6}$/i.test(color)
}
/** 读取并校验本地存储中的主题偏好。 */
function readStoredThemePreference(): ThemeCustomizerTheme {
if (!isBrowser()) return 'auto'
@@ -102,6 +106,7 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto'
}
/** 生成与当前主题偏好一致的定制器默认设置。 */
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
return {
layout: 'vertical',
@@ -122,6 +127,7 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
return getDefaultThemeCustomizerSettings().shadow
}
/** 规范化持久化的主题定制设置并迁移旧值。 */
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
@@ -171,12 +177,14 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
/** 将完整主题定制设置写入本地存储。 */
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
if (!isBrowser()) return
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify(settings))
}
/** 广播主题定制设置变更,供布局与菜单同步响应。 */
function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
if (!isBrowser()) return
@@ -187,6 +195,7 @@ function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
)
}
/** 根据背景亮度选择可读的前景色。 */
function getTextColorForHex(backgroundColor: string) {
const hex = backgroundColor.replace('#', '')
const red = Number.parseInt(hex.slice(0, 2), 16)
@@ -197,20 +206,22 @@ function getTextColorForHex(backgroundColor: string) {
return luminance > 0.68 ? '#3A3541' : '#FFFFFF'
}
/** 将主色写入 Vuetify 运行时主题,所有已注册主题会同步更新。 */
/** 将主色写入 Vuetify 运行时主题,默认主色下保留玻璃主题的香槟强调色。 */
export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyThemeApi) {
if (!isHexColor(color)) return
const onPrimaryColor = getTextColorForHex(color)
for (const [themeName, themeDefinition] of Object.entries(themeApi.themes.value)) {
const themePrimaryColor = themeName === 'glass' && color === defaultPrimaryColor ? glassDefaultPrimaryColor : color
for (const themeDefinition of Object.values(themeApi.themes.value)) {
themeDefinition.colors.primary = color
themeDefinition.colors['on-primary'] = onPrimaryColor
themeDefinition.colors.primary = themePrimaryColor
themeDefinition.colors['on-primary'] = getTextColorForHex(themePrimaryColor)
}
document.documentElement.style.setProperty('--initial-loader-color', color)
localStorage.setItem('materio-initial-loader-color', color)
syncThemeFavicon(color)
const activePrimaryColor = themeApi.current.value.colors.primary
document.documentElement.style.setProperty('--initial-loader-color', activePrimaryColor)
localStorage.setItem('materio-initial-loader-color', activePrimaryColor)
syncThemeFavicon(activePrimaryColor)
}
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
@@ -231,6 +242,7 @@ export function applyThemeCustomizerRootSettings(
document.body.setAttribute('data-theme-skin', settings.skin)
}
/** 将自动主题偏好解析为当前系统对应的实际主题。 */
function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
@@ -239,11 +251,13 @@ function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
return themePreference
}
/** 将实际主题名称同步到文档根节点和 body。 */
function syncThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
document.body.setAttribute('data-theme', themeName)
}
/** 应用主题偏好并避免异步样式加载造成旧主题回写。 */
async function applyThemePreference(themePreference: ThemeCustomizerTheme, themeApi: VuetifyThemeApi) {
const currentVersion = ++themeApplyVersion
const resolvedTheme = getResolvedThemeName(themePreference)
@@ -270,6 +284,7 @@ export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi)
return settings
}
/** 持久化部分主题定制设置并同步当前页面外观。 */
export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustomizerSettings>) {
const nextSettings = normalizeThemeCustomizerSettings({
...readThemeCustomizerSettings(),
@@ -285,6 +300,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
return nextSettings
}
/** 判断当前主题定制设置是否仍为默认值。 */
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const defaults = normalizeThemeCustomizerSettings({
layout: 'vertical',
@@ -312,6 +328,7 @@ export function useThemeCustomizer() {
const themeApi = useTheme()
const settings = settingsState
/** 合并、保存并应用一组主题定制设置。 */
async function updateSettings(patch: Partial<ThemeCustomizerSettings>) {
const previousTheme = settings.value.theme
const nextSettings = normalizeThemeCustomizerSettings({
@@ -334,34 +351,42 @@ export function useThemeCustomizer() {
dispatchThemeCustomizerChange(nextSettings)
}
/** 更新主题主色。 */
function setPrimaryColor(color: string) {
return updateSettings({ primaryColor: color })
}
/** 更新全局圆角档位。 */
function setRadius(radius: ThemeCustomizerRadius) {
return updateSettings({ radius })
}
/** 更新当前主题。 */
function setTheme(theme: ThemeCustomizerTheme) {
return updateSettings({ theme })
}
/** 更新全局阴影档位。 */
function setShadow(shadow: ThemeCustomizerShadow) {
return updateSettings({ shadow })
}
/** 更新表面皮肤。 */
function setSkin(skin: ThemeCustomizerSkin) {
return updateSettings({ skin })
}
/** 更新桌面布局模式。 */
function setLayout(layout: ThemeCustomizerLayout) {
return updateSettings({ layout })
}
/** 更新浅色主题的半深色侧栏设置。 */
function setSemiDarkMenu(semiDarkMenu: boolean) {
return updateSettings({ semiDarkMenu })
}
/** 将主题定制器恢复到默认设置。 */
async function resetSettings() {
await updateSettings({
layout: 'vertical',
@@ -374,6 +399,7 @@ export function useThemeCustomizer() {
})
}
/** 在自动主题模式下响应系统配色变化。 */
function handleSystemThemeChange() {
if (settings.value.theme === 'auto') {
updateSettings({ theme: 'auto' })
@@ -315,6 +315,11 @@ const themes: ThemeSwitcherTheme[] = [
title: t('theme.transparent'),
icon: 'mdi-gradient-horizontal',
},
{
name: 'glass',
title: t('theme.glass'),
icon: 'mdi-blur-radial',
},
]
function getThemeLayoutTitle(layout: ThemeCustomizerSettings['layout']) {
+1
View File
@@ -146,6 +146,7 @@ export default {
auto: 'Follow System',
autoUI: 'Auto',
transparent: 'Transparent',
glass: 'Glass',
purple: 'Purple',
custom: 'Custom Style',
transparency: 'Transparency',
+1
View File
@@ -144,6 +144,7 @@ export default {
auto: '跟随系统',
autoUI: '自动',
transparent: '透明',
glass: '玻璃',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',
+1
View File
@@ -144,6 +144,7 @@ export default {
auto: '跟隨系統',
autoUI: '自動',
transparent: '透明',
glass: '玻璃',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',
+55
View File
@@ -210,6 +210,61 @@ const theme: VuetifyOptions['theme'] = {
'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',
},
},
glass: {
dark: true,
colors: {
'primary': '#E4B863',
'secondary': '#7C9CC4',
'on-secondary': '#07101C',
'success': '#69C59A',
'info': '#7C9CC4',
'warning': '#E4B863',
'error': '#FF7B7B',
'on-primary': '#171106',
'on-success': '#07150F',
'on-warning': '#171106',
'background': '#0B1322',
'on-background': '#F2F5FA',
'surface': '#182235',
'on-surface': '#F2F5FA',
'surface-variant': '#202C42',
'on-surface-variant': 'rgba(242, 245, 250, 0.72)',
'grey-50': '#121D30',
'grey-100': '#18253A',
'grey-200': '#233149',
'grey-300': '#34435D',
'grey-400': '#8794A8',
'grey-500': '#A1ACBD',
'grey-600': '#BBC3D0',
'grey-700': '#CDD3DD',
'grey-800': '#E0E4EA',
'grey-900': '#F2F5FA',
'perfect-scrollbar-thumb': 'rgba(255, 255, 255, 0.24)',
'skin-bordered-background': '#111B2B',
'skin-bordered-surface': '#182235',
'card-background': '#182235',
},
variables: {
'code-color': '#F3DCA8',
'overlay-scrim-background': '#030712',
'overlay-scrim-opacity': 0.66,
'hover-opacity': 0.08,
'focus-opacity': 0.14,
'selected-opacity': 0.16,
'activated-opacity': 0.14,
'pressed-opacity': 0.18,
'dragged-opacity': 0.14,
'border-color': '#FFFFFF',
'table-header-background': 'rgba(255, 255, 255, 0.06)',
'custom-background': 'rgba(255, 255, 255, 0.06)',
'card-background': '#182235',
// Shadows
'shadow-key-umbra-opacity': 'rgba(3, 7, 18, 0.34)',
'shadow-key-penumbra-opacity': 'rgba(3, 7, 18, 0.24)',
'shadow-key-ambient-opacity': 'rgba(3, 7, 18, 0.16)',
},
},
},
}
+485
View File
@@ -0,0 +1,485 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable scss/at-rule-no-unknown */
// 玻璃主题通过 Vuetify 全局表面类统一塑造材质业务页面无需逐个接入
html[data-theme='glass'] {
--glass-surface: rgba(255, 255, 255, 8%);
--glass-surface-soft: rgba(255, 255, 255, 5%);
--glass-surface-raised: rgba(255, 255, 255, 12%);
--glass-border: rgba(255, 255, 255, 15%);
--glass-border-hover: rgba(255, 255, 255, 28%);
--glass-highlight: rgba(255, 255, 255, 22%);
--glass-shadow:
0 16px 40px rgba(3, 7, 18, 50%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 35%);
--glass-shadow-raised:
0 20px 52px rgba(3, 7, 18, 58%), 0 0 0 1px rgba(255, 255, 255, 5%), inset 0 1px 0 rgba(255, 255, 255, 25%),
inset 0 -1px 0 rgba(2, 6, 16, 38%);
--glass-shadow-hover:
0 22px 60px rgba(3, 7, 18, 64%), 0 0 0 1px rgba(255, 255, 255, 8%), inset 0 1px 0 rgba(255, 255, 255, 32%),
inset 0 -1px 0 rgba(2, 6, 16, 40%);
--glass-blur: 40px;
--glass-blur-raised: 60px;
--glass-saturate: 180%;
--glass-motion: 500ms cubic-bezier(0.16, 1, 0.3, 1);
--app-card-rest-shadow: var(--glass-shadow);
--app-card-hover-shadow: var(--glass-shadow-hover);
--app-fab-shadow: var(--glass-shadow);
--app-fab-shadow-strong: var(--glass-shadow-raised);
--app-fab-shadow-hover: var(--glass-shadow-hover);
--app-fab-shadow-strong-hover: var(--glass-shadow-hover);
--app-fab-shadow-active: 0 8px 22px rgba(3, 7, 18, 48%), inset 0 1px 0 rgba(255, 255, 255, 18%);
--app-overlay-shadow: var(--glass-shadow-raised);
--app-surface-shadow: var(--glass-shadow);
--app-surface-hover-shadow: var(--glass-shadow-hover);
--app-surface-border-width: 1px;
--app-surface-border-opacity: 0.15;
--app-surface-border: 1px solid var(--glass-border);
--app-card-light-border-width: 1px;
--app-card-light-border-opacity: 0.15;
--app-card-light-border: 1px solid var(--glass-border);
--app-overlay-border: 1px solid var(--glass-border);
--app-grouped-list-background: var(--glass-surface);
--app-grouped-list-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
--app-grouped-list-border: 1px solid var(--glass-border);
--app-grouped-list-separator-color: rgba(255, 255, 255, 10%);
--app-grouped-list-header-color: rgba(242, 245, 250, 68%);
--app-grouped-list-chevron-color: rgba(242, 245, 250, 56%);
--app-grouped-list-hover-background: rgba(255, 255, 255, 8%);
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%);
--mp-motion-duration-page: 500ms;
--mp-motion-duration-overlay: 500ms;
--mp-motion-ease-standard: cubic-bezier(0.16, 1, 0.3, 1);
background-color: #0b1322;
color-scheme: dark;
body {
background-color: #0b1322;
}
&[data-theme-radius='none'] {
--app-theme-surface-radius: 8px;
--app-field-radius: 8px;
--app-control-radius: 8px;
--app-overlay-radius: 10px;
}
&[data-theme-radius='small'] {
--app-theme-surface-radius: 12px;
--app-field-radius: 12px;
--app-control-radius: 10px;
--app-overlay-radius: 14px;
}
&[data-theme-radius='default'] {
--app-theme-surface-radius: 20px;
--app-field-radius: 16px;
--app-control-radius: 14px;
--app-overlay-radius: 22px;
}
&[data-theme-radius='large'] {
--app-theme-surface-radius: 24px;
--app-field-radius: 20px;
--app-control-radius: 16px;
--app-overlay-radius: 26px;
}
&[data-theme-radius='extra'] {
--app-theme-surface-radius: 28px;
--app-field-radius: 24px;
--app-control-radius: 18px;
--app-overlay-radius: 30px;
}
.app-wrapper::before {
position: fixed;
z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.7'/%3E%3C/svg%3E");
block-size: 100%;
content: '';
inline-size: 100%;
inset: 0;
opacity: 0.025;
pointer-events: none;
}
.v-application,
.v-layout,
.v-main,
.layout-wrapper,
.layout-page-content,
.layout-content-wrapper,
.page-content-container {
background: transparent;
}
.v-application {
position: relative;
z-index: 1;
}
.layout-page-content {
background-color: rgba(255, 255, 255, 2.5%);
}
:where(
.app-surface:not(.no-blur),
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-sheet:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-expansion-panel,
.v-stepper,
.v-table
) {
border-color: var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
background-color: var(--glass-surface) !important;
box-shadow: var(--glass-shadow) !important;
}
:where(.v-list, .v-toolbar, .v-footer) {
border-color: var(--glass-border);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
background-color: var(--glass-surface) !important;
}
:where(
.layout-vertical-nav,
.layout-navbar,
.footer-nav-card,
.theme-customizer-panel-host,
.agent-assistant-panel,
.Vue-Toastification__toast
) {
border-color: var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
background-color: var(--glass-surface-raised) !important;
box-shadow: var(--glass-shadow-raised) !important;
}
.layout-vertical-nav {
border-inline-end: 1px solid var(--glass-border);
}
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
border-color: var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
background: var(--glass-surface-raised) !important;
box-shadow: var(--glass-shadow-raised) !important;
}
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar .navbar-content-container,
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
.theme-customizer-panel,
.agent-assistant-shell {
background-color: transparent !important;
}
:where(.theme-customizer-border-scene, .theme-customizer-radius-scene) {
border-color: var(--glass-border) !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 4%), rgba(255, 255, 255, 7%)),
rgba(255, 255, 255, 3%) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 12%),
inset 0 -1px 0 rgba(2, 6, 16, 18%);
}
:where(
.theme-customizer-border-scene__card,
.theme-customizer-border-scene__dialog,
.theme-customizer-border-scene__menu,
.theme-customizer-radius-scene__card
) {
border-color: rgba(255, 255, 255, 18%);
background: rgba(255, 255, 255, 7%) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 14%),
inset 0 -1px 0 rgba(2, 6, 16, 20%);
}
.theme-customizer-preview-option.is-active
> :where(.theme-customizer-border-scene, .theme-customizer-radius-scene) {
border-color: rgb(var(--v-theme-primary)) !important;
background: rgba(var(--v-theme-primary), 8%) !important;
}
.agent-assistant-shell {
--agent-assistant-panel-bg: var(--glass-surface-raised);
--agent-assistant-panel-blur: var(--glass-blur-raised);
--agent-assistant-assistant-bg: var(--glass-surface);
}
.agent-assistant-fab__bubble,
.nav-button,
.compact-fab .v-btn,
.global-action-buttons .v-btn {
border: 1px solid var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
background-color: var(--glass-surface-raised) !important;
box-shadow: var(--glass-shadow) !important;
}
.v-overlay__scrim {
-webkit-backdrop-filter: blur(12px) saturate(120%);
backdrop-filter: blur(12px) saturate(120%);
background: rgba(3, 7, 18, 62%);
}
.v-dialog--fullscreen {
background-color: transparent !important;
}
.v-overlay__content {
border-radius: var(--app-overlay-radius);
> :where(.v-card, .v-sheet, .v-list) {
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
background-color: var(--glass-surface-raised) !important;
box-shadow: var(--glass-shadow-raised) !important;
}
}
.v-tooltip > .v-overlay__content,
.v-snackbar__wrapper {
border: 1px solid var(--glass-border);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
background: rgba(11, 19, 34, 76%) !important;
box-shadow: var(--glass-shadow);
}
.v-field {
--v-field-border-opacity: 0.18;
--v-field-border-width: 1px;
border-radius: var(--app-field-radius);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
background-color: var(--glass-surface-soft) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 15%),
inset 0 -1px 0 rgba(2, 6, 16, 30%);
transition:
background-color var(--glass-motion),
box-shadow var(--glass-motion);
}
.v-field--focused {
--v-field-border-opacity: 0.36;
background-color: rgba(255, 255, 255, 10%) !important;
box-shadow:
0 0 0 3px rgba(var(--v-theme-primary), 15%),
inset 0 1px 0 rgba(255, 255, 255, 25%);
}
.v-field.v-field--variant-underlined,
.v-field.v-field--variant-plain {
border-radius: 0;
-webkit-backdrop-filter: none;
backdrop-filter: none;
background-color: transparent !important;
box-shadow: none !important;
}
.v-field.v-field--variant-underlined.v-field--focused,
.v-field.v-field--variant-plain.v-field--focused {
background-color: transparent !important;
box-shadow: none !important;
}
.v-field input::placeholder,
.v-field textarea::placeholder {
color: rgba(242, 245, 250, 38%);
opacity: 1;
}
.v-btn {
letter-spacing: 0;
transition:
background-color var(--glass-motion),
border-color var(--glass-motion),
box-shadow var(--glass-motion),
transform var(--glass-motion);
}
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
border: 1px solid rgba(255, 255, 255, 20%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
background-color: rgba(255, 255, 255, 10%) !important;
box-shadow:
0 4px 16px rgba(3, 7, 18, 45%),
inset 0 1px 0 rgba(255, 255, 255, 25%),
inset 0 -1px 0 rgba(2, 6, 16, 30%) !important;
}
.v-btn.bg-primary {
border-color: rgba(var(--v-theme-primary), 48%);
background-color: rgba(var(--v-theme-primary), 34%) !important;
}
.v-btn.bg-success {
border-color: rgba(var(--v-theme-success), 48%);
background-color: rgba(var(--v-theme-success), 34%) !important;
}
.v-btn.bg-info {
border-color: rgba(var(--v-theme-info), 48%);
background-color: rgba(var(--v-theme-info), 34%) !important;
}
.v-btn.bg-warning {
border-color: rgba(var(--v-theme-warning), 48%);
background-color: rgba(var(--v-theme-warning), 34%) !important;
}
.v-btn.bg-error {
border-color: rgba(var(--v-theme-error), 48%);
background-color: rgba(var(--v-theme-error), 34%) !important;
}
:where(.v-btn, .v-card--link, [role='button']):focus-visible {
outline: 0;
box-shadow:
0 0 0 3px rgba(var(--v-theme-primary), 18%),
var(--glass-shadow) !important;
}
.v-chip {
border-color: rgba(255, 255, 255, 16%);
background-color: rgba(255, 255, 255, 7%);
}
.v-table {
.v-table__wrapper > table > thead {
background-color: rgba(255, 255, 255, 6%);
}
.v-table__wrapper > table > tbody > tr:hover > td {
background-color: rgba(255, 255, 255, 6%);
}
}
.v-skeleton-loader {
background-color: var(--glass-surface-soft);
}
.app-card-colorful {
--app-card-accent-start-opacity: 0.018;
--app-card-accent-end-opacity: 0.008;
--app-card-border-opacity: 0.15;
--app-card-hover-border-opacity: 0.28;
--app-card-stripe-opacity: 0.12;
--app-card-surface-opacity: 0.08;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
}
:where(.v-card, .v-sheet)
:where(.app-surface, .v-card, .v-sheet, .v-list, .v-table, .v-expansion-panel, .v-stepper) {
border-color: transparent !important;
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: none !important;
}
:where(.v-card, .v-sheet)
:where(
.app-surface:not(.bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-card:not(.bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-sheet:not(.bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-list,
.v-table,
.v-expansion-panel,
.v-stepper
) {
background-color: transparent !important;
}
@media (hover: hover) and (pointer: fine) {
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover {
border-color: var(--glass-border-hover);
background-color: rgba(255, 255, 255, 15%) !important;
box-shadow:
0 10px 32px rgba(3, 7, 18, 55%),
inset 0 1px 0 rgba(255, 255, 255, 35%) !important;
}
.v-btn.bg-primary:hover {
border-color: rgba(var(--v-theme-primary), 62%);
background-color: rgba(var(--v-theme-primary), 42%) !important;
}
.v-btn.bg-success:hover {
border-color: rgba(var(--v-theme-success), 62%);
background-color: rgba(var(--v-theme-success), 42%) !important;
}
.v-btn.bg-info:hover {
border-color: rgba(var(--v-theme-info), 62%);
background-color: rgba(var(--v-theme-info), 42%) !important;
}
.v-btn.bg-warning:hover {
border-color: rgba(var(--v-theme-warning), 62%);
background-color: rgba(var(--v-theme-warning), 42%) !important;
}
.v-btn.bg-error:hover {
border-color: rgba(var(--v-theme-error), 62%);
background-color: rgba(var(--v-theme-error), 42%) !important;
}
.v-card--link:hover {
border-color: var(--glass-border-hover) !important;
box-shadow: var(--glass-shadow-hover) !important;
}
}
.v-btn:active {
transform: scale(0.97);
}
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
:where(
.app-surface,
.v-card,
.v-sheet,
.v-list,
.v-toolbar,
.v-expansion-panel,
.layout-vertical-nav,
.layout-navbar,
.theme-customizer-panel-host,
.agent-assistant-panel
) {
background-color: rgba(18, 28, 45, 94%) !important;
}
}
@media (prefers-reduced-motion: reduce) {
.v-btn,
.v-card,
.v-field,
.background-image {
transition-duration: 0.01ms !important;
}
}
}
+16 -1
View File
@@ -1,7 +1,22 @@
import { applyDocumentThemeChrome } from '@/utils/themePalette'
import {
applyDocumentThemeChrome,
getThemeColorScheme,
resolveThemeName,
themeRootPalettes,
} from '@/utils/themePalette'
import { describe, expect, it, vi } from 'vitest'
describe('theme palette', () => {
// 玻璃主题在首屏与运行阶段都应保持深色外壳和夜航底色。
it('resolves glass as a dark theme with its dedicated launch palette', () => {
expect(resolveThemeName('glass')).toBe('glass')
expect(getThemeColorScheme('glass')).toBe('dark')
expect(themeRootPalettes.glass).toEqual({
background: '#0B1322',
primary: '#E4B863',
})
})
it('notifies the favicon renderer with the applied primary color', () => {
const handleFaviconChange = vi.fn()
+2 -1
View File
@@ -4,13 +4,14 @@ declare global {
}
}
/** 根据当前 Vuetify 主题同步 ApexCharts 的全局明暗外观。 */
export function configureApexChartsTheme(themeName: string) {
if (typeof window === 'undefined' || !window.Apex) {
return
}
try {
const isDark = themeName === 'dark' || themeName === 'transparent'
const isDark = ['dark', 'glass', 'transparent'].includes(themeName)
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
+13 -6
View File
@@ -12,7 +12,12 @@ class ThemeManager {
private currentTheme: string = 'default'
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
private bundledThemeLoaders: Record<string, () => Promise<unknown>> = {
glass: () => import('@/styles/themes/glass.scss'),
transparent: () => import('@/styles/themes/transparent.scss'),
}
/** 注册内置主题及其按需加载的样式入口。 */
constructor() {
// 注册所有可用主题
this.registerTheme('default', '')
@@ -20,8 +25,9 @@ class ThemeManager {
this.registerTheme('dark', '')
this.registerTheme('purple', '')
this.registerTheme('auto', '')
// 只有透明主题有特定的CSS文件
// 透明和玻璃主题使用独立的按需样式文件
this.registerTheme('transparent', './src/styles/themes/transparent.css')
this.registerTheme('glass', './src/styles/themes/glass.css')
}
/**
@@ -80,9 +86,10 @@ class ThemeManager {
}
try {
// 动态导入CSS模块
if (themeName === 'transparent') {
await import('@/styles/themes/transparent.scss')
// 动态导入打包内置的主题样式模块
const bundledThemeLoader = this.bundledThemeLoaders[themeName]
if (bundledThemeLoader) {
await bundledThemeLoader()
this.themes.get(themeName)!.isLoaded = true
return
}
@@ -134,8 +141,8 @@ class ThemeManager {
const theme = this.themes.get(themeName)
if (!theme) return
// 对于动态导入的CSS,我们无法直接卸载,但可以标记为未加载
if (themeName === 'transparent') {
// 对于动态导入的 CSS,我们无法直接卸载,但可以标记为未加载
if (this.bundledThemeLoaders[themeName]) {
theme.isLoaded = false
return
}
+12 -3
View File
@@ -1,7 +1,7 @@
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
export type ThemePreference = 'auto' | 'default' | 'light' | 'dark' | 'purple' | 'transparent'
export type ResolvedThemeName = 'light' | 'dark' | 'purple' | 'transparent'
export type ThemePreference = 'auto' | 'dark' | 'default' | 'glass' | 'light' | 'purple' | 'transparent'
export type ResolvedThemeName = 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
export type ThemeColorScheme = 'light' | 'dark'
interface ThemeRootPalette {
@@ -33,14 +33,20 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
background: '#1C1C1C',
primary: '#A370F7',
},
glass: {
background: '#0B1322',
primary: '#E4B863',
},
}
const validResolvedThemes = new Set<string>(Object.keys(themeRootPalettes))
/** 将任意主题名称收敛为应用支持的实际主题。 */
function normalizeResolvedThemeName(themeName: string | null | undefined): ResolvedThemeName {
return validResolvedThemes.has(themeName || '') ? (themeName as ResolvedThemeName) : 'light'
}
/** 将用户主题偏好解析为当前实际主题。 */
export function resolveThemeName(themePreference: string | null | undefined): ResolvedThemeName {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
@@ -53,16 +59,19 @@ export function resolveThemeName(themePreference: string | null | undefined): Re
return normalizeResolvedThemeName(themePreference)
}
/** 返回浏览器原生控件应使用的明暗配色。 */
export function getThemeColorScheme(themeName: string | null | undefined): ThemeColorScheme {
return ['dark', 'purple', 'transparent'].includes(themeName || '') ? 'dark' : 'light'
return ['dark', 'glass', 'purple', 'transparent'].includes(themeName || '') ? 'dark' : 'light'
}
/** 批量同步匹配选择器的 meta 内容。 */
function setMetaContent(selector: string, content: string) {
document.querySelectorAll<HTMLMetaElement>(selector).forEach(meta => {
meta.content = content
})
}
/** 更新或创建浏览器主题色 meta 标签。 */
function ensureThemeColorMeta(themeColor: string) {
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')