Vue3.5 企业级管理系统实战(十四):动态主题切换

发布于:2025-04-14 ⋅ 阅读:(25) ⋅ 点赞:(0)

动态主题切换是针对用户体验的常见的功能之一,我们可以自己实现如暗黑模式、明亮模式的切换,也可以利用 Element Plus 默认支持的强大动态主题方案实现。这里我们探讨的是后者通过 CSS 变量设置的方案

1 组件准备

1.1 修改 Navbar 组件

在 src/layout/components/Navbar.vue 中添加设置按钮,代码如下:

//src/layout/components/Navbar.vue
<template>
  <div class="navbar" flex>
    <hamburger
      @toggleCollapse="toggleSidebar"
      :collapse="sidebar.opened"
    ></hamburger>
    <BreadCrumb></BreadCrumb>

    <div flex justify-end flex-1 items-center mr-20px>
      <screenfull mx-5px></screenfull>
      <el-tooltip content="ChangeSize" placement="bottom">
        <size-select></size-select>
      </el-tooltip>
      <svg-icon
        icon-name="ant-design:setting-outlined"
        size-2em
        @click="openShowSetting"
      ></svg-icon>
    </div>
  </div>
</template>

<style scoped lang="scss">
.navbar {
  @apply h-[var(--navbar-height)];
}
</style>

<script lang="ts" setup>
import { useAppStore } from "@/stores/app";
// 在解构的时候要考虑值是不是对象,如果非对象解构出来就 丧失响应式了
const { toggleSidebar, sidebar } = useAppStore();

const emit = defineEmits<{
  (event: "showSetting", isShow: boolean): void;
}>();

const openShowSetting = () => {
  emit("showSetting", true);
};
</script>

1.2 封装 RightPanel 组件

我们希望点击设置按钮后,右侧出现抽屉面板。在 src/components 下新建 RightPanel,然后新建 index.vue 文件,代码如下:

//src/components/RightPanel/index.vue 
<template>
  <el-drawer
    :model-value="modelValue"
    :direction="direction"
    :title="title"
    @close="handleClose"
  >
    <slot></slot>
  </el-drawer>
</template>

<script lang="ts" setup>
import type { DrawerProps } from "element-plus";
import type { PropType } from "vue";
defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  direction: {
    type: String as PropType<DrawerProps["direction"]>,
    default: "rtl"
  },
  title: {
    type: String,
    default: ""
  }
});
const emit = defineEmits(["update:modelValue"]);
const handleClose = () => {
  emit("update:modelValue", false);
};
</script>

1.3 封装 ThemePicker 组件

在 src/components 下新建 ThemePicker/index.vue 用来选择主题颜色,代码如下:

//src/components/ThemeOicker/index.vue
<template>
  <el-color-picker v-model="theme" :predefine="predefineColors" />
</template>

<script lang="ts" setup>
import { useSettingStore } from "@/stores/settings";

const store = useSettingStore();

const theme = ref(store.settings.theme);
const predefineColors = [
  "#ff4500",
  "#ff8c00",
  "#ffd700",
  "#90ee90",
  "#00ced1",
  "#1e90ff",
  "#c71585"
];
watch(theme, (newValue) => {
  store.changeSetting({ key: "theme", value: newValue });
});
</script>

 注:useSettingStore 会在后文中说明。

1.4 封装 Settings 组件

在 src/layout/components 下新建 Settings/index.vue,用来引入系统配置相关的组件,代码如下:

//src/layout/components/Settings/index.vue
<template>
  <div class="drawer-item">
    <span>主题色</span>
    <theme-picker></theme-picker>
  </div>
  <!--后面可以放其他配置项-->
</template>

1.5 页面引用

修改 src/layout/index.vue,引入 RightPanel 组件,并修改 navbar 组件自定义事件,代码如下:

//src/layout/index.vue
<template>
  <div class="app-wrapper">
    <div class="sidebar-container">
      <sidebar></sidebar>
    </div>
    <div class="main-container">
      <div class="header">
        <!--  上边包含收缩的导航条 -->
        <navbar @showSetting="openSetting"></navbar>
        <tags-view></tags-view>
      </div>
      <div class="app-main">
        <app-main></app-main>
      </div>
    </div>

    <right-panel v-model="setting" title="设置">
      <!-- 设置功能 -->
      <Settings></Settings>
    </right-panel>
  </div>
