Vue3封装主题色完善版

场景

我在上家公司曾参照百度、淘宝等网站的颜色配置方式,制定了主题色元素表,以元素类型、应用类型、备注、页面类型的方式存储色值,以特殊类名来深度修改element等组件颜色,使用body class,例如html[data-theme="dark"]:root{}的方式向外抛出,挂载在浏览器上,并使用localStorage存储选择,所有页面元素内使用var()来取色,做到了一键切换所有风格颜色。

考虑到那时候大厂几乎不设切换主题色功能,网上流行案例只提供切换后台导航栏的颜色,逻辑简单,我也就满足于简单使用。现今,开源项目多如雨后春笋,我见识过很多有个性的项目,深感往期写法乏力、难以配色且维护艰难。

我总结经验,写成一套逻辑完善,写法简洁,易集成的主题色修改方法,源码放出供诸君品鉴。

效果

实现

index.scss style主配置文件,引入main.ts

css 复制代码
@use './reset.scss';
@use './fonts.scss' as *;
@use './flex.style.scss' as *;
@use './scrollbar.scss';
@use './element.dark.scss' as element-dark;
@use './element.light.scss' as element-light;

:root {
  --app-font-family: #{$font-family-sans-serif};
  --bh-accent: #3a82ff;
  --bh-accent-rgb: 58, 130, 255;
  --bh-accent-soft: rgba(58, 130, 255, 0.14);

  --bh-theme-bg-app: #0b0b0b;
  --bh-theme-bg-main: #0b0b0b;
  --bh-theme-bg-aside: #171717;
  --bh-theme-panel-bg: #111317;
  --bh-theme-border: rgba(255, 255, 255, 0.08);
  --bh-theme-hover-bg: rgba(255, 255, 255, 0.06);
  --bh-theme-text-strong: rgba(255, 255, 255, 0.96);
  --bh-theme-text-normal: rgba(255, 255, 255, 0.72);
  --bh-theme-text-muted: rgba(255, 255, 255, 0.46);
  --bh-theme-text-subtle: rgba(255, 255, 255, 0.32);
  --bh-theme-ring: rgba(255, 255, 255, 0.9);
}

html[data-bh-theme='light'] {
  --bh-theme-bg-app: #edf1f7;
  --bh-theme-bg-main: #edf1f7;
  --bh-theme-bg-aside: #f8fafc;
  --bh-theme-panel-bg: #f3f6fb;
  --bh-theme-border: rgba(15, 23, 42, 0.12);
  --bh-theme-hover-bg: rgba(15, 23, 42, 0.06);
  --bh-theme-text-strong: rgba(15, 23, 42, 0.96);
  --bh-theme-text-normal: rgba(15, 23, 42, 0.78);
  --bh-theme-text-muted: rgba(15, 23, 42, 0.52);
  --bh-theme-text-subtle: rgba(15, 23, 42, 0.42);
  --bh-theme-ring: rgba(15, 23, 42, 0.9);
}

body {
  font-family: var(--app-font-family);
}

element.dark.scss element配置深色 写法借鉴我原本写法,多了一个父级类名约束,可以支持多种不同的表格类型

