Skip to main content

多主题开发

一、概述

ThingsPanel系统主题的实现分为两个部分,一部分是组件库的主题配置,另一部分是 UnoCSS 的主题配置。为了统一两个部分的主题配置,在这之上维护了一些主题配置,通过这些主题配置分别控制组件库和 UnoCSS 的主题配置。

二、原理

  1. 定义一些主题配置的变量,包括各种主题颜色,布局的参数配置等
  2. 通过这些配置产出符合组件库的主题变量
  3. 通过这些配置产出一些主题 tokens 并衍生出对应的 css 变量,再将这些 css 变量传递给 UnoCSS

三、主题配置

1. 类型定义

主题配置的类型定义见 App.Theme.ThemeSetting

代码位置src/typings/app.d.ts

2. 初始化配置

export const themeSettings: App.Theme.ThemeSetting = {
//默认配置
};

代码位置src/theme/settings.ts

3. 配置覆盖更新

当发布新的版本时,可以通过配置覆盖更新的方式,来更新主题配置

export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {
//覆盖配置
};

代码位置src/theme/settings.ts

4. 环境说明

开发环境

  • 当项目处于开发模式时,主题配置不会被缓存
  • 可以通过更新 src/theme/settings.ts 中的 themeSettings 来更新主题配置
  • 开发阶段为了能够实时看到主题配置的变化,所以不会缓存主题配置

生产环境

  • 当项目处于生产模式时,主题配置会被缓存到 localStorage 中
  • 每次发布新版本,可以通过更新 src/theme/settings.ts 中的 overrideThemeSettings 来覆盖更新主题配置

四、主题 Tokens

1. 类型定义

type ThemeToken = {
colors: ThemeTokenColor;
boxShadow: {
header: string;
sider: string;
tab: string;
};
};

代码位置src/typings/app.d.ts

2. 基于 Tokens 的 CSS 变量

初始化时会在 html 上生成一些 css 变量,这些 css 变量是基于主题 tokens 产出的

/** Theme vars */
export const themeVars: App.Theme.ThemeToken = {
colors: {
...colorPaletteVars,
nprogress: 'rgb(var(--nprogress-color))',
container: 'rgb(var(--container-bg-color))',
layout: 'rgb(var(--layout-bg-color))',
inverted: 'rgb(var(--inverted-bg-color))',
base_text: 'rgb(var(--base-text-color))'
},
boxShadow: {
header: 'var(--header-box-shadow)',
sider: 'var(--sider-box-shadow)',
tab: 'var(--tab-box-shadow)'
}
};

代码位置src/theme/vars.ts

3. Tokens 初始化

/**
* Create theme token
*
* @param colors Theme colors
*/
export function createThemeToken(colors: App.Theme.ThemeColor) {
const paletteColors = createThemePaletteColors(colors);

const themeTokens: App.Theme.ThemeToken = {
colors: {
...paletteColors,
nprogress: paletteColors.primary,
container: 'rgb(255, 255, 255)',
layout: 'rgb(247, 250, 252)',
inverted: 'rgb(0, 20, 40)',
base_text: 'rgb(31, 31, 31)'
},
boxShadow: {
header: '0 1px 2px rgb(0, 21, 41, 0.08)',
sider: '2px 0 8px 0 rgb(29, 35, 41, 0.05)',
tab: '0 1px 2px rgb(0, 21, 41, 0.08)'
}
};

const darkThemeTokens: App.Theme.ThemeToken = {
colors: {
...themeTokens.colors,
container: 'rgb(28, 28, 28)',
layout: 'rgb(18, 18, 18)',
base_text: 'rgb(224, 224, 224)'
},
boxShadow: {
...themeTokens.boxShadow
}
};

return {
themeTokens,
darkThemeTokens
};
}

代码位置src/store/modules/theme/shared.ts

五、UnoCSS 主题

1. 主题配置

通过 Theme Tokens 注入到 UnoCSS 的主题配置中,借助于 UnoCSS 的能力,可以使用类似 text-primary bg-primary 等 class 名称进而统一了组件库和 UnoCSS 的主题颜色的应用。

import { themeVars } from './src/theme/vars';

export default defineConfig<Theme>({
theme: {
...themeVars
}
});

代码位置./uno.config.ts

2. 暗黑模式

通过 UnoCSS 提供的预设暗黑模式方案,只要在 html 上添加 class="dark",则项目中类似于 dark:text-#000 dark:bg-#333 的 class 就会生效,从而达到暗黑模式的效果。