</template>

<script lang="ts" setup>
const setting = ref(false);
const openSetting = () => {
  setting.value = true;
};
</script>
<style lang="scss" scoped>
.app-wrapper {
  @apply flex w-full h-full;
  .sidebar-container {
    // 跨组件设置样式
    @apply bg-[var(--menu-bg)];
    :deep(.sidebar-container-menu:not(.el-menu--collapse)) {
      @apply w-[var(--sidebar-width)];
    }
  }
  .main-container {
    @apply flex flex-col flex-1 overflow-hidden;
  }
  .header {
    @apply h-84px;
    .navbar {
      @apply h-[var(--navbar-height)] bg-yellow;
    }
    .tags-view {
      @apply h-[var(--tagsview-height)] bg-blue;
    }
  }
  .app-main {
    @apply bg-cyan;
    min-height: calc(100vh - var(--tagsview-height) - var(--navbar-height));
  }
}
</style>

2 主题设置方法

2.1 修改 Style

在 src/style 下,添加默认主题色,分别修改 variables.module.scss 和 variables.module.scss.d.ts,代码如下:

//variables.module.scss
$sideBarWidth: 210px;
$navBarHeight: 50px;
$tagsViewHeight: 34px;

// 导航颜色
$menuText: #bfcbd9;
// 导航激活的颜色
$menuActiveText: #409eff;
// 菜单背景色
$menuBg: #304156;
$theme: #409eff;

:export {
  menuText: $menuText;
  menuActiveText: $menuActiveText;
  menuBg: $menuBg;
  theme: $theme;
}
//variables.module.scss.d.ts
interface IVaraibles {
  menuText: string;
  menuActiveText: string;
  menuBg: string;
  theme: string;
  navBarHeight: string;
  tagsViewHeight: string;
}

export const varaibles: IVaraibles;
export default varaibles;

2.2 添加 settings Store

在 src/stores 下新建 settings.ts,代码如下:

//src/stores/settings.ts
import varaibles from "@/style/variables.module.scss";

// 定义一个名为 "setting" 的 Vuex 存储,用于管理应用的设置
export const useSettingStore = defineStore(
  "setting",
  () => {
    // 如果选择的是同样的主题颜色,就不更改,节省
    const settings = reactive({
      theme: varaibles.theme, //当前选择的颜色
      originalTheme: "" //正在应用的颜色
    });
    type ISetting = typeof settings;

    // 定义一个方法来更改设置
    const changeSetting = <T extends keyof ISetting>({
      key,
      value
    }: {
      key: T;
      value: ISetting[T];
    }) => {
      settings[key] = value;
    };
    return { changeSetting, settings };
  },
  {
    persist: {
      storage: sessionStorage, // 使用 sessionStorage 作为持久化存储
      pick: ["settings.theme"] // 持久化 settings 对象中的 theme 属性
    }
  }
);

2.3 安装 css-color-function

查看 Element Plus 源码(如下图,详细代码感兴趣的可以自己去看看源码),可以发现它是用 --el-color-#{$type} 、--el-color-#{$type}-light-{$i} 来设置基础颜色的,我们只需要修改这两个变量对应的颜色,就可以更换主题了。

要生成以上两个变量的形式,我们需要借助一个库 css-color-function,使用 pnpm 安装,代码如下:

pnpm i css-color-function

在 src/vite-env.d.ts中添加声明,增加 ts 提示,代码如下:

/// <reference types="vite/client" />

declare module "element-plus/dist/locale/en.mjs";
declare module "element-plus/dist/locale/zh-cn.mjs";

declare module "css-color-function" {
  export function convert(color: string): string;
}

2.4 color 公共方法

在 src/utils 下新建 color.ts,封装生成和设置主题颜色的方法,代码如下:

//src/utils/color.ts
// 引入 css-color-function 库,该库用于处理 CSS 颜色函数表达式
import cssFunc from "css-color-function";