css 复制代码
.bh-el-dark,
.bh-theme-dark .bh-el-theme {
  .el-input__wrapper,
  .el-select__wrapper,
  .el-input-number {
    background-color: #222 !important;
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important;
  }

  .el-input__inner,
  .el-select__placeholder,
  .el-select__selected-item {
    color: rgba(255, 255, 255, 0.8);
  }

  .el-table {
    --el-table-bg-color: #171717;
    --el-table-tr-bg-color: #171717;
    --el-table-header-bg-color: #202020;
    --el-table-border-color: rgba(255, 255, 255, 0.08);
    --el-table-row-hover-bg-color: rgba(244, 199, 72, 0.08);
    --el-table-text-color: rgba(255, 255, 255, 0.86);
    --el-text-color-regular: rgba(255, 255, 255, 0.86);
    --el-table-header-text-color: rgba(255, 255, 255, 0.66);
  }

  .el-table__inner-wrapper::before {
    background-color: rgba(255, 255, 255, 0.08);
  }

  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
    background: #1d1d1d !important;
  }

  .el-table--striped .el-table__body tr.el-table__row td.el-table__cell {
    background: #171717;
  }

  .el-table .el-table__body td.el-table-fixed-column--right,
  .el-table .el-table__body td.el-table-fixed-column--left {
    background: #171717 !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table-fixed-column--left {
    background: #1d1d1d !important;
  }

  .el-table__body tr:hover > td.el-table__cell {
    background: rgba(186, 170, 125, 0.16) !important;
  }

  .el-table__body tr.hover-row > td.el-table-fixed-column--right,
  .el-table__body tr.hover-row > td.el-table-fixed-column--left,
  .el-table__body tr:hover > td.el-table-fixed-column--right,
  .el-table__body tr:hover > td.el-table-fixed-column--left {
    background: rgba(186, 170, 125, 0.16) !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table__cell {
    background: rgba(186, 170, 125, 0.2) !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped.hover-row > td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped.hover-row > td.el-table-fixed-column--left,
  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table-fixed-column--left {
    background: rgba(186, 170, 125, 0.2) !important;
  }

  .el-pager li,
  .btn-prev,
  .btn-next {
    background: #222 !important;
    color: rgba(255, 255, 255, 0.78) !important;
  }

  .el-pager li.is-active {
    color: #f4c748 !important;
  }
}

.bh-dialog-dark,
.bh-theme-dark .bh-dialog-theme {
  &.el-dialog,
  .el-dialog {
    border-radius: 14px;
    overflow: hidden;
    background: #121212;
    border: 1px solid rgba(255, 255, 255, 0.08);
  }

  .el-dialog__header {
    margin-right: 0;
    padding: 18px 20px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  }

  .el-dialog__title,
  .el-dialog__close {
    color: #f8fafc;
  }

  .el-dialog__body {
    padding: 18px 20px 10px;
    background: #121212;
  }

  .el-dialog__footer {
    padding: 10px 20px 18px;
    border-top: 1px solid rgba(255, 255, 255, 0.08);
    background: #121212;
  }

  .el-form-item__label {
    color: rgba(255, 255, 255, 0.76);
  }

  .el-input__inner::placeholder {
    color: rgba(255, 255, 255, 0.36);
  }

  .el-button--default {
    background: #222;
    border-color: rgba(255, 255, 255, 0.14);
    color: rgba(255, 255, 255, 0.82);
  }

  .el-button--default:hover {
    background: #2c2c2c;
    border-color: rgba(244, 199, 72, 0.48);
    color: #f4c748;
  }
}

.bh-switch-dark,
.bh-theme-dark .bh-switch-theme {
  --el-switch-on-color: #2f80ed;
  --el-switch-off-color: #3a3a3a;
  height: 22px;

  .el-switch__core {
    min-width: 50px;
    height: 22px;
    border-radius: 11px;
    border: 1px solid rgba(255, 255, 255, 0.14);
  }

  .el-switch__inner {
    color: #fff;
    font-size: 12px;
    font-weight: 500;
  }

  &:not(.is-checked) .el-switch__core {
    background-color: #333 !important;
    border-color: rgba(255, 255, 255, 0.16);
  }

  &.is-checked .el-switch__core {
    border-color: rgba(47, 128, 237, 0.72);
  }
}

.bh-select-dropdown-dark,
.bh-theme-dark .bh-select-dropdown-theme {
  background: #1a1a1a !important;
  border-color: rgba(255, 255, 255, 0.12) !important;

  .el-popper__arrow::before {
    background: #1a1a1a !important;
    border-color: rgba(255, 255, 255, 0.12) !important;
  }

  .el-select-dropdown__item {
    color: rgba(255, 255, 255, 0.78);
  }

  .el-select-dropdown__item.is-hovering {
    background: rgba(244, 199, 72, 0.1);
  }

  .el-select-dropdown__item.is-selected {
    color: #f4c748;
  }
}

.bh-drawer-dark,
.bh-theme-dark .bh-drawer-theme {
  .el-overlay {
    background-color: rgba(0, 0, 0, 0.45) !important;
  }

  .el-drawer__wrapper {
    background: transparent !important;
  }

  .el-drawer {
    background: #121212;
    border-left: 1px solid rgba(255, 255, 255, 0.08);
    box-shadow: -8px 0 24px rgba(0, 0, 0, 0.55) !important;
  }

  .el-drawer__body {
    height: 100%;
    padding: 0;
    background: #121212;
  }

  .el-drawer__header {
    margin-bottom: 0;
    padding: 0;
    background: #121212;
  }

  .el-tabs__item {
    color: rgba(255, 255, 255, 0.68);
  }

  .el-tabs__item.is-active {
    color: #f4c748;
  }

  .el-tabs__active-bar {
    background-color: #f4c748;
  }
}

element.light.scss 浅色主题配置

css 复制代码
.bh-el-light,
.bh-theme-light .bh-el-theme,
.bh-theme-light .bh-el-dark {
  .el-input__wrapper,
  .el-select__wrapper,
  .el-input-number {
    background-color: #fff !important;
    box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12) inset !important;
  }

  .el-input__inner,
  .el-select__placeholder,
  .el-select__selected-item {
    color: rgba(15, 23, 42, 0.82);
  }

  .el-table {
    --el-table-bg-color: #fff;
    --el-table-tr-bg-color: #fff;
    --el-table-header-bg-color: #f5f7fa;
    --el-table-border-color: rgba(15, 23, 42, 0.08);
    --el-table-row-hover-bg-color: rgba(47, 128, 237, 0.08);
    --el-table-text-color: rgba(15, 23, 42, 0.86);
    --el-text-color-regular: rgba(15, 23, 42, 0.86);
    --el-table-header-text-color: rgba(15, 23, 42, 0.66);
  }

  .el-table__inner-wrapper::before {
    background-color: rgba(15, 23, 42, 0.08);
  }

  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
    background: #f8fafc !important;
  }

  .el-table--striped .el-table__body tr.el-table__row td.el-table__cell {
    background: #fff;
  }

  .el-table .el-table__body td.el-table-fixed-column--right,
  .el-table .el-table__body td.el-table-fixed-column--left {
    background: #fff !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped td.el-table-fixed-column--left {
    background: #f8fafc !important;
  }

  .el-table__body tr:hover > td.el-table__cell {
    background: rgba(47, 128, 237, 0.08) !important;
  }

  .el-table__body tr.hover-row > td.el-table-fixed-column--right,
  .el-table__body tr.hover-row > td.el-table-fixed-column--left,
  .el-table__body tr:hover > td.el-table-fixed-column--right,
  .el-table__body tr:hover > td.el-table-fixed-column--left {
    background: rgba(47, 128, 237, 0.08) !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table__cell {
    background: rgba(47, 128, 237, 0.1) !important;
  }

  .el-table--striped .el-table__body tr.el-table__row--striped.hover-row > td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped.hover-row > td.el-table-fixed-column--left,
  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table-fixed-column--right,
  .el-table--striped .el-table__body tr.el-table__row--striped:hover > td.el-table-fixed-column--left {
    background: rgba(47, 128, 237, 0.1) !important;
  }

  .el-pager li,
  .btn-prev,
  .btn-next {
    background: #fff !important;
    color: rgba(15, 23, 42, 0.78) !important;
  }

  .el-pager li.is-active {
    color: #2f80ed !important;
  }
}

