-
- {{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}
- 0 - 24
-
-
-
{{ t('theme.customizer.shadow') }}
+
+
+ {{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}
+ 0 - 24
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+ 0
+ 24
+
-
- 0
- 24
-
-
+
{{ t('theme.customizer.semiDarkMenu') }}
diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts
index 8933d0a9..a2011ba8 100644
--- a/src/composables/useThemeCustomizer.ts
+++ b/src/composables/useThemeCustomizer.ts
@@ -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
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 = {
high: '24',
low: '6',
@@ -86,14 +87,17 @@ const legacyShadowMap: Record = {
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 {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
@@ -171,12 +177,14 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
const settingsState = ref(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) {
const nextSettings = normalizeThemeCustomizerSettings({
...readThemeCustomizerSettings(),
@@ -285,6 +300,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial) {
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' })
diff --git a/src/layouts/default/components/UserProfile.vue b/src/layouts/default/components/UserProfile.vue
index be5a02fb..eb12adcd 100644
--- a/src/layouts/default/components/UserProfile.vue
+++ b/src/layouts/default/components/UserProfile.vue
@@ -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']) {
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 58e12fd7..5a14e4e9 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -146,6 +146,7 @@ export default {
auto: 'Follow System',
autoUI: 'Auto',
transparent: 'Transparent',
+ glass: 'Glass',
purple: 'Purple',
custom: 'Custom Style',
transparency: 'Transparency',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 481800e7..02e205c0 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -144,6 +144,7 @@ export default {
auto: '跟随系统',
autoUI: '自动',
transparent: '透明',
+ glass: '玻璃',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',
diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts
index d89c1c1b..90c1417b 100644
--- a/src/locales/zh-TW.ts
+++ b/src/locales/zh-TW.ts
@@ -144,6 +144,7 @@ export default {
auto: '跟隨系統',
autoUI: '自動',
transparent: '透明',
+ glass: '玻璃',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',
diff --git a/src/plugins/vuetify/theme.ts b/src/plugins/vuetify/theme.ts
index 2cf8fa86..05d72eb3 100644
--- a/src/plugins/vuetify/theme.ts
+++ b/src/plugins/vuetify/theme.ts
@@ -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)',
+ },
+ },
},
}
diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss
new file mode 100644
index 00000000..8d11edcb
--- /dev/null
+++ b/src/styles/themes/glass.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/utils/__tests__/themePalette.spec.ts b/src/utils/__tests__/themePalette.spec.ts
index 7a48117b..b622f48a 100644
--- a/src/utils/__tests__/themePalette.spec.ts
+++ b/src/utils/__tests__/themePalette.spec.ts
@@ -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()
diff --git a/src/utils/apexCharts.ts b/src/utils/apexCharts.ts
index f8db110c..2324ab5c 100644
--- a/src/utils/apexCharts.ts
+++ b/src/utils/apexCharts.ts
@@ -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 }) {
diff --git a/src/utils/themeManager.ts b/src/utils/themeManager.ts
index 807cff71..fda0fa0d 100644
--- a/src/utils/themeManager.ts
+++ b/src/utils/themeManager.ts
@@ -12,7 +12,12 @@ class ThemeManager {
private currentTheme: string = 'default'
private loadedLinks: Map = new Map()
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
+ private bundledThemeLoaders: Record Promise> = {
+ 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
}
diff --git a/src/utils/themePalette.ts b/src/utils/themePalette.ts
index 46865e56..7d0a813e 100644
--- a/src/utils/themePalette.ts
+++ b/src/utils/themePalette.ts
@@ -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 = {
background: '#1C1C1C',
primary: '#A370F7',
},
+ glass: {
+ background: '#0B1322',
+ primary: '#E4B863',
+ },
}
const validResolvedThemes = new Set(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(selector).forEach(meta => {
meta.content = content
})
}
+/** 更新或创建浏览器主题色 meta 标签。 */
function ensureThemeColorMeta(themeColor: string) {
const metas = document.querySelectorAll('meta[name="theme-color"]')