// 定义一个对象 formula,用于存储颜色生成公式
// 每个属性名代表生成的颜色名称,属性值是一个 CSS 颜色函数表达式
// "xxx" 是一个占位符,后续会被实际的主色调替换
// tint 函数用于将颜色变亮,括号内的百分比表示变亮的程度
const formula: { [prop: string]: string } = {
  "primary-light-1": "color(xxx tint(10%))",
  "primary-light-2": "color(xxx tint(20%))",
  "primary-light-3": "color(xxx tint(30%))",
  "primary-light-4": "color(xxx tint(40%))",
  "primary-light-5": "color(xxx tint(50%))",
  "primary-light-6": "color(xxx tint(60%))",
  "primary-light-7": "color(xxx tint(70%))",
  "primary-light-8": "color(xxx tint(80%))",
  "primary-light-9": "color(xxx tint(90%))"
};

// 定义一个函数 generateColors,用于根据主色调生成一系列变亮的颜色
// 参数 primary 是传入的主色调,通常是一个有效的 CSS 颜色值
const generateColors = (primary: string) => {
  // 定义一个空对象 colors,用于存储生成的颜色
  const colors: Record<string, string> = {};
  // 遍历 formula 对象的每个键值对
  Object.entries(formula).forEach(([key, v]) => {
    // 将颜色公式中的占位符 "xxx" 替换为实际的主色调
    const value = v.replace(/xxx/g, primary);
    // 使用 cssFunc.convert 方法将颜色函数表达式转换为实际的 CSS 颜色值
    // 并将生成的颜色存储到 colors 对象中,键为颜色名称,值为实际颜色值
    colors[key] = cssFunc.convert(value);
  });
  // 返回生成的颜色对象
  return colors;
};

// 定义一个函数 setColors,用于将生成的颜色设置为 HTML 根元素的自定义属性
// 参数 colors 是一个对象,包含颜色名称和对应的实际颜色值
const setColors = (colors: Record<string, string>) => {
  // 获取 HTML 根元素
  const el = document.documentElement;
  // 遍历 colors 对象的每个键值对
  Object.entries(colors).forEach(([key, value]) => {
    // 使用 el.style.setProperty 方法将颜色值设置为根元素的自定义属性
    // 自定义属性的名称为 --el-color- 加上颜色名称
    el.style.setProperty(`--el-color-${key}`, value);
  });
};

// 导出 generateColors 和 setColors 函数,以便在其他模块中使用
export { generateColors, setColors };

‌2.5 设置主题

在 src 下新建 hooks 文件夹,新建 useGenerateTheme.ts 文件,用于监控主题颜色变化并设置主题颜色,代码如下:

//src/hooks/useGenerateTheme.ts 
// 从 @/stores/settings 模块中导入 useSettingStore 函数,该函数用于获取设置状态的存储实例
import { useSettingStore } from "@/stores/settings";
// 从 @/utils/color 模块中导入 generateColors 和 setColors 函数
// generateColors 用于根据主色调生成一系列变亮的颜色
// setColors 用于将生成的颜色设置为 HTML 根元素的自定义属性
import { generateColors, setColors } from "@/utils/color";

// 定义一个组合式函数 useGenerateTheme,用于处理主题生成和更新逻辑
export const useGenerateTheme = () => {
  // 通过调用 useSettingStore 函数获取设置状态的存储实例
  const store = useSettingStore();

  const theme = computed(() => store.settings.theme);
  const originalTheme = computed(() => store.settings.originalTheme);

  // 使用 watchEffect 函数创建一个副作用函数,该函数会在其依赖项发生变化时自动执行
  watchEffect(() => {
    // 检查当前的主题值 theme.value 是否与原始主题值 originalTheme.value 不同
    if (theme.value !== originalTheme.value) {
      // 如果主题值发生了变化,生成新的颜色对象
      const colors = {
        // 将当前主题颜色作为主色调
        primary: theme.value,
        // 调用 generateColors 函数,根据当前主题颜色生成一系列变亮的颜色
        // 并将这些颜色合并到 colors 对象中
        ...generateColors(theme.value)
      };

      // 调用 setColors 函数,将生成的颜色对象应用到 HTML 根元素的自定义属性上
      // 从而实现主题颜色的更新
      setColors(colors);

      // 调用存储实例的 changeSetting 方法,更新存储中的原始主题值
      // 使其与当前主题值保持一致
      store.changeSetting({ key: "originalTheme", value: theme.value });
    }
  });

  // 此函数的主要目的是生成主题颜色并将其应用到 HTML 根元素上
  // 以实现主题的动态更新
};