export default defineConfig<Theme>({
presets: [presetUno({ dark: 'class' })]
});

代码位置./uno.config.ts

六、组件库主题

1. NaiveUI 主题配置

主题变量生成

根据主题颜色产出组件库的主题变量:

/**
* Get naive theme
*
* @param colors Theme colors
*/
function getNaiveTheme(colors: App.Theme.ThemeColor) {
const { primary: colorLoading } = colors;

const theme: GlobalThemeOverrides = {
common: {
...getNaiveThemeColors(colors)
},
LoadingBar: {
colorLoading
}
};

return theme;
}

/** Naive theme */
const naiveTheme = computed(() => getNaiveTheme(themeColors.value));

代码位置

  • src/store/modules/theme/shared.ts
  • src/store/modules/theme/index.ts

应用主题变量

<template>
<NConfigProvider
:theme="naiveDarkTheme"
:theme-overrides="themeStore.naiveTheme"
:locale="naiveLocale"
:date-locale="naiveDateLocale"
class="h-full"
>
<AppProvider>
<RouterView class="bg-layout" />
</AppProvider>
</NConfigProvider>
</template>

代码位置src/App.vue

2. AntDesignVue 主题配置

主题变量生成

根据主题颜色产出组件库的主题变量:

/**
* Get antd theme
*
* @param colors Theme colors
* @param darkMode Is dark mode
*/
function getAntdTheme(colors: App.Theme.ThemeColor, darkMode: boolean) {
const { defaultAlgorithm, darkAlgorithm } = antdTheme;

const { primary, info, success, warning, error } = colors;

const theme: ConfigProviderProps['theme'] = {
token: {
colorPrimary: primary,
colorInfo: info,
colorSuccess: success,
colorWarning: warning,
colorError: error
},
algorithm: [darkMode ? darkAlgorithm : defaultAlgorithm],
components: {
Menu: {
colorSubItemBg: 'transparent'
}
}
};

return theme;
}

/** Antd theme */
const antdTheme = computed(() => getAntdTheme(themeColors.value, darkMode.value));

代码位置

  • src/store/modules/theme/shared.ts
  • src/store/modules/theme/index.ts

应用主题变量

<template>
<ConfigProvider :theme="themeStore.antdTheme" :locale="antdLocale">
<AppProvider>
<RouterView class="bg-layout" />
</AppProvider>
</ConfigProvider>
</template>

七、系统 Logo 与加载样式

系统 Logo 由组件 SystemLogo 来实现,它是一个 SFC 组件,可以通过 props 来设置它的样式。

<script lang="ts" setup>
defineOptions({ name: 'SystemLogo' });
</script>

<template>
<icon-local-logo />
</template>

<style scoped></style>

代码位置src/components/common/system-logo.vue

具体实现原理参考本地 Icon。

2. 系统加载样式

系统初始化时的加载样式通过 html 代码方式实现。

组件位置

src/plugins/loading.ts

渲染原理

创建 setupLoading 函数,它的主要功能是设置页面加载时的动画效果。这个加载动画包括:

  • 系统 Logo
  • 旋转的点阵动画
  • 标题文字

所有元素的颜色均基于从本地存储获取的主题色 themeColor 动态生成。并且在 DOM 中查找 ID 为 app 的元素作为加载动画的挂载点,如果找到了这个元素,则将其内部 HTML 替换为刚刚构建的加载动画 HTML 结构。

export function setupLoading() {
const themeColor = localStg.get('themeColor') || '#DB5A6B';

const { r, g, b } = getRgbOfColor(themeColor);

const primaryColor = `--primary-color: ${r} ${g} ${b}`;

const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];

const logoWithClass = systemLogo.replace('<svg', `<svg class="size-128px text-primary"`);

const dot = loadingClasses
.map(item => {
return `<div class="absolute w-16px h-16px bg-primary rounded-8px animate-pulse ${item}"></div>`;
})
.join('\n');

const loading = `
<div class="fixed-center flex-col" style="${primaryColor}">
${logoWithClass}
<div class="w-56px h-56px my-36px">
<div class="relative h-full animate-spin">
${dot}
</div>
</div>
<h2 class="text-28px font-500 text-#646464">${$t('system.title')}</h2>
</div>`;

const app = document.getElementById('app');

if (app) {
app.innerHTML = loading;
}
}

代码位置src/plugins/loading.ts

最后要将 setupLoading 函数注册到 main.ts 中:

async function setupApp() {
setupLoading();
app.mount('#app');
}