Files
AUTO-MAS-test/frontend/src/composables/useTheme.ts
2025-09-08 00:03:52 +08:00

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ref, computed, watch } from 'vue'
import { theme } from 'ant-design-vue'
export type ThemeMode = 'system' | 'light' | 'dark'
export type ThemeColor =
| 'blue'
| 'purple'
| 'cyan'
| 'green'
| 'magenta'
| 'pink'
| 'red'
| 'orange'
| 'yellow'
| 'volcano'
| 'geekblue'
| 'lime'
| 'gold'
const themeMode = ref<ThemeMode>('system')
const themeColor = ref<ThemeColor>('blue')
const isDark = ref(false)
// 预设主题色
const themeColors: Record<ThemeColor, string> = {
blue: '#1677ff',
purple: '#722ed1',
cyan: '#13c2c2',
green: '#52c41a',
magenta: '#eb2f96',
pink: '#eb2f96',
red: '#ff4d4f',
orange: '#fa8c16',
yellow: '#fadb14',
volcano: '#fa541c',
geekblue: '#2f54eb',
lime: '#a0d911',
gold: '#faad14',
}
// 检测系统主题
const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 更新主题
const updateTheme = () => {
let shouldBeDark: boolean
if (themeMode.value === 'system') {
shouldBeDark = getSystemTheme()
} else {
shouldBeDark = themeMode.value === 'dark'
}
isDark.value = shouldBeDark
// 更新HTML类名
if (shouldBeDark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// 更新CSS变量
updateCSSVariables()
}
// 更新CSS变量
const updateCSSVariables = () => {
const root = document.documentElement
const primaryColor = themeColors[themeColor.value]
// 基础背景(用于估算混合)
const baseLightBg = '#ffffff'
const baseDarkBg = '#141414'
const baseMenuBg = isDark.value ? baseDarkBg : baseLightBg
// 改进:侧边栏背景使用 HSL 调整而不是简单线性混合,提高在不同主色下的可读性
const siderBg = deriveSiderBg(primaryColor, baseMenuBg, isDark.value)
const siderBorder = deriveSiderBorder(siderBg, primaryColor, isDark.value)
// 基础文字候选色
const candidateTextLight = 'rgba(255,255,255,0.88)'
const candidateTextDark = 'rgba(0,0,0,0.88)'
// 选菜单文字颜色:对 siderBg 计算对比度,优先满足 >=4.5
const menuTextColor = pickAccessibleColor(candidateTextDark, candidateTextLight, siderBg)
const iconColor = menuTextColor
// ===== AntD token 变量(保留) =====
if (isDark.value) {
root.style.setProperty('--ant-color-primary', primaryColor)
root.style.setProperty('--ant-color-primary-hover', hslLighten(primaryColor, 6))
root.style.setProperty('--ant-color-primary-bg', addAlpha(primaryColor, 0.10))
root.style.setProperty('--ant-color-text', 'rgba(255, 255, 255, 0.88)')
root.style.setProperty('--ant-color-text-secondary', 'rgba(255, 255, 255, 0.65)')
root.style.setProperty('--ant-color-text-tertiary', 'rgba(255, 255, 255, 0.45)')
root.style.setProperty('--ant-color-bg-container', baseDarkBg)
root.style.setProperty('--ant-color-bg-layout', '#000000')
root.style.setProperty('--ant-color-bg-elevated', '#1f1f1f')
root.style.setProperty('--ant-color-border', '#424242')
root.style.setProperty('--ant-color-border-secondary', '#303030')
root.style.setProperty('--ant-color-error', '#ff4d4f')
root.style.setProperty('--ant-color-success', '#52c41a')
root.style.setProperty('--ant-color-warning', '#faad14')
} else {
root.style.setProperty('--ant-color-primary', primaryColor)
root.style.setProperty('--ant-color-primary-hover', hslDarken(primaryColor, 6))
root.style.setProperty('--ant-color-primary-bg', addAlpha(primaryColor, 0.10))
root.style.setProperty('--ant-color-text', 'rgba(0, 0, 0, 0.88)')
root.style.setProperty('--ant-color-text-secondary', 'rgba(0, 0, 0, 0.65)')
root.style.setProperty('--ant-color-text-tertiary', 'rgba(0, 0, 0, 0.45)')
root.style.setProperty('--ant-color-bg-container', baseLightBg)
root.style.setProperty('--ant-color-bg-layout', '#f5f5f5')
root.style.setProperty('--ant-color-bg-elevated', '#ffffff')
root.style.setProperty('--ant-color-border', '#d9d9d9')
root.style.setProperty('--ant-color-border-secondary', '#d9d9d9')
root.style.setProperty('--ant-color-error', '#ff4d4f')
root.style.setProperty('--ant-color-success', '#52c41a')
root.style.setProperty('--ant-color-warning', '#faad14')
}
// ===== 自定义菜单配色 =====
// 动态 Alpha根据主色亮度调整透明度以保持区分度
const lumPrim = getLuminance(primaryColor)
const hoverAlphaBase = isDark.value ? 0.22 : 0.14
const selectedAlphaBase = isDark.value ? 0.38 : 0.26
const hoverAlpha = clamp01(hoverAlphaBase + (isDark.value ? (lumPrim > 0.65 ? -0.04 : 0) : (lumPrim < 0.30 ? 0.04 : 0)))
const selectedAlpha = clamp01(selectedAlphaBase + (isDark.value ? (lumPrim > 0.65 ? -0.05 : 0) : (lumPrim < 0.30 ? 0.05 : 0)))
// 估算最终选中背景(混合算实际颜色用于对比度计算)
const estimatedSelectedBg = blendColors(baseMenuBg, primaryColor, selectedAlpha)
const selectedTextColor = pickAccessibleColor('rgba(0,0,0,0.90)', 'rgba(255,255,255,0.92)', estimatedSelectedBg)
const hoverTextColor = menuTextColor
root.style.setProperty('--app-sider-bg', siderBg)
root.style.setProperty('--app-sider-border-color', siderBorder)
root.style.setProperty('--app-menu-text-color', menuTextColor)
root.style.setProperty('--app-menu-icon-color', iconColor)
root.style.setProperty('--app-menu-item-hover-text-color', hoverTextColor)
root.style.setProperty('--app-menu-item-selected-text-color', selectedTextColor)
// 背景同时提供 rgba 与 hex alpha兼容处理
const hoverRgba = hexToRgba(primaryColor, hoverAlpha)
const selectedRgba = hexToRgba(primaryColor, selectedAlpha)
root.style.setProperty('--app-menu-item-hover-bg', hoverRgba)
root.style.setProperty('--app-menu-item-hover-bg-hex', addAlpha(primaryColor, hoverAlpha))
root.style.setProperty('--app-menu-item-selected-bg', selectedRgba)
root.style.setProperty('--app-menu-item-selected-bg-hex', addAlpha(primaryColor, selectedAlpha))
}
// ===== 新增缺失基础函数(从旧版本恢复) =====
const addAlpha = (hex: string, alpha: number) => {
const a = alpha > 1 ? alpha / 100 : alpha
const clamped = Math.min(1, Math.max(0, a))
const alphaHex = Math.round(clamped * 255).toString(16).padStart(2, '0')
return `${hex}${alphaHex}`
}
const blendColors = (color1: string, color2: string, ratio: number) => {
const r1 = hexToRgb(color1)
const r2 = hexToRgb(color2)
if (!r1 || !r2) return color1
const r = Math.round(r1.r * (1 - ratio) + r2.r * ratio)
const g = Math.round(r1.g * (1 - ratio) + r2.g * ratio)
const b = Math.round(r1.b * (1 - ratio) + r2.b * ratio)
return rgbToHex(r, g, b)
}
const getLuminance = (hex: string) => {
const rgb = hexToRgb(hex)
if (!rgb) return 0
const transform = (v: number) => {
const srgb = v / 255
return srgb <= 0.03928 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4)
}
const r = transform(rgb.r)
const g = transform(rgb.g)
const b = transform(rgb.b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
// ===== 新增/改进的颜色工具 =====
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null
}
const rgbToHex = (r: number, g: number, b: number) => {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
// 新增hex -> rgba 字符串
const hexToRgba = (hex: string, alpha: number) => {
const rgb = hexToRgb(hex)
if (!rgb) return 'rgba(0,0,0,0)'
const a = alpha > 1 ? alpha / 100 : alpha
return `rgba(${rgb.r},${rgb.g},${rgb.b},${clamp01(a)})`
}
// HSL 转换(感知更平滑)
const rgbToHsl = (r: number, g: number, b: number) => {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
const d = max - min
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s, l }
}
const hslToRgb = (h: number, s: number, l: number) => {
h /= 360
if (s === 0) {
const val = Math.round(l * 255)
return { r: val, g: val, b: val }
}
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const r = hue2rgb(p, q, h + 1/3)
const g = hue2rgb(p, q, h)
const b = hue2rgb(p, q, h - 1/3)
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
const hslAdjust = (hex: string, dl: number) => {
const rgb = hexToRgb(hex)
if (!rgb) return hex
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b)
const nl = clamp01(l + dl)
const nrgb = hslToRgb(h, s, nl)
return rgbToHex(nrgb.r, nrgb.g, nrgb.b)
}
const hslLighten = (hex: string, percent: number) => hslAdjust(hex, percent/100)
const hslDarken = (hex: string, percent: number) => hslAdjust(hex, -percent/100)
// 对比度 (WCAG)
const contrastRatio = (hex1: string, hex2: string) => {
const L1 = getLuminance(hex1)
const L2 = getLuminance(hex2)
const light = Math.max(L1, L2)
const dark = Math.min(L1, L2)
return (light + 0.05) / (dark + 0.05)
}
const rgbaExtractHex = (color: string) => {
// 只支持 hex(#rrggbb) 直接返回;若 rgba 则忽略 alpha 并合成背景为黑假设
if (color.startsWith('#') && (color.length === 7)) return color
// 简化:返回黑或白占位
return '#000000'
}
const pickAccessibleColor = (c1: string, c2: string, bg: string, minRatio = 4.5) => {
const hexBg = rgbaExtractHex(bg)
const hex1 = rgbaExtractHex(c1 === 'rgba(255,255,255,0.88)' ? '#ffffff' : (c1.includes('255,255,255') ? '#ffffff' : '#000000'))
const hex2 = rgbaExtractHex(c2 === 'rgba(255,255,255,0.88)' ? '#ffffff' : (c2.includes('255,255,255') ? '#ffffff' : '#000000'))
const r1 = contrastRatio(hex1, hexBg)
const r2 = contrastRatio(hex2, hexBg)
// 优先满足 >= minRatio都满足取更高否则取更高
if (r1 >= minRatio && r2 >= minRatio) return r1 >= r2 ? c1 : c2
if (r1 >= minRatio) return c1
if (r2 >= minRatio) return c2
return r1 >= r2 ? c1 : c2
}
// 改进侧栏背景:如果深色模式,降低亮度并略增饱和;浅色模式提高亮度轻度染色
const deriveSiderBg = (primary: string, base: string, dark: boolean) => {
const mixRatio = dark ? 0.22 : 0.18
const mixed = blendColors(base, primary, mixRatio)
return dark ? hslDarken(mixed, 8) : hslLighten(mixed, 6)
}
const deriveSiderBorder = (siderBg: string, primary: string, dark: boolean) => {
return dark ? blendColors(siderBg, primary, 0.30) : blendColors('#d9d9d9', primary, 0.25)
}
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', () => {
if (themeMode.value === 'system') {
updateTheme()
}
})
// 监听主题模式和颜色变化
watch(themeMode, updateTheme, { immediate: true })
watch(themeColor, updateTheme)
// Ant Design 主题配置
const antdTheme = computed(() => ({
algorithm: isDark.value ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: themeColors[themeColor.value],
},
}))
export function useTheme() {
const setThemeMode = (mode: ThemeMode) => {
themeMode.value = mode
localStorage.setItem('theme-mode', mode)
}
const setThemeColor = (color: ThemeColor) => {
themeColor.value = color
localStorage.setItem('theme-color', color)
}
// 初始化时从localStorage读取设置
const initTheme = () => {
const savedMode = localStorage.getItem('theme-mode') as ThemeMode
const savedColor = localStorage.getItem('theme-color') as ThemeColor
if (savedMode) {
themeMode.value = savedMode
}
if (savedColor) {
themeColor.value = savedColor
}
updateTheme()
}
return {
themeMode: computed(() => themeMode.value),
themeColor: computed(() => themeColor.value),
isDark: computed(() => isDark.value),
antdTheme,
themeColors,
setThemeMode,
setThemeColor,
initTheme,
}
}