2.6 页面引入

在 App.vue 中引入 useGenerateTheme.ts 进行应用,代码如下:

//src/App.vue
<template>
  <el-config-provider :size="store.size" :locale="locale">
    <router-view></router-view>
  </el-config-provider>
</template>

<script lang="ts" setup>
import { useAppStore } from "./stores/app";
import en from "element-plus/dist/locale/en.mjs";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
import { useGenerateTheme } from "@/hooks/useGenerateTheme";
const language = ref("zh-cn");
const locale = computed(() => (language.value === "zh-cn" ? zhCn : en));

const store = useAppStore();

useGenerateTheme(); // watch主题
</script>

3 组件应用

3.1 Element 组件

完成文章前面的步骤后,Element 组件都可以在选择主题后,自动变化主题颜色了。效果如下:

3.2 自定义组件

自定义组件要使用主题切换,需要在组件中进行添加,示例如下:

示例1:

修改 TagsView 组件,添加主题部分,因原代码太长,省略其他后的代码如下:

<template>
  <div class="tags-view-container">
    <el-scrollbar w-full whitespace-nowrap>
      <router-link
        class="tags-view-item"
        v-for="(tag, index) in visitedViews"
        :class="{
          active: isActive(tag)
        }"
        :style="{
          backgroundColor: isActive(tag) ? theme : '',
          borderColor: isActive(tag) ? theme : ''
        }"
        :key="index"
        :to="{ path: tag.path, query: tag.query }"
      >
        <!-- 省略其他代码 -->
      </router-link>
    </el-scrollbar>
  </div>
</template>

<script lang="ts" setup>
import { useTagsView } from "@/stores/tagsView";
import type {
  RouteLocationNormalizedGeneric,
  RouteRecordRaw
} from "vue-router";
import { join } from "path-browserify";
import { routes } from "@/router/index"; //从应用的路由配置文件中导入了所有的路由定义
import { useSettingStore } from "@/stores/settings";
//...省略其他代码...
const isActive = (tag: RouteLocationNormalizedGeneric) => {
  return tag.path === route.path;
};
//...省略其他代码...
const settingsStore = useSettingStore();
const theme = computed(() => settingsStore.settings.theme);
</script>

<style scoped>
//...省略其他代码...
</style>

示例2:

修改 Sidebar 组件,添加主题部分,完整代码如下:

//src/layout/components/Sidebar/index.vue
<template>
  <div>
    <el-menu
      class="sidebar-container-menu"
      router
      :default-active="defaultActive"
      :background-color="varaibles.menuBg"
      :text-color="varaibles.menuText"
      :active-text-color="theme"
      :collapse="sidebar.opened"
    >
      <sidebar-item
        v-for="route in routes"
        :key="route.path"
        :item="route"
        :base-path="route.path"
      />
      <!-- 增加父路径,用于el-menu-item渲染的时候拼接 -->
    </el-menu>
  </div>

  <!-- :collapse="true" -->
</template>

<script lang="ts" setup>
import { useAppStore } from "@/stores/app";
import varaibles from "@/style/variables.module.scss";
import { routes } from "@/router";
import { useSettingStore } from "@/stores/settings";

const route = useRoute();

const { sidebar } = useAppStore();

const defaultActive = computed(() => {
  // .....
  return route.path;
});

const settingsStore = useSettingStore();
const theme = computed(() => settingsStore.settings.theme);
</script>

<style scoped></style>

完成后,页面效果如下:

以上,就是主题切换的全部内容。

下一篇将继续探讨 tagsView 切换设置,敬请期待~

 


网站公告

今日签到

点亮在社区的每一天
去签到