.bh-dialog-light,
.bh-theme-light .bh-dialog-theme,
.bh-theme-light .bh-dialog-dark {
  &.el-dialog,
  .el-dialog {
    border-radius: 14px;
    overflow: hidden;
    background: #fff;
    border: 1px solid rgba(15, 23, 42, 0.08);
  }

  .el-dialog__header {
    margin-right: 0;
    padding: 18px 20px;
    border-bottom: 1px solid rgba(15, 23, 42, 0.08);
  }

  .el-dialog__title,
  .el-dialog__close {
    color: #0f172a;
  }

  .el-dialog__body {
    padding: 18px 20px 10px;
    background: #fff;
  }

  .el-dialog__footer {
    padding: 10px 20px 18px;
    border-top: 1px solid rgba(15, 23, 42, 0.08);
    background: #fff;
  }

  .el-form-item__label {
    color: rgba(15, 23, 42, 0.76);
  }

  .el-input__inner::placeholder {
    color: rgba(15, 23, 42, 0.36);
  }

  .el-button--default {
    background: #fff;
    border-color: rgba(15, 23, 42, 0.14);
    color: rgba(15, 23, 42, 0.82);
  }

  .el-button--default:hover {
    background: #f8fafc;
    border-color: rgba(47, 128, 237, 0.42);
    color: #2f80ed;
  }
}

