动态修改主题
这里主要通过动态选择主题色,根据element-plus/theme-chalk/index.css主题文件作为模板,用主题色替换变量 动态生成 全新的theme主题样式内容,内嵌到style标签中。
elemen主题涉及到的主题变量为以下几种:
element-plus ui每套主题 共用到以下种颜色 根据白色和主题色进行混合生成渐变色
sasss中Mix 函数是将两种颜色根据一定的比例混合在一起,生成另一种颜色
$--color-primary: #409EFF !default;
// 下面几种都是根据主题色生成的渐变色 所以我们只关心主题色是什么 然后再生成这九种渐变色 把原始模板替换掉
$--color-primary-light-1: mix($--color-white, $--color-primary, 10%) !default; /* 53a8ff */
$--color-primary-light-2: mix($--color-white, $--color-primary, 20%) !default; /* 66b1ff */
$--color-primary-light-3: mix($--color-white, $--color-primary, 30%) !default; /* 79bbff */
$--color-primary-light-4: mix($--color-white, $--color-primary, 40%) !default; /* 8cc5ff */
$--color-primary-light-5: mix($--color-white, $--color-primary, 50%) !default; /* a0cfff */
$--color-primary-light-6: mix($--color-white, $--color-primary, 60%) !default; /* b3d8ff */
$--color-primary-light-7: mix($--color-white, $--color-primary, 70%) !default; /* c6e2ff */
$--color-primary-light-8: mix($--color-white, $--color-primary, 80%) !default; /* d9ecff */
$--color-primary-light-9: mix($--color-white, $--color-primary, 90%) !default; /* ecf5ff */
关于element ui 颜色 设计可以阅读这两篇文章
https://element-plus.gitee.io/#/zh-CN/component/color
https://juejin.cn/post/6844903960218697741
效果图
默认情况下
修改主题
确定后
主题修改 看下html 动态生成的style
本章建议
> 建议大家先结合完整源码 先过一遍 别着急写 先看下主线 再细究
看源码时从App.vue里这个hook方法开始看 生成主题主要逻辑就在这个hook方法里
vue3-admin/src/App.vue
目前最新源码 https://gitee.com/brolly/vue3-element-admin
图标字体文件准备
选择对应版本图标字体
https://unpkg.com/browse/element-plus@1.0.2-beta.33/lib/theme-chalk/fonts/
下载下来放到 vue3-admin/public下
如何下载
点击ttf文件和woff文件 进去后点击 view raw下载
2-1 安装相关依赖
安装axios
首次我们需要远程获取element-plus/lib/theme-chalk/index.css 主题样式文件,作为原始模板
npm i axios
安装css-color-function
将通过该包提供的convert函数生成将css color-mod函数生成的渐变色 转换成rgb css-color-function使用说明文档
根据css-color-function 的color.convert函数 将颜色函数color CSS字符串转换为RGB颜色字符串。
// 下面color函数以及 shade tin是css的color-mod函数
// 了解文档 http://cdn1.w3cplus.com/css4/color-mod.html
'color(#11A983 shade(10%))' => 'rgb(15, 152, 118)'
'color(#11A983 tint(10%))' => 'rgb(41, 178, 143)'
'color(#11A983 tint(20%))' => 'rgb(65, 186, 156)'
'color(#11A983 tint(30%))' => 'rgb(88, 195, 168)'
'color(#11A983 tint(40%))' => 'rgb(112, 203, 181)'
'color(#11A983 tint(50%))' => 'rgb(136, 212, 193)'
'color(#11A983 tint(60%))' => 'rgb(160, 221, 205)'
'color(#11A983 tint(70%))' => 'rgb(184, 229, 218)'
'color(#11A983 tint(80%))' => 'rgb(207, 238, 230)'
'color(#11A983 tint(90%))' => 'rgb(231, 246, 243)'
// 利用css-color-function转换为rgb
import color from 'css-color-function'
color.convert('color(#11A983 shade(10%))') // 'rgb(15, 152, 118)'
npm i css-color-function
手动创建声明文件
可能需要重新启动npm run serve 如果还报缺少声明文件 重启下
src/css-color-function.d.ts
declare module 'css-color-function' {
export function convert(color: string): string;
}
2-2 Navbar添加设置图标
src/layout/components/Navbar.vue
<template>
<div class="navbar">
<hambuger @toggleClick="toggleSidebar" :is-active="sidebar.opened"/>
<breadcrumb />
<div class="right-menu">
<!-- 设置 -->
<div @click="openShowSetting" class="setting right-menu-item hover-effect">
<i class="el-icon-s-tools"></i>
</div>
<!-- 全屏 -->
<screenfull id="screefull" class="right-menu-item hover-effect" />
<!-- element组件size切换 -->
<el-tooltip content="Global Size" effect="dark" placement="bottom">
<size-select class="right-menu-item hover-effect" />
</el-tooltip>
<!-- 用户头像 -->
<avatar />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
import Breadcrumb from '@/components/Breadcrumb/index.vue'
import Hambuger from '@/components/Hambuger/index.vue'
import { useStore } from '@/store/index'
import Screenfull from '@/components/Screenfull/index.vue'
import SizeSelect from '@/components/SizeSelect/index.vue'
import Avatar from './avatar/index.vue'
export default defineComponent({
name: 'Navbar',
components: {
Breadcrumb,
Hambuger,
Screenfull,
SizeSelect,
Avatar
},
emits: ['showSetting'],
setup(props, { emit }) {
// 使用我们自定义的useStore 具备类型提示
// store.state.app.sidebar 对于getters里的属性没有类型提示
const store = useStore()
const toggleSidebar = () => {
store.dispatch('app/toggleSidebar')
}
// 从getters中获取sidebar
const sidebar = computed(() => store.getters.sidebar)
// 打开设置面板
const openShowSetting = () => {
emit('showSetting', true)
}
return {
toggleSidebar,
sidebar,
openShowSetting
}
}
})
</script>
<style lang="scss">
.navbar {
display: flex;
background: #fff;
border-bottom: 1px solid rgba(0, 21, 41, .08);
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
.right-menu {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 15px;
.setting {
font-size: 26px;
}
&-item {
padding: 0 8px;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025);
}
}
}
}
}
</style>
2-2 封装RightPanel组件
element.ts中导入el-drawer组件
src/components/RightPanel/index.vue
<template>
<div class="right-panel">
<el-drawer
:model-value="modelValue"
:direction="direction"
:show-close="showClose"
:custom-class="customClass"
:with-header="withHeader"
:title="title"
:size="size"
@close="handleClose"
>
<slot />
</el-drawer>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'RightPanel',
props: {
modelValue: {
type: Boolean,
default: true
},
direction: {
type: String,
validator(val: string) {
return ['rtl', 'ltr', 'ttb', 'btt'].includes(val)
},
default: 'rtl'
},
title: {
type: String,
default: '自定义title'
},
size: {
type: [String, Number]
},
customClass: {
type: String,
default: 'setting-panel'
},
showClose: {
type: Boolean,
default: true
},
withHeader: {
type: Boolean,
default: true
}
},
// 在组件上使用modelValue文档说明
// https://v3.cn.vuejs.org/guide/component-basics.html#%E5%9C%A8%E7%BB%84%E4%BB%B6%E4%B8%8A%E4%BD%BF%E7%94%A8-v-model
emits: ['update:modelValue', 'close'],
setup(props, { emit }) {
const handleClose = () => {
emit('update:modelValue', false)
emit('close')
}
return {
handleClose
}
}
})
</script>
<style lang="scss" scoped>
</style>
layout组件中导入rightPanel
src/layout/index.vue
<template>
<div class="app-wrapper">
<div class="sidebar-container">
<Sidebar />
</div>
<div class="main-container">
<div class="header">
<navbar @showSetting="openSetting" />
<tags-view />
</div>
<!-- AppMain router-view -->
<app-main />
</div>
<right-panel
v-model="showSetting"
title="样式风格设置"
:size="SettingsPanelWidth"
>
<!-- settings 面板设置组件 -->
<settings />
</right-panel>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import Sidebar from './components/Sidebar/index.vue'
import AppMain from './components/AppMain.vue'
import Navbar from './components/Navbar.vue'
import TagsView from './components/TagsView/index.vue'
import RightPanel from '@/components/RightPanel/index.vue'
import Settings from './components/Settings/index.vue'
import varibalse from '@/styles/variables.scss'
export default defineComponent({
components: {
Sidebar,
AppMain,
Navbar,
TagsView,
RightPanel,
Settings
},
setup() {
// rightPanel显示隐藏状态
const showSetting = ref(false)
const openSetting = () => {
showSetting.value = true
}
return {
showSetting,
openSetting,
// 调整panel宽度
SettingsPanelWidth: varibalse.settingPanelWidth
}
}
})
</script>
<style lang="scss" scoped>
.app-wrapper {
display: flex;
width: 100%;
height: 100%;
.main-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.app-main {
/* 50= navbar 50 如果有tagsview + 34 */
min-height: calc(100vh - 84px);
}
}
}
</style>
调整样式修改scss变量
src/styles/variables.scss
scss变量类型声明不要忘了
src/styles/variables.scss
// base color
$blue:#324157;
$light-blue:#3A71A8;
$red:#C03639;
$pink: #E65D6E;
$green: #30B08F;
$tiffany: #4AB7BD;
$yellow:#FEC171;
$panGreen: #30B08F;
// sidebar
$menuText:#bfcbd9;
$menuActiveText:#409EFF;
$subMenuActiveText:#f4f4f5; // https://github.com/ElemeFE/element/issues/12951
$menuBg:#304156;
$menuHover:#263445;
$subMenuBg:#1f2d3d;
$subMenuHover:#001528;
$sideBarWidth: 210px;
$settingPanelWidth: 260px;
// 默认主题色
$theme: #409EFF;
// The :export directive is the magic sauce for webpack
// https://mattferderer.com/use-sass-variables-in-typescript-and-javascript
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
theme: $theme;
settingPanelWidth: $settingPanelWidth;
}
src/styles/variables.scss.d.ts
export interface ScssVariables {
menuText: string;
menuActiveText: string;
subMenuActiveText: string;
menuBg: string;
menuHover: string;
subMenuBg: string;
subMenuHover: string;
sideBarWidth: string;
theme: string;
settingPanelWidth: string;
}
export const variables: ScssVariables
export default variables
2-3 创建settings组件
src/store/modules/settings.ts
<template>
<div class="drawer-container">
<div class="drawer-item">
<span>主题色</span>
<!-- 主题组件 -->
<theme-picker />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import ThemePicker from '@/components/ThemePicker/index.vue'
export default defineComponent({
name: 'Settings',
components: {
ThemePicker
}
})
</script>
<style lang="scss" scoped>
.drawer-container {
padding: 24px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
.drawer-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
font-size: 16px;
color: rgba(0, 0, 0, .65);
}
}
</style>
2-4 创建ThemePicker组件
颜色面板组件
element.ts导入color picker组件
import { App } from 'vue'
import {
locale,
ElButton,
ElMessage,
ElNotification,
ElMessageBox,
ElMenu,
ElMenuItem,
ElSubmenu,
ElRow,
ElCol,
ElBreadcrumb,
ElBreadcrumbItem,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElScrollbar,
ElDrawer,
ElColorPicker
} from 'element-plus'
// 默认主题
import 'element-plus/lib/theme-chalk/index.css'
// Element Plus 组件内部默认使用英语
// https://element-plus.gitee.io/#/zh-CN/component/i18n
import lang from 'element-plus/lib/locale/lang/zh-cn'
// Element Plus 直接使用了 Day.js 项目的时间日期国际化设置, 并且会自动全局设置已经导入的 Day.js 国际化配置。
import 'dayjs/locale/zh-cn'
// $ELEMENT size属性类型
export type Size = 'default' | 'medium' | 'small' | 'mini'
interface ElementOptions {
size: Size
}
export default (app: App, options: ElementOptions): void => {
locale(lang)
// 按需导入组件列表
const components = [
ElButton,
ElMessage,
ElNotification,
ElMessageBox,
ElMenu,
ElMenuItem,
ElSubmenu,
ElRow,
ElCol,
ElBreadcrumb,
ElBreadcrumbItem,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElScrollbar,
ElDrawer,
ElColorPicker
]
components.forEach(component => {
app.component(component.name, component)
})
// Vue.prototype 替换为 config.globalProperties
// 文档说明 https://v3.cn.vuejs.org/guide/migration/global-api.html#vue-prototype-%E6%9B%BF%E6%8D%A2%E4%B8%BA-config-globalproperties
app.config.globalProperties.$message = ElMessage
app.config.globalProperties.$notify = ElNotification
app.config.globalProperties.$confirm = ElMessageBox.confirm
app.config.globalProperties.$alert = ElMessageBox.alert
app.config.globalProperties.$prompt = ElMessageBox.prompt
// 全局配置 https://element-plus.gitee.io/#/zh-CN/component/quickstart#quan-ju-pei-zhi
// 该对象目前支持 size 与 zIndex 字段。size 用于改变组件的默认尺寸 small,zIndex 设置弹框的初始 z-index(默认值:2000)。
app.config.globalProperties.$ELEMENT = {
size: options.size
}
}
src/components/ThemePicker/index.vue
<template>
<el-color-picker
v-model="theme"
class="theme-picker"
:predefine="themeColor"
popper-class="theme-picker-dropdown"
/>
</template>
<script lang='ts'>
import { defineComponent, ref, computed, watch } from 'vue'
import { useStore } from '@/store'
import { useGenerateTheme } from '@/hooks/useGenerateTheme'
export default defineComponent({
name: 'ThemePicker',
setup() {
const store = useStore()
// 预设可选颜色
// eslint-disable-next-line comma-spacing, comma-dangle
const themeColor = ['#409EFF', '#1890ff', '#304156', '#212121', '#11a983', '#13c2c2', '#6959CD', '#f5222d',]
// store中获取默认主题色
const defaultTheme = computed(() => store.state.settings.theme)
const theme = ref('')
// 主题生成方法 稍后
const { generateTheme } = useGenerateTheme()
// 监听默认样式
watch(defaultTheme, value => {
theme.value = value
}, {
immediate: true
})
// 根据theme选择变化 重新生成主题
watch(theme, (value) => {
// 同步store
store.dispatch('settings/changeSetting', { key: 'theme', value })
// 根据theme选择变化 重新生成主题
generateTheme(value)
})
return {
themeColor,
theme
}
}
})
</script>
<style lang="scss">
.theme-picker {
height: 26px !important;
margin-right: 8px;
.el-color-picker__trigger {
height: 26px !important;
width: 26px !important;
padding: 2px;
}
}
.theme-message,
.theme-picker-dropdown {
z-index: 99999 !important;
}
.theme-picker-dropdown .el-color-dropdown__link-btn {
display: none;
}
</style>
2-5 store中存储theme
创建settings module
src/store/modules/settings.ts
import { MutationTree, ActionTree } from 'vuex'
import variables from '@/styles/variables.scss'
import { IRootState } from '@/store'
export interface ISettingsState {
theme: string;
originalStyle: string;
}
// 定义state
const state: ISettingsState = {
theme: variables.theme,
originalStyle: '' // 保存element 主题样式文件内容 作为替换模板
}
// 动态key的情况下 根据不同的key 约束对应value
// http://www.voidcn.com/article/p-wtmkdcie-byz.html
type ValueOf<T> = T[keyof T];
interface ISettings { // 约束payload类型
key: keyof ISettingsState; // 约束为ISettingsState中key
value: ValueOf<ISettingsState>; // 约束为ISettingsState中value的类型
}
// 定义mutations 通用muation
const mutations: MutationTree<ISettingsState> = {
CHANGE_SETTING(state, { key, value }: ISettings) {
if (key in state) {
(state[key] as ValueOf<ISettingsState>) = value
}
}
}
const actions: ActionTree<ISettingsState, IRootState> = {
changeSetting({ commit }, data) {
commit('CHANGE_SETTING', data)
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
store中缓存settings.theme和settings.originalStyle
src/store/index.ts
import { InjectionKey } from 'vue'
import { createStore, Store, useStore as baseUseStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import app, { IAppState } from '@/store/modules/app'
import tagsView, { ITagsViewState } from '@/store/modules/tagsView'
import settings, { ISettingsState } from '@/store/modules/settings'
import getters from './getters'
// 模块声明在根状态下
export interface IRootState {
app: IAppState;
tagsView: ITagsViewState;
settings: ISettingsState;
}
// 通过下面方式使用 TypeScript 定义 store 能正确地为 store 提供类型声明。
// https://next.vuex.vuejs.org/guide/typescript-support.html#simplifying-usestore-usage
// eslint-disable-next-line symbol-description
export const key: InjectionKey<Store<IRootState>> = Symbol()
// 对于getters在组件使用时没有类型提示
// 有人提交了pr #1896 为getters创建泛型 应该还未发布
// https://github.com/vuejs/vuex/pull/1896
// 代码pr内容详情
// https://github.com/vuejs/vuex/pull/1896/files#diff-093ad82a25aee498b11febf1cdcb6546e4d223ffcb49ed69cc275ac27ce0ccce
// vuex store持久化 默认使用localstorage持久化
const persisteAppState = createPersistedState({
storage: window.sessionStorage, // 指定storage 也可自定义
key: 'vuex_app', // 存储名 默认都是vuex 多个模块需要指定 否则会覆盖
// paths: ['app'] // 针对app这个模块持久化
// 只针对app模块下sidebar.opened状态持久化
paths: ['app.sidebar.opened', 'app.size'] // 通过点连接符指定state路径
})
const persisteSettingsState = createPersistedState({
storage: window.sessionStorage, // 指定storage 也可自定义
key: 'vuex_setting', // 存储名 默认都是vuex 多个模块需要指定 否则会覆盖
// paths: ['app'] // 针对app这个模块持久化
// 只针对app模块下sidebar.opened状态持久化
paths: ['settings.theme', 'settings.originalStyle'] // 通过点连接符指定state路径
})
export default createStore<IRootState>({
plugins: [
persisteAppState,
persisteSettingsState
],
getters,
modules: {
app,
tagsView,
settings
}
})
// 定义自己的 `useStore` 组合式函数
// https://next.vuex.vuejs.org/zh/guide/typescript-support.html#%E7%AE%80%E5%8C%96-usestore-%E7%94%A8%E6%B3%95
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function useStore () {
return baseUseStore(key)
}
// vuex持久化 vuex-persistedstate文档说明
// https://www.npmjs.com/package/vuex-persistedstate
getters添加theme
src/store/getters.ts
import { GetterTree } from 'vuex'
import { IRootState } from './index'
// 定义全局getters
const getters: GetterTree<IRootState, IRootState> = {
sidebar: (state) => state.app.sidebar,
size: state => state.app.size,
themeColor: state => state.settings.theme
}
export default getters
2-6 主题生成逻辑
最开始需要在App.vue中调用生成主题hooks useGenerateTheme ThemePicker组件里也要调用 useGenerateTheme 更好选择的主题颜色 生成主题
src/App.vue
<template>
<div id="app">
<router-view />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useGenerateTheme } from '@/hooks/useGenerateTheme'
export default defineComponent({
name: 'App',
setup() {
// 根据此时store中主题色生成
useGenerateTheme()
}
})
</script>
<style>
#app {
height: 100%;
}
</style>
创建useGenerateTheme Hook函数
src目录下创建hooks文件夹
src/utils/useGenerateTheme.ts
import { computed } from 'vue'
import { useThemeFiles } from '@/hooks/useThemeFiles'
import { getStyleTemplate } from '@/utils/getStyleTemplate'
import { generateColors } from '@/utils/color'
import { writeNewStyle } from '@/utils/writeNewStyle'
import { useStore } from '@/store'
export const useGenerateTheme = () => {
const store = useStore()
// 从store获取中获取 theme主题色
const defaultTheme = computed(() => store.state.settings.theme)
// 获取element-ui 主题文件内容 通过axios获取的 作为变量替换模板
const originalStyle = computed(() => store.state.settings.originalStyle)
// 生成主题
// 了解element ui 设计 https://juejin.cn/post/6844903960218697741
const generateTheme = (color: string) => {
const colors = Object.assign({
primary: defaultTheme.value
}, generateColors(color))
// 写入新的css样式
writeNewStyle(originalStyle.value, colors)
}
// 第一步 远程获取element-ui 主题文件作为模板 然后进行变量替换 替换成我们所选主题色
const { getThemeChalkStyle } = useThemeFiles()
// 如果主题模板不存在 就要发送请求获取
if (!originalStyle.value) {
// axios请求后去 主题模板
getThemeChalkStyle().then(data => {
// data是获取到主题文件的css内容
// 生成样式模板 将里面css内容里默认主题颜色值 替换成变量标记 如 '#409eff' => 'primary',
// 方便我们后续 把primary字符标记 换成我们的主题色
const styleValue = getStyleTemplate(data as string)
// 把模板保存到 store中缓存起来 不用每次重新获取
store.dispatch('settings/changeSetting', { key: 'originalStyle', value: styleValue })
// 根据主题色生成主题 插入到html中
generateTheme(defaultTheme.value)
})
} else {
generateTheme(defaultTheme.value)
}
return {
generateTheme
}
}
创建useThemeFiles hooks函数
主要是为了远程获取 elment-ui theme index.css
src/hooks/useThemeFiles.ts
import { useFetch } from './useFetch'
import pkgJson from 'element-plus/package.json'
// 获取element-plus版本
const version = pkgJson.version
interface ReturnFn {
getThemeChalkStyle: () => Promise<unknown>
}
export const useThemeFiles = ():ReturnFn => {
const getThemeChalkStyle = async (): Promise<unknown> => {
// 返回获取到的指定版本的element主题css内容
// return await useFetch('//unpkg.com/element-plus@1.0.2-beta.33/lib/theme-chalk/index.css')
return await useFetch(`//unpkg.com/element-plus@${version}/lib/theme-chalk/index.css`)
}
return {
getThemeChalkStyle
}
}
上面ts中导入了json文件 需要配置tsconfig
添加”resolveJsonModule”: true即可
创建useFetch请求hook函数
src/hooks/useFetch.ts
import axios from 'axios'
const useFetch = async (url: string): Promise<unknown> => {
return await new Promise((resolve, reject) => {
axios({
url,
method: 'get'
}).then(res => {
if (res.status === 200) {
resolve(res.data)
} else {
reject(new Error(res.statusText))
}
}).catch(err => {
reject(new Error(err.message))
})
})
}
export {
useFetch
}
生成最终的样式模板
主要就是把获取到默认主题css内容 替换成变量标记,后续作为模板替换为我们想要的主题色和渐变色
export interface IObject {
[prop: string]: string
}
// element ui 关于以下颜色设计阅读资料
// https://juejin.cn/post/6844903960218697741
// 官方文档说明
// https://element-plus.gitee.io/#/zh-CN/component/custom-theme
// element-plus ui每套主题 共用到以下多种颜色 根据根据白色和主题色进行混合生成渐变色
// Mix 函数是将两种颜色根据一定的比例混合在一起,生成另一种颜色
// $--color-primary: #409EFF !default;
// $--color-primary-light-1: mix($--color-white, $--color-primary, 10%) !default; /* 53a8ff */
// $--color-primary-light-2: mix($--color-white, $--color-primary, 20%) !default; /* 66b1ff */
// $--color-primary-light-3: mix($--color-white, $--color-primary, 30%) !default; /* 79bbff */
// $--color-primary-light-4: mix($--color-white, $--color-primary, 40%) !default; /* 8cc5ff */
// $--color-primary-light-5: mix($--color-white, $--color-primary, 50%) !default; /* a0cfff */
// $--color-primary-light-6: mix($--color-white, $--color-primary, 60%) !default; /* b3d8ff */
// $--color-primary-light-7: mix($--color-white, $--color-primary, 70%) !default; /* c6e2ff */
// $--color-primary-light-8: mix($--color-white, $--color-primary, 80%) !default; /* d9ecff */
// $--color-primary-light-9: mix($--color-white, $--color-primary, 90%) !default; /* ecf5ff */
// 根据样式内容生成样式模板
export const getStyleTemplate = (data: string): string => {
// 这些是我们要把key也就是css内容中颜色值 替换成右边value作为变量标记 后续我们之关系右边value这些变量标记
const colorMap: IObject = {
'#3a8ee6': 'shade-1',
'#409eff': 'primary',
'#53a8ff': 'light-1',
'#66b1ff': 'light-2',
'#79bbff': 'light-3',
'#8cc5ff': 'light-4',
'#a0cfff': 'light-5',
'#b3d8ff': 'light-6',
'#c6e2ff': 'light-7',
'#d9ecff': 'light-8',
'#ecf5ff': 'light-9'
}
Object.keys(colorMap).forEach(key => {
const value = colorMap[key]
// 将key对应的颜色值 替换成 右边对应value primary list-1这些 后续生成主题 作为替换变量标记使用
data = data.replace(new RegExp(key, 'ig'), value)
})
return data // 返回css字符串模板 之后主要靠它用我们所选的主题色 渐变色 把里面 变量标记替换掉生成主题css
}
generateTheme生成主题函数
这个函数直接定义在了useGenerateTheme Hook中 并返回到外面 供其他组件使用
generateTheme函数
// 生成主题
// element ui 设计 https://juejin.cn/post/6844903960218697741
const generateTheme = (color: string) => {
const colors = Object.assign({
primary: defaultTheme.value
}, generateColors(color))
// 动态创建style标签挂载到html中 并写入新的css样式
writeNewStyle(originalStyle.value, colors)
}
src/utils/useGenerateTheme.ts
import { computed } from 'vue'
import { useThemeFiles } from '@/hooks/useThemeFiles'
import { getStyleTemplate } from '@/utils/getStyleTemplate'
import { generateColors } from '@/utils/color'
import { writeNewStyle } from '@/utils/writeNewStyle'
import { useStore } from '@/store'
export const useGenerateTheme = () => {
const store = useStore()
const defaultTheme = computed(() => store.state.settings.theme)
const originalStyle = computed(() => store.state.settings.originalStyle)
// 生成主题
// element ui 设计 https://juejin.cn/post/6844903960218697741
const generateTheme = (color: string) => {
const colors = Object.assign({
primary: defaultTheme.value
}, generateColors(color))
// 写入新的css样式
writeNewStyle(originalStyle.value, colors)
}
// 第一步 远程获取element-ui 主题文件作为模板 然后进行变量替换 替换成我们所选主题色
const { getThemeChalkStyle } = useThemeFiles()
if (!originalStyle.value) {
getThemeChalkStyle().then(data => {
// data是主题文件的css内容
const styleValue = getStyleTemplate(data as string)
store.dispatch('settings/changeSetting', { key: 'originalStyle', value: styleValue })
generateTheme(defaultTheme.value)
})
} else {
generateTheme(defaultTheme.value)
}
return {
generateTheme
}
}
generateColors函数
generateTheme函数 里会调用此函数
src/utils/color.ts
import color from 'css-color-function'
import { formula, IObject } from './constants'
// 转换成不同色调的rgb颜色值
// https://www.w3cplus.com/css/the-power-of-rgba.html
export const generateColors = (primary: string): IObject => {
const colors = {} as IObject
Object.keys(formula).forEach(key => {
// element ui 主题色 渐变色设计 https://juejin.cn/post/6844903960218697741
// 根据主题色生成渐变色 将formula对象中字符primary 替换成我们所选的主题色
const value = formula[key].replace(/primary/g, primary)
colors[key] = color.convert(value) // 转换成rgba颜色值
})
return colors
}
// 主题色的渐变色设计 https://juejin.cn/post/6844903960218697741
// color-mod css颜色函数
// https://www.w3cplus.com/css4/color-mod.html
// export const formula: IObject = {
// 'shade-1': 'color(primary shade(10%))',
// 'light-1': 'color(primary tint(10%))',
// 'light-2': 'color(primary tint(20%))',
// 'light-3': 'color(primary tint(30%))',
// 'light-4': 'color(primary tint(40%))',
// 'light-5': 'color(primary tint(50%))',
// 'light-6': 'color(primary tint(60%))',
// 'light-7': 'color(primary tint(70%))',
// 'light-8': 'color(primary tint(80%))',
// 'light-9': 'color(primary tint(90%))'
// }
constants变量
src/utils/constants.ts
export interface IObject {
[prop: string]: string;
}
// 主题色的渐变色设计 https://juejin.cn/post/6844903960218697741
// color-mod css颜色函数
// https://www.w3cplus.com/css4/color-mod.html
export const formula: IObject = {
'shade-1': 'color(primary shade(10%))',
'light-1': 'color(primary tint(10%))',
'light-2': 'color(primary tint(20%))',
'light-3': 'color(primary tint(30%))',
'light-4': 'color(primary tint(40%))',
'light-5': 'color(primary tint(50%))',
'light-6': 'color(primary tint(60%))',
'light-7': 'color(primary tint(70%))',
'light-8': 'color(primary tint(80%))',
'light-9': 'color(primary tint(90%))'
}
writeNewStyle函数
generateTheme函数 里会调用此函数 插入style到html中
src/utils/writeNewStyle.ts
import { IObject } from './constants'
// 写入新的css样式
export const writeNewStyle = (originalStyle: string, colors: IObject): void => {
Object.keys(colors).forEach(key => {
// 根据模板将之前变量标记替换成颜色值
const reg = new RegExp('(:|\\s+)' + key, 'g')
originalStyle = originalStyle.replace(reg, '$1' + colors[key])
})
// 之前有插入过id名为chalk-theme-style style元素就直接重新里面内容 没有就动态创建style并加上id
const chalkStyle = document.getElementById('chalk-theme-style')
if (!chalkStyle) {
const style = document.createElement('style')
style.innerText = originalStyle
style.id = 'chalk-theme-style'
// 插入到head中
document.head.appendChild(style)
} else {
(chalkStyle as HTMLElement).innerText = originalStyle
}
}
2-7 修改tagviews组件使用主题色
src/layout/components/TagsView/index.vue
<template>
<div class="tags-view-container">
<scroll-panel>
<div class="tags-view-wrapper">
<router-link
class="tags-view-item"
:class="{
active: isActive(tag)
}"
:style="{
backgroundColor: isActive(tag) ? themeColor : '',
borderColor: isActive(tag) ? themeColor : ''
}"
v-for="(tag, index) in visitedTags"
:key="index"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
>
<el-dropdown
trigger="contextmenu"
@command="command => handleTagCommand(command, tag)">
<span>
{{ tag.meta.title }}
<!-- affix固定的路由tag是无法删除 -->
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
></span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="all">关闭所有</el-dropdown-item>
<el-dropdown-item command="other">关闭其他</el-dropdown-item>
<el-dropdown-item command="self" v-if="!tag.meta || !tag.meta.affix">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</router-link>
</div>
</scroll-panel>
</div>
</template>
<script lang="ts">
import path from 'path'
import { defineComponent, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute, RouteRecordRaw, useRouter } from 'vue-router'
import { useStore } from '@/store'
import { RouteLocationWithFullPath } from '@/store/modules/tagsView'
import { routes } from '@/router'
import ScrollPanel from './ScrollPanel.vue'
// 右键菜单
enum TagCommandType {
All = 'all',
Other = 'other',
Self = 'self',
Refresh = 'refresh'
}
export default defineComponent({
name: 'TagsView',
components: {
ScrollPanel
},
setup() {
const store = useStore()
const router = useRouter()
const route = useRoute()
// 可显示的tags view
const visitedTags = computed(() => store.state.tagsView.visitedViews)
// 从路由表中过滤出要affixed tagviews
const fillterAffixTags = (routes: Array<RouteLocationWithFullPath | RouteRecordRaw>, basePath = '/') => {
let tags: RouteLocationWithFullPath[] = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
// 把路由路径解析成完整路径,路由可能是相对路径
const tagPath = path.resolve(basePath, route.path)
tags.push({
name: route.name,
path: tagPath,
fullPath: tagPath,
meta: { ...route.meta }
} as RouteLocationWithFullPath)
}
// 深度优先遍历 子路由(子路由路径可能相对于route.path父路由路径)
if (route.children) {
const childTags = fillterAffixTags(route.children, route.path)
if (childTags.length) {
tags = [...tags, ...childTags]
}
}
})
return tags
}
// 初始添加affix的tag
const initTags = () => {
const affixTags = fillterAffixTags(routes)
for (const tag of affixTags) {
if (tag.name) {
store.dispatch('tagsView/addVisitedView', tag)
}
}
}
// 添加tag
const addTags = () => {
const { name } = route
if (name) {
store.dispatch('tagsView/addView', route)
}
}
// 路径发生变化追加tags view
watch(() => route.path, () => {
addTags()
})
// 最近当前router到tags view
onMounted(() => {
initTags()
addTags()
})
// 当前是否是激活的tag
const isActive = (tag: RouteRecordRaw) => {
return tag.path === route.path
}
// 让删除后tags view集合中最后一个为选中状态
const toLastView = (visitedViews: RouteLocationWithFullPath[], view: RouteLocationWithFullPath) => {
// 得到集合中最后一个项tag view 可能没有
const lastView = visitedViews[visitedViews.length - 1]
if (lastView) {
router.push(lastView.fullPath as string)
} else { // 集合中都没有tag view时
// 如果刚刚删除的正是Dashboard 就重定向回Dashboard(首页)
if (view.name === 'Dashboard') {
router.replace({ path: '/redirect' + view.fullPath as string })
} else {
// tag都没有了 删除的也不是Dashboard 只能跳转首页
router.push('/')
}
}
}
// 关闭当前右键的tag路由
const closeSelectedTag = (view: RouteLocationWithFullPath) => {
// 关掉并移除view
store.dispatch('tagsView/delView', view).then(() => {
// 如果移除的view是当前选中状态view, 就让删除后的集合中最后一个tag view为选中态
if (isActive(view)) {
toLastView(visitedTags.value, view)
}
})
}
// 是否是始终固定在tagsview上的tag
const isAffix = (tag: RouteLocationWithFullPath) => {
return tag.meta && tag.meta.affix
}
// 右键菜单
const handleTagCommand = (command: TagCommandType, view: RouteLocationWithFullPath) => {
switch (command) {
case TagCommandType.All: // 右键删除标签导航所有tag 除了affix为true的
handleCloseAllTag(view)
break
case TagCommandType.Other: // 关闭其他tag 除了affix为true的和当前右键的tag
handleCloseOtherTag(view)
break
case TagCommandType.Self: // 关闭当前右键的tag affix为true的tag下拉菜单中无此项
closeSelectedTag(view)
break
case TagCommandType.Refresh: // 刷新当前右键选中tag对应的路由
refreshSelectedTag(view)
}
}
// 删除所有tag 除了affix为true的
const handleCloseAllTag = (view: RouteLocationWithFullPath) => {
// 对于是affix的tag是不会被删除的
store.dispatch('tagsView/delAllView').then(() => {
// 关闭所有后 就让切换到剩下affix中最后一个tag
toLastView(visitedTags.value, view)
})
}
// 删除其他tag 除了当前右键的tag
const handleCloseOtherTag = (view: RouteLocationWithFullPath) => {
store.dispatch('tagsView/delOthersViews', view).then(() => {
if (!isActive(view)) { // 删除其他tag后 让该view路由激活
router.push(view.path)
}
})
}
// 右键刷新 清空当前对应路由缓存
const refreshSelectedTag = (view: RouteLocationWithFullPath) => {
// 刷新前 将该路由名称从缓存列表中移除
store.dispatch('tagsView/delCachedView', view).then(() => {
const { fullPath } = view
nextTick(() => {
router.replace('/redirect' + fullPath)
})
})
}
// 获取主题色
const themeColor = computed(() => store.getters.themeColor)
return {
visitedTags,
isActive,
closeSelectedTag,
isAffix,
handleTagCommand,
themeColor
}
}
})
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
overflow: hidden;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
background: #fff;
color: #495060;
padding: 0 8px;
box-sizing: border-box;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #409EFF;
color: #fff;
border-color: #409EFF;
::v-deep {
.el-dropdown {
color: #fff;
}
}
&::before {
position: relative;
display: inline-block;
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
background: #fff;
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-container {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
</style>
2-8 修改sidebar使用主题色
src/layout/components/Sidebar/index.vue
<template>
<div>
<el-menu
class="sidebar-container-menu"
mode="vertical"
:default-active="activeMenu"
:background-color="scssVariables.menuBg"
:text-color="scssVariables.menuText"
:active-text-color="themeColor"
:collapse="isCollapse"
:collapse-transition="true"
>
<sidebar-item
v-for="route in menuRoutes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { useRoute } from 'vue-router'
import variables from '@/styles/variables.scss'
import { routes } from '@/router'
import SidebarItem from './SidebarItem.vue'
import { useStore } from '@/store'
export default defineComponent({
name: 'Sidebar',
components: {
SidebarItem
},
setup() {
const route = useRoute()
const store = useStore()
// 根据路由路径 对应 当前激活的菜单
const activeMenu = computed(() => {
const { path, meta } = route
// 可根据meta.activeMenu指定 当前路由激活时 让哪个菜单高亮选中
if (meta.activeMenu) {
return meta.activeMenu
}
return path
})
// scss变量
const scssVariables = computed(() => variables)
// 展开收起状态 稍后放store 当前是展开就让它收起
const isCollapse = computed(() => !store.getters.sidebar.opened)
// 渲染路由
const menuRoutes = computed(() => routes)
// 获取主题色
const themeColor = computed(() => store.getters.themeColor)
return {
// ...toRefs(variables), // 不有toRefs原因 缺点variables里面变量属性来源不明确
scssVariables,
isCollapse,
activeMenu,
menuRoutes,
themeColor
}
}
})
</script>
2-9 element.ts
可以注释掉了,因为我们会根据store里默认theme值生成内联css
本节参考源码
这里注意 utils/useGenerateTheme.ts移到hooks目录里 文档中是正确的
https://gitee.com/brolly/vue3-element-admin/commit/1a074ecd1e6855241860a287cf1bede38006c684
移动commit
https://gitee.com/brolly/vue3-element-admin/commit/52195d3285cf0aa71919af1bbea34e22e53cc53b