mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
feat: add glass theme
This commit is contained in:
+11
-1
@@ -296,6 +296,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 安全读取本地存储,隐私模式或存储不可用时返回空值。
|
||||||
function getLocalStorageValue(key) {
|
function getLocalStorageValue(key) {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key)
|
return localStorage.getItem(key)
|
||||||
@@ -322,12 +323,18 @@
|
|||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
primary: '#A370F7',
|
primary: '#A370F7',
|
||||||
},
|
},
|
||||||
|
glass: {
|
||||||
|
background: '#0B1322',
|
||||||
|
primary: '#E4B863',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 读取已保存的主题偏好,缺省时跟随系统。
|
||||||
function getSavedThemePreference() {
|
function getSavedThemePreference() {
|
||||||
return getLocalStorageValue('theme') || 'auto'
|
return getLocalStorageValue('theme') || 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将启动阶段的主题偏好解析为实际主题。
|
||||||
function resolveLaunchTheme(themePreference) {
|
function resolveLaunchTheme(themePreference) {
|
||||||
if (themePreference === 'auto') {
|
if (themePreference === 'auto') {
|
||||||
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
@@ -340,16 +347,19 @@
|
|||||||
return launchThemePalettes[themePreference] ? themePreference : 'light'
|
return launchThemePalettes[themePreference] ? themePreference : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回启动阶段浏览器原生控件应使用的明暗配色。
|
||||||
function getLaunchColorScheme(themeName) {
|
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) {
|
function setMetaContent(selector, content) {
|
||||||
document.querySelectorAll(selector).forEach(meta => {
|
document.querySelectorAll(selector).forEach(meta => {
|
||||||
meta.setAttribute('content', content)
|
meta.setAttribute('content', content)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新或创建浏览器主题色 meta 标签。
|
||||||
function syncThemeColorMeta(themeColor) {
|
function syncThemeColorMeta(themeColor) {
|
||||||
const metas = document.querySelectorAll('meta[name="theme-color"]')
|
const metas = document.querySelectorAll('meta[name="theme-color"]')
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -73,10 +73,13 @@ const backgroundImages = ref<string[]>([])
|
|||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const previousImageIndex = ref<number | null>(null)
|
const previousImageIndex = ref<number | null>(null)
|
||||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
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 isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||||
|
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
|
||||||
const shouldLoadBackgroundImages = computed(
|
const shouldLoadBackgroundImages = computed(
|
||||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||||
)
|
)
|
||||||
const transparentBackgroundBlur = ref(16)
|
const transparentBackgroundBlur = ref(16)
|
||||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||||
@@ -434,7 +437,7 @@ function startBackgroundRotation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止登录页或透明主题背景图加载、重试和轮播。
|
// 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。
|
||||||
function stopBackgroundLoading() {
|
function stopBackgroundLoading() {
|
||||||
backgroundRequestController?.abort()
|
backgroundRequestController?.abort()
|
||||||
backgroundRequestController = null
|
backgroundRequestController = null
|
||||||
@@ -597,7 +600,7 @@ onMounted(async () => {
|
|||||||
stopBackgroundLoading()
|
stopBackgroundLoading()
|
||||||
if (shouldLoad) {
|
if (shouldLoad) {
|
||||||
loadBackgroundImages()
|
loadBackgroundImages()
|
||||||
} else if (!isTransparentTheme.value) {
|
} else if (!isBackdropTheme.value) {
|
||||||
backgroundImages.value = []
|
backgroundImages.value = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -652,12 +655,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
||||||
<!-- 透明主题背景 -->
|
<!-- 登录页、透明主题和玻璃主题共用动态壁纸场景。 -->
|
||||||
<div
|
<div
|
||||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
|
||||||
class="background-container"
|
class="background-container"
|
||||||
:class="{
|
:class="{
|
||||||
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
|
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
|
||||||
|
'is-glass-theme': shouldUseGlassBackgroundTreatment,
|
||||||
'is-transparent-glass-lightweight':
|
'is-transparent-glass-lightweight':
|
||||||
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
|
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
|
||||||
}"
|
}"
|
||||||
@@ -737,6 +741,22 @@ onUnmounted(() => {
|
|||||||
opacity: var(--transparent-background-poster-opacity, 1);
|
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.active,
|
||||||
.background-container.is-transparent-glass-lightweight .background-image.previous {
|
.background-container.is-transparent-glass-lightweight .background-image.previous {
|
||||||
filter: blur(var(--transparent-background-blur, 16px));
|
filter: blur(var(--transparent-background-blur, 16px));
|
||||||
|
|||||||
@@ -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.auto'), value: 'auto', icon: 'mdi-monitor' },
|
||||||
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
|
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
|
||||||
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
|
{ 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 }>>(() => [
|
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)
|
const showLayoutSection = computed(() => !appMode.value)
|
||||||
|
|
||||||
|
// 玻璃主题使用成套的外阴影与内高光,不提供不完整的 Vuetify elevation 调整。
|
||||||
|
const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
|
||||||
|
|
||||||
const hasAppModeCustomization = computed(() => {
|
const hasAppModeCustomization = computed(() => {
|
||||||
return (
|
return (
|
||||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||||
@@ -325,6 +329,7 @@ async function handleResetSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showShadowSection">
|
||||||
<VDivider class="mt-7" />
|
<VDivider class="mt-7" />
|
||||||
|
|
||||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
||||||
@@ -365,6 +370,7 @@ async function handleResetSettings() {
|
|||||||
<span>24</span>
|
<span>24</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
|
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
|
||||||
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
|
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
|||||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
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 {
|
export interface ThemeCustomizerSettings {
|
||||||
layout: ThemeCustomizerLayout
|
layout: ThemeCustomizerLayout
|
||||||
@@ -72,11 +72,12 @@ export interface ThemeCustomizerSettings {
|
|||||||
type VuetifyThemeApi = ReturnType<typeof useTheme>
|
type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||||
|
|
||||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
|
const glassDefaultPrimaryColor = '#E4B863'
|
||||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||||
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||||
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||||
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
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> = {
|
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||||
high: '24',
|
high: '24',
|
||||||
low: '6',
|
low: '6',
|
||||||
@@ -86,14 +87,17 @@ const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
|||||||
|
|
||||||
let themeApplyVersion = 0
|
let themeApplyVersion = 0
|
||||||
|
|
||||||
|
/** 判断当前代码是否运行在浏览器环境。 */
|
||||||
function isBrowser() {
|
function isBrowser() {
|
||||||
return typeof window !== 'undefined'
|
return typeof window !== 'undefined'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验主题主色是否为完整的六位十六进制颜色。 */
|
||||||
function isHexColor(color: unknown): color is string {
|
function isHexColor(color: unknown): color is string {
|
||||||
return typeof color === 'string' && /^#[\da-f]{6}$/i.test(color)
|
return typeof color === 'string' && /^#[\da-f]{6}$/i.test(color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取并校验本地存储中的主题偏好。 */
|
||||||
function readStoredThemePreference(): ThemeCustomizerTheme {
|
function readStoredThemePreference(): ThemeCustomizerTheme {
|
||||||
if (!isBrowser()) return 'auto'
|
if (!isBrowser()) return 'auto'
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
|||||||
return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto'
|
return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||||
return {
|
return {
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
@@ -122,6 +127,7 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
|
|||||||
return getDefaultThemeCustomizerSettings().shadow
|
return getDefaultThemeCustomizerSettings().shadow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 规范化持久化的主题定制设置并迁移旧值。 */
|
||||||
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||||
const fallback = getDefaultThemeCustomizerSettings()
|
const fallback = getDefaultThemeCustomizerSettings()
|
||||||
const storedRadius = settings.radius as string | undefined
|
const storedRadius = settings.radius as string | undefined
|
||||||
@@ -171,12 +177,14 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
||||||
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
||||||
|
|
||||||
|
/** 将完整主题定制设置写入本地存储。 */
|
||||||
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||||
if (!isBrowser()) return
|
if (!isBrowser()) return
|
||||||
|
|
||||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify(settings))
|
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 广播主题定制设置变更,供布局与菜单同步响应。 */
|
||||||
function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
|
function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
|
||||||
if (!isBrowser()) return
|
if (!isBrowser()) return
|
||||||
|
|
||||||
@@ -187,6 +195,7 @@ function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根据背景亮度选择可读的前景色。 */
|
||||||
function getTextColorForHex(backgroundColor: string) {
|
function getTextColorForHex(backgroundColor: string) {
|
||||||
const hex = backgroundColor.replace('#', '')
|
const hex = backgroundColor.replace('#', '')
|
||||||
const red = Number.parseInt(hex.slice(0, 2), 16)
|
const red = Number.parseInt(hex.slice(0, 2), 16)
|
||||||
@@ -197,20 +206,22 @@ function getTextColorForHex(backgroundColor: string) {
|
|||||||
return luminance > 0.68 ? '#3A3541' : '#FFFFFF'
|
return luminance > 0.68 ? '#3A3541' : '#FFFFFF'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将主色写入 Vuetify 运行时主题,所有已注册主题会同步更新。 */
|
/** 将主色写入 Vuetify 运行时主题,默认主色下保留玻璃主题的香槟强调色。 */
|
||||||
export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyThemeApi) {
|
export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyThemeApi) {
|
||||||
if (!isHexColor(color)) return
|
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 = themePrimaryColor
|
||||||
themeDefinition.colors.primary = color
|
themeDefinition.colors['on-primary'] = getTextColorForHex(themePrimaryColor)
|
||||||
themeDefinition.colors['on-primary'] = onPrimaryColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.documentElement.style.setProperty('--initial-loader-color', color)
|
const activePrimaryColor = themeApi.current.value.colors.primary
|
||||||
localStorage.setItem('materio-initial-loader-color', color)
|
|
||||||
syncThemeFavicon(color)
|
document.documentElement.style.setProperty('--initial-loader-color', activePrimaryColor)
|
||||||
|
localStorage.setItem('materio-initial-loader-color', activePrimaryColor)
|
||||||
|
syncThemeFavicon(activePrimaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
||||||
@@ -231,6 +242,7 @@ export function applyThemeCustomizerRootSettings(
|
|||||||
document.body.setAttribute('data-theme-skin', settings.skin)
|
document.body.setAttribute('data-theme-skin', settings.skin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将自动主题偏好解析为当前系统对应的实际主题。 */
|
||||||
function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
|
function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
|
||||||
if (themePreference === 'auto') {
|
if (themePreference === 'auto') {
|
||||||
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
@@ -239,11 +251,13 @@ function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
|
|||||||
return themePreference
|
return themePreference
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将实际主题名称同步到文档根节点和 body。 */
|
||||||
function syncThemeAttribute(themeName: string) {
|
function syncThemeAttribute(themeName: string) {
|
||||||
document.documentElement.setAttribute('data-theme', themeName)
|
document.documentElement.setAttribute('data-theme', themeName)
|
||||||
document.body.setAttribute('data-theme', themeName)
|
document.body.setAttribute('data-theme', themeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 应用主题偏好并避免异步样式加载造成旧主题回写。 */
|
||||||
async function applyThemePreference(themePreference: ThemeCustomizerTheme, themeApi: VuetifyThemeApi) {
|
async function applyThemePreference(themePreference: ThemeCustomizerTheme, themeApi: VuetifyThemeApi) {
|
||||||
const currentVersion = ++themeApplyVersion
|
const currentVersion = ++themeApplyVersion
|
||||||
const resolvedTheme = getResolvedThemeName(themePreference)
|
const resolvedTheme = getResolvedThemeName(themePreference)
|
||||||
@@ -270,6 +284,7 @@ export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi)
|
|||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 持久化部分主题定制设置并同步当前页面外观。 */
|
||||||
export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustomizerSettings>) {
|
export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustomizerSettings>) {
|
||||||
const nextSettings = normalizeThemeCustomizerSettings({
|
const nextSettings = normalizeThemeCustomizerSettings({
|
||||||
...readThemeCustomizerSettings(),
|
...readThemeCustomizerSettings(),
|
||||||
@@ -285,6 +300,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
|||||||
return nextSettings
|
return nextSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断当前主题定制设置是否仍为默认值。 */
|
||||||
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||||
const defaults = normalizeThemeCustomizerSettings({
|
const defaults = normalizeThemeCustomizerSettings({
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
@@ -312,6 +328,7 @@ export function useThemeCustomizer() {
|
|||||||
const themeApi = useTheme()
|
const themeApi = useTheme()
|
||||||
const settings = settingsState
|
const settings = settingsState
|
||||||
|
|
||||||
|
/** 合并、保存并应用一组主题定制设置。 */
|
||||||
async function updateSettings(patch: Partial<ThemeCustomizerSettings>) {
|
async function updateSettings(patch: Partial<ThemeCustomizerSettings>) {
|
||||||
const previousTheme = settings.value.theme
|
const previousTheme = settings.value.theme
|
||||||
const nextSettings = normalizeThemeCustomizerSettings({
|
const nextSettings = normalizeThemeCustomizerSettings({
|
||||||
@@ -334,34 +351,42 @@ export function useThemeCustomizer() {
|
|||||||
dispatchThemeCustomizerChange(nextSettings)
|
dispatchThemeCustomizerChange(nextSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新主题主色。 */
|
||||||
function setPrimaryColor(color: string) {
|
function setPrimaryColor(color: string) {
|
||||||
return updateSettings({ primaryColor: color })
|
return updateSettings({ primaryColor: color })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新全局圆角档位。 */
|
||||||
function setRadius(radius: ThemeCustomizerRadius) {
|
function setRadius(radius: ThemeCustomizerRadius) {
|
||||||
return updateSettings({ radius })
|
return updateSettings({ radius })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新当前主题。 */
|
||||||
function setTheme(theme: ThemeCustomizerTheme) {
|
function setTheme(theme: ThemeCustomizerTheme) {
|
||||||
return updateSettings({ theme })
|
return updateSettings({ theme })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新全局阴影档位。 */
|
||||||
function setShadow(shadow: ThemeCustomizerShadow) {
|
function setShadow(shadow: ThemeCustomizerShadow) {
|
||||||
return updateSettings({ shadow })
|
return updateSettings({ shadow })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新表面皮肤。 */
|
||||||
function setSkin(skin: ThemeCustomizerSkin) {
|
function setSkin(skin: ThemeCustomizerSkin) {
|
||||||
return updateSettings({ skin })
|
return updateSettings({ skin })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新桌面布局模式。 */
|
||||||
function setLayout(layout: ThemeCustomizerLayout) {
|
function setLayout(layout: ThemeCustomizerLayout) {
|
||||||
return updateSettings({ layout })
|
return updateSettings({ layout })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新浅色主题的半深色侧栏设置。 */
|
||||||
function setSemiDarkMenu(semiDarkMenu: boolean) {
|
function setSemiDarkMenu(semiDarkMenu: boolean) {
|
||||||
return updateSettings({ semiDarkMenu })
|
return updateSettings({ semiDarkMenu })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将主题定制器恢复到默认设置。 */
|
||||||
async function resetSettings() {
|
async function resetSettings() {
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
@@ -374,6 +399,7 @@ export function useThemeCustomizer() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在自动主题模式下响应系统配色变化。 */
|
||||||
function handleSystemThemeChange() {
|
function handleSystemThemeChange() {
|
||||||
if (settings.value.theme === 'auto') {
|
if (settings.value.theme === 'auto') {
|
||||||
updateSettings({ theme: 'auto' })
|
updateSettings({ theme: 'auto' })
|
||||||
|
|||||||
@@ -315,6 +315,11 @@ const themes: ThemeSwitcherTheme[] = [
|
|||||||
title: t('theme.transparent'),
|
title: t('theme.transparent'),
|
||||||
icon: 'mdi-gradient-horizontal',
|
icon: 'mdi-gradient-horizontal',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'glass',
|
||||||
|
title: t('theme.glass'),
|
||||||
|
icon: 'mdi-blur-radial',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function getThemeLayoutTitle(layout: ThemeCustomizerSettings['layout']) {
|
function getThemeLayoutTitle(layout: ThemeCustomizerSettings['layout']) {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export default {
|
|||||||
auto: 'Follow System',
|
auto: 'Follow System',
|
||||||
autoUI: 'Auto',
|
autoUI: 'Auto',
|
||||||
transparent: 'Transparent',
|
transparent: 'Transparent',
|
||||||
|
glass: 'Glass',
|
||||||
purple: 'Purple',
|
purple: 'Purple',
|
||||||
custom: 'Custom Style',
|
custom: 'Custom Style',
|
||||||
transparency: 'Transparency',
|
transparency: 'Transparency',
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export default {
|
|||||||
auto: '跟随系统',
|
auto: '跟随系统',
|
||||||
autoUI: '自动',
|
autoUI: '自动',
|
||||||
transparent: '透明',
|
transparent: '透明',
|
||||||
|
glass: '玻璃',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加样式',
|
custom: '附加样式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export default {
|
|||||||
auto: '跟隨系統',
|
auto: '跟隨系統',
|
||||||
autoUI: '自動',
|
autoUI: '自動',
|
||||||
transparent: '透明',
|
transparent: '透明',
|
||||||
|
glass: '玻璃',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加樣式',
|
custom: '附加樣式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
@@ -210,6 +210,61 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',
|
'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)',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import { applyDocumentThemeChrome } from '@/utils/themePalette'
|
import {
|
||||||
|
applyDocumentThemeChrome,
|
||||||
|
getThemeColorScheme,
|
||||||
|
resolveThemeName,
|
||||||
|
themeRootPalettes,
|
||||||
|
} from '@/utils/themePalette'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('theme palette', () => {
|
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', () => {
|
it('notifies the favicon renderer with the applied primary color', () => {
|
||||||
const handleFaviconChange = vi.fn()
|
const handleFaviconChange = vi.fn()
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根据当前 Vuetify 主题同步 ApexCharts 的全局明暗外观。 */
|
||||||
export function configureApexChartsTheme(themeName: string) {
|
export function configureApexChartsTheme(themeName: string) {
|
||||||
if (typeof window === 'undefined' || !window.Apex) {
|
if (typeof window === 'undefined' || !window.Apex) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isDark = themeName === 'dark' || themeName === 'transparent'
|
const isDark = ['dark', 'glass', 'transparent'].includes(themeName)
|
||||||
|
|
||||||
window.Apex.dataLabels = {
|
window.Apex.dataLabels = {
|
||||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ class ThemeManager {
|
|||||||
private currentTheme: string = 'default'
|
private currentTheme: string = 'default'
|
||||||
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
|
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
|
||||||
private themeListeners: Map<(theme: string) => void, EventListener> = 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() {
|
constructor() {
|
||||||
// 注册所有可用主题
|
// 注册所有可用主题
|
||||||
this.registerTheme('default', '')
|
this.registerTheme('default', '')
|
||||||
@@ -20,8 +25,9 @@ class ThemeManager {
|
|||||||
this.registerTheme('dark', '')
|
this.registerTheme('dark', '')
|
||||||
this.registerTheme('purple', '')
|
this.registerTheme('purple', '')
|
||||||
this.registerTheme('auto', '')
|
this.registerTheme('auto', '')
|
||||||
// 只有透明主题有特定的CSS文件
|
// 透明和玻璃主题使用独立的按需样式文件。
|
||||||
this.registerTheme('transparent', './src/styles/themes/transparent.css')
|
this.registerTheme('transparent', './src/styles/themes/transparent.css')
|
||||||
|
this.registerTheme('glass', './src/styles/themes/glass.css')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,9 +86,10 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 动态导入CSS模块
|
// 动态导入打包内置的主题样式模块。
|
||||||
if (themeName === 'transparent') {
|
const bundledThemeLoader = this.bundledThemeLoaders[themeName]
|
||||||
await import('@/styles/themes/transparent.scss')
|
if (bundledThemeLoader) {
|
||||||
|
await bundledThemeLoader()
|
||||||
this.themes.get(themeName)!.isLoaded = true
|
this.themes.get(themeName)!.isLoaded = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -134,8 +141,8 @@ class ThemeManager {
|
|||||||
const theme = this.themes.get(themeName)
|
const theme = this.themes.get(themeName)
|
||||||
if (!theme) return
|
if (!theme) return
|
||||||
|
|
||||||
// 对于动态导入的CSS,我们无法直接卸载,但可以标记为未加载
|
// 对于动态导入的 CSS,我们无法直接卸载,但可以标记为未加载。
|
||||||
if (themeName === 'transparent') {
|
if (this.bundledThemeLoaders[themeName]) {
|
||||||
theme.isLoaded = false
|
theme.isLoaded = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||||
|
|
||||||
export type ThemePreference = 'auto' | 'default' | 'light' | 'dark' | 'purple' | 'transparent'
|
export type ThemePreference = 'auto' | 'dark' | 'default' | 'glass' | 'light' | 'purple' | 'transparent'
|
||||||
export type ResolvedThemeName = 'light' | 'dark' | 'purple' | 'transparent'
|
export type ResolvedThemeName = 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
|
||||||
export type ThemeColorScheme = 'light' | 'dark'
|
export type ThemeColorScheme = 'light' | 'dark'
|
||||||
|
|
||||||
interface ThemeRootPalette {
|
interface ThemeRootPalette {
|
||||||
@@ -33,14 +33,20 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
|||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
primary: '#A370F7',
|
primary: '#A370F7',
|
||||||
},
|
},
|
||||||
|
glass: {
|
||||||
|
background: '#0B1322',
|
||||||
|
primary: '#E4B863',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const validResolvedThemes = new Set<string>(Object.keys(themeRootPalettes))
|
const validResolvedThemes = new Set<string>(Object.keys(themeRootPalettes))
|
||||||
|
|
||||||
|
/** 将任意主题名称收敛为应用支持的实际主题。 */
|
||||||
function normalizeResolvedThemeName(themeName: string | null | undefined): ResolvedThemeName {
|
function normalizeResolvedThemeName(themeName: string | null | undefined): ResolvedThemeName {
|
||||||
return validResolvedThemes.has(themeName || '') ? (themeName as ResolvedThemeName) : 'light'
|
return validResolvedThemes.has(themeName || '') ? (themeName as ResolvedThemeName) : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将用户主题偏好解析为当前实际主题。 */
|
||||||
export function resolveThemeName(themePreference: string | null | undefined): ResolvedThemeName {
|
export function resolveThemeName(themePreference: string | null | undefined): ResolvedThemeName {
|
||||||
if (themePreference === 'auto') {
|
if (themePreference === 'auto') {
|
||||||
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||||
@@ -53,16 +59,19 @@ export function resolveThemeName(themePreference: string | null | undefined): Re
|
|||||||
return normalizeResolvedThemeName(themePreference)
|
return normalizeResolvedThemeName(themePreference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回浏览器原生控件应使用的明暗配色。 */
|
||||||
export function getThemeColorScheme(themeName: string | null | undefined): ThemeColorScheme {
|
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) {
|
function setMetaContent(selector: string, content: string) {
|
||||||
document.querySelectorAll<HTMLMetaElement>(selector).forEach(meta => {
|
document.querySelectorAll<HTMLMetaElement>(selector).forEach(meta => {
|
||||||
meta.content = content
|
meta.content = content
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新或创建浏览器主题色 meta 标签。 */
|
||||||
function ensureThemeColorMeta(themeColor: string) {
|
function ensureThemeColorMeta(themeColor: string) {
|
||||||
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
|
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user