.bh-switch-light,
.bh-theme-light .bh-switch-theme,
.bh-theme-light .bh-switch-dark {
  --el-switch-on-color: #2f80ed;
  --el-switch-off-color: #d8dde6;
  height: 22px;

  .el-switch__core {
    min-width: 50px;
    height: 22px;
    border-radius: 11px;
    border: 1px solid rgba(15, 23, 42, 0.14);
  }

  .el-switch__inner {
    color: #fff;
    font-size: 12px;
    font-weight: 500;
  }

  &:not(.is-checked) .el-switch__core {
    background-color: #d8dde6 !important;
    border-color: rgba(15, 23, 42, 0.16);
  }

  &.is-checked .el-switch__core {
    border-color: rgba(47, 128, 237, 0.72);
  }
}

.bh-select-dropdown-light,
.bh-theme-light .bh-select-dropdown-theme,
.bh-theme-light .bh-select-dropdown-dark {
  background: #fff !important;
  border-color: rgba(15, 23, 42, 0.12) !important;

  .el-popper__arrow::before {
    background: #fff !important;
    border-color: rgba(15, 23, 42, 0.12) !important;
  }

  .el-select-dropdown__item {
    color: rgba(15, 23, 42, 0.78);
  }

  .el-select-dropdown__item.is-hovering {
    background: rgba(47, 128, 237, 0.08);
  }

  .el-select-dropdown__item.is-selected {
    color: #2f80ed;
  }
}

.bh-drawer-light,
.bh-theme-light .bh-drawer-theme,
.bh-theme-light .bh-drawer-dark {
  .el-overlay {
    background-color: rgba(15, 23, 42, 0.22) !important;
  }

  .el-drawer__wrapper {
    background: transparent !important;
  }

  .el-drawer {
    background: #fff;
    border-left: 1px solid rgba(15, 23, 42, 0.08);
    box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12) !important;
  }

  .el-drawer__body {
    height: 100%;
    padding: 0;
    background: #fff;
  }

  .el-drawer__header {
    margin-bottom: 0;
    padding: 0;
    background: #fff;
  }

  .el-tabs__item {
    color: rgba(15, 23, 42, 0.68);
  }

  .el-tabs__item.is-active {
    color: #2f80ed;
  }

  .el-tabs__active-bar {
    background-color: #2f80ed;
  }
}

scrollbar.scss 滚动条配置

css 复制代码
// 默认与当前控制台深色一致
:root {
  --bh-scrollbar-size: 6px;
  --bh-scrollbar-radius: 6px;
  --bh-scrollbar-track: transparent;
  --bh-scrollbar-thumb: rgba(255, 255, 255, 0.16);
  --bh-scrollbar-thumb-hover: rgba(244, 199, 72, 0.35);
  --bh-scrollbar-thumb-border-width: 2px;
  --bh-scrollbar-corner: transparent;
  --bh-scrollbar-track-margin-block: 4px;
}

html[data-bh-theme='light'] {
  --bh-scrollbar-thumb: rgba(15, 23, 42, 0.22);
  --bh-scrollbar-thumb-hover: rgba(47, 128, 237, 0.42);
}

@mixin bh-themed-scrollbar {
  scrollbar-width: thin;
  scrollbar-color: var(--bh-scrollbar-thumb) var(--bh-scrollbar-track);

  &::-webkit-scrollbar {
    width: var(--bh-scrollbar-size);
    height: var(--bh-scrollbar-size);
  }

  &::-webkit-scrollbar-track {
    margin: var(--bh-scrollbar-track-margin-block) 0;
    background: var(--bh-scrollbar-track);
    border-radius: var(--bh-scrollbar-radius);
  }

  &::-webkit-scrollbar-thumb {
    background: var(--bh-scrollbar-thumb);
    border-radius: var(--bh-scrollbar-radius);
    border: var(--bh-scrollbar-thumb-border-width) solid transparent;
    background-clip: padding-box;
  }

  &::-webkit-scrollbar-thumb:hover {
    background: var(--bh-scrollbar-thumb-hover);
    border: var(--bh-scrollbar-thumb-border-width) solid transparent;
    background-clip: padding-box;
  }

  &::-webkit-scrollbar-corner {
    background: var(--bh-scrollbar-corner);
  }
}

.bh-scrollbar {
  @include bh-themed-scrollbar;
}

// Element Plus:固定高度表格内部使用 ElScrollbar,实际滚动层为 .el-scrollbar__wrap。
.bh-el-dark .el-table .el-scrollbar__wrap,
.bh-el-light .el-table .el-scrollbar__wrap,
.bh-el-theme .el-table .el-scrollbar__wrap {
  @include bh-themed-scrollbar;
  overflow-x: hidden !important;
}

.bh-el-dark .el-table .el-scrollbar__bar.is-horizontal,
.bh-el-light .el-table .el-scrollbar__bar.is-horizontal,
.bh-el-theme .el-table .el-scrollbar__bar.is-horizontal {
  display: none !important;
}

.bh-table-x-scroll {
  .el-table .el-scrollbar__wrap {
    overflow-x: auto !important;
  }

  .el-table .el-scrollbar__bar.is-horizontal {
    display: block !important;
  }
}

consoleTheme.ts 后台主题色配置库

css 复制代码
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export type ConsoleThemeMode = 'dark' | 'light'

export interface AccentOption {
  id: string
  label: string
  hex: string
}

const STORAGE_KEY = 'bh-console-theme'

const DEFAULT_THEME: ConsoleThemeMode = 'dark'
const DEFAULT_ACCENT = '#3a82ff'

const ACCENT_OPTIONS: AccentOption[] = [
  { id: 'blue', label: '蓝色', hex: '#3a82ff' },
  { id: 'green', label: '绿色', hex: '#22c55e' },
  { id: 'amber', label: '琥珀色', hex: '#f59e0b' },
  { id: 'red', label: '红色', hex: '#ef4444' },
  { id: 'lime', label: '青柠色', hex: '#84cc16' },
  { id: 'purple', label: '紫色', hex: '#a855f7' },
  { id: 'pink', label: '粉色', hex: '#ec4899' },
  { id: 'cyan', label: '青色', hex: '#06b6d4' },
  { id: 'orange', label: '橙色', hex: '#f97316' },
]

function hexToRgb(hex: string): [number, number, number] {
  const normalized = hex.replace('#', '')
  const value = normalized.length === 3
    ? normalized.split('').map((item) => item + item).join('')
    : normalized
  const num = Number.parseInt(value, 16)
  return [(num >> 16) & 255, (num >> 8) & 255, num & 255]
}

function applyThemeVars(theme: ConsoleThemeMode, accentHex: string) {
  if (typeof document === 'undefined') return
  const root = document.documentElement
  const body = document.body
  const [r, g, b] = hexToRgb(accentHex)

  root.dataset.bhTheme = theme
  body.classList.remove('bh-theme-dark', 'bh-theme-light')
  body.classList.add(`bh-theme-${theme}`)

  root.style.setProperty('--bh-accent', accentHex)
  root.style.setProperty('--bh-accent-rgb', `${r}, ${g}, ${b}`)
  root.style.setProperty('--bh-accent-soft', `rgba(${r}, ${g}, ${b}, 0.14)`)
}

export const useConsoleThemeStore = defineStore('console-theme', () => {
  const theme = ref<ConsoleThemeMode>(DEFAULT_THEME)
  const accentHex = ref(DEFAULT_ACCENT)
  const ready = ref(false)

  const accentOptions = ACCENT_OPTIONS
  const selectedAccent = computed(() => accentOptions.find((item) => item.hex === accentHex.value) ?? accentOptions[0])

  function setTheme(nextTheme: ConsoleThemeMode) {
    theme.value = nextTheme
    applyThemeVars(theme.value, accentHex.value)
    persist()
  }

  function setAccent(nextAccent: string) {
    accentHex.value = nextAccent
    applyThemeVars(theme.value, accentHex.value)
    persist()
  }

  function persist() {
    if (typeof window === 'undefined') return
    const payload = JSON.stringify({
      theme: theme.value,
      accentHex: accentHex.value,
    })
    window.localStorage.setItem(STORAGE_KEY, payload)
  }

  function init() {
    if (ready.value) return
    if (typeof window !== 'undefined') {
      const raw = window.localStorage.getItem(STORAGE_KEY)
      if (raw) {
        const parsed = JSON.parse(raw) as { theme?: ConsoleThemeMode; accentHex?: string }
        if (parsed.theme === 'dark' || parsed.theme === 'light') {
          theme.value = parsed.theme
        }
        if (parsed.accentHex && ACCENT_OPTIONS.some((item) => item.hex === parsed.accentHex)) {
          accentHex.value = parsed.accentHex
        }
      }
    }
    applyThemeVars(theme.value, accentHex.value)
    ready.value = true
  }

  return {
    accentHex,
    accentOptions,
    init,
    selectedAccent,
    setAccent,
    setTheme,
    theme,
  }
})

ThemeDrawer.vue 颜色配置UI

javascript 复制代码
<script setup lang="ts">
import { computed } from 'vue'
import { CaretBottom } from '@element-plus/icons-vue'
import type { AccentOption, ConsoleThemeMode } from '@/stores/consoleTheme'

const props = defineProps<{
  modelValue: boolean
  theme: ConsoleThemeMode
  accentHex: string
  accentOptions: AccentOption[]
}>()

const emit = defineEmits<{
  'update:modelValue': [value: boolean]
  'update:theme': [value: ConsoleThemeMode]
  'update:accentHex': [value: string]
}>()

const currentTheme = computed({
  get: () => props.theme,
  set: (value: ConsoleThemeMode) => emit('update:theme', value),
})

const selectedAccent = computed(() => props.accentOptions.find((item) => item.hex === props.accentHex))
const drawerClass = computed(() => `bh-drawer-${props.theme}`)

function closeDrawer() {
  emit('update:modelValue', false)
}

function onModelValueUpdate(value: boolean) {
  emit('update:modelValue', value)
}

function chooseAccent(hex: string) {
  emit('update:accentHex', hex)
}
</script>

<template>
  <el-drawer
    :model-value="modelValue"
    direction="rtl"
    size="420px"
    :with-header="false"
    append-to-body
    :class="['theme-drawer', drawerClass, 'bh-el-theme', `bh-theme-${theme}`]"
    @close="closeDrawer"
    @update:model-value="onModelValueUpdate"
  >
    <section class="theme-drawer__panel bh-scrollbar">
      <header class="theme-drawer__header">主题设置</header>

      <div class="theme-drawer__block">
        <h3 class="theme-drawer__label">主题风格</h3>
        <el-select v-model="currentTheme" class="theme-drawer__select" :popper-class="`bh-select-dropdown-${theme}`">
          <el-option label="深色" value="dark" />
          <el-option label="浅色" value="light" />
        </el-select>
      </div>

      <div class="theme-drawer__block">
        <h3 class="theme-drawer__label">强调色</h3>
        <div class="theme-drawer__colors">
          <button
            v-for="option in accentOptions"
            :key="option.id"
            type="button"
            class="theme-drawer__color-item"
            :class="{ 'is-active': option.hex === accentHex }"
            :style="{ '--swatch': option.hex }"
            :aria-label="`选择${option.label}`"
            @click="chooseAccent(option.hex)"
          />
        </div>
        <p class="theme-drawer__selected">
          当前颜色:
          <span>{{ selectedAccent?.label ?? '蓝色' }}</span>
        </p>
      </div>

      <button type="button" class="theme-drawer__collapse">
        <el-icon><CaretBottom /></el-icon>
      </button>
    </section>
  </el-drawer>
</template>

<style scoped lang="scss">
.theme-drawer {
  &__panel {
    height: 100%;
    padding: 18px 14px;
    overflow: auto;
  }

  &__header {
    margin: 0 0 14px;
    font-size: 22px;
    font-weight: 700;
    line-height: 1.2;
    color: var(--bh-theme-text-strong);
  }

  &__block {
    margin-bottom: 14px;
    border: 1px solid var(--bh-theme-border);
    background: var(--bh-theme-panel-bg);
  }

  &__label {
    margin: 0;
    padding: 10px 12px;
    font-size: 15px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--bh-theme-text-muted);
    border-bottom: 1px solid var(--bh-theme-border);
  }

  &__select {
    width: calc(100% - 24px);
    margin: 12px;
  }

  &__colors {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    padding: 12px 12px 8px;
  }

  &__color-item {
    width: 30px;
    height: 30px;
    border: 0;
    cursor: pointer;
    background: var(--swatch);
    position: relative;

    &::after {
      content: '';
      position: absolute;
      inset: -2px;
      border: 2px solid transparent;
    }

    &.is-active::after {
      border-color: var(--bh-theme-ring);
    }
  }

  &__selected {
    margin: 0;
    padding: 0 12px 12px;
    font-size: 14px;
    color: var(--bh-theme-text-muted);
  }

  &__collapse {
    margin-top: auto;
    width: 100%;
    height: 42px;
    border: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: transparent;
    color: var(--bh-theme-text-muted);
    cursor: pointer;
    font-size: 20px;
  }
}
</style>

页面调用处 (核心逻辑):

bash 复制代码
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useConsoleThemeStore } from '@/stores/consoleTheme'

const themeDrawerVisible = ref(false)
function openThemeDrawer() {
  themeDrawerVisible.value = true
}
const consoleThemeStore = useConsoleThemeStore()
consoleThemeStore.init()
const themeClass = computed(() => `bh-theme-${consoleThemeStore.theme}`)
const elementThemeClass = computed(() => `bh-el-${consoleThemeStore.theme}`)
</script>

<template>
  <div class="console-layout" :class="[themeClass, elementThemeClass]">
      <ThemeDrawer
      v-model="themeDrawerVisible"
      :theme="consoleThemeStore.theme"
      :accent-hex="consoleThemeStore.accentHex"
      :accent-options="consoleThemeStore.accentOptions"
      @update:theme="consoleThemeStore.setTheme"
      @update:accent-hex="consoleThemeStore.setAccent"
    />
  </div>
</template>
<style scoped lang="scss">
.console-layout {
  height: 100vh;
  max-height: 100dvh;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: 260px minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  overflow: hidden;
  background: var(--bh-theme-bg-app);
  }
</style>

页面引用处:

bash 复制代码
<div class="org-page__panel bh-el-dark flex-column flex-1">
      <div class="org-page__filters">
        <el-input
          v-model="filters.orgName"
          placeholder="请输入组织名称"
          clearable
          @keyup.enter="doSearch"
        />

        <div class="org-page__filter-actions flex">
          <el-button type="primary" :icon="Search" @click="doSearch">筛选</el-button>
          <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
          <el-button type="success" @click="openCreateDialog">新增组织</el-button>
        </div>
      </div>

      <div class="org-page__table-wrap flex-1">
        <el-table v-loading="loading" :data="pagedRows" row-key="id" height="100%" @row-click="handleRowClick">
          //...
        </el-table>
      </div>

      <footer class="org-page__pagination flex flex-justify-end flex-shrink-0">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          background
          layout="total, prev, pager, next, sizes"
          :total="total"
          :page-sizes="[10, 20, 50]"
          @size-change="handlePageSizeChange"
        />
      </footer>
    </div>
相关推荐
a1117761 小时前
细胞结构实验室(react 开源)
前端·javascript·开源·html
aaaak_1 小时前
PDD 直播间 评论 , wss hex Protobuf 解析流程分析学习
java·前端·学习
ikoala1 小时前
用了几周明基 RD280UG,我终于明白程序员为什么需要一台“专用显示器”
前端·后端·程序员
文心快码BaiduComate2 小时前
Comate搭载DeepSeek-V4
前端·后端
豹哥学前端2 小时前
5分钟搞懂事件委托
前端·javascript·面试
Awu12272 小时前
🍎把数学公式搬进 Web 表格:一个 VTable 实战案例
前端
江无行者2 小时前
aly oss技能应用
前端
朝阳392 小时前
单向数据流
前端
小小小小宇2 小时前
H5 嵌入微信 / 支付宝 / 抖音小程序 WebView:调用原生能力完整方案
前端