preferences.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import type { DeepPartial } from '@vben-core/typings';
  2. import type { Preferences } from './types';
  3. import { markRaw, reactive, readonly, watch } from 'vue';
  4. import {
  5. StorageManager,
  6. generatorColorVariables,
  7. merge,
  8. updateCSSVariables,
  9. } from '@vben-core/toolkit';
  10. import {
  11. breakpointsTailwind,
  12. useBreakpoints,
  13. useDebounceFn,
  14. } from '@vueuse/core';
  15. import { defaultPreferences } from './config';
  16. import { BUILT_IN_THEME_PRESETS } from './constants';
  17. const STORAGE_KEY = 'preferences';
  18. const STORAGE_KEY_LOCALE = `${STORAGE_KEY}-locale`;
  19. const STORAGE_KEY_THEME = `${STORAGE_KEY}-theme`;
  20. interface initialOptions {
  21. namespace: string;
  22. overrides?: DeepPartial<Preferences>;
  23. }
  24. function isDarkTheme(theme: string) {
  25. let dark = theme === 'dark';
  26. if (theme === 'auto') {
  27. dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  28. }
  29. return dark;
  30. }
  31. class PreferenceManager {
  32. private cache: StorageManager | null = null;
  33. // private flattenedState: Flatten<Preferences>;
  34. private initialPreferences: Preferences = defaultPreferences;
  35. private isInitialized: boolean = false;
  36. private savePreferences: (preference: Preferences) => void;
  37. private state: Preferences = reactive<Preferences>({
  38. ...this.loadPreferences(),
  39. });
  40. constructor() {
  41. this.cache = new StorageManager();
  42. // this.flattenedState = reactive(flattenObject(this.state));
  43. this.savePreferences = useDebounceFn(
  44. (preference: Preferences) => this._savePreferences(preference),
  45. 100,
  46. );
  47. }
  48. /**
  49. * 保存偏好设置
  50. * @param {Preferences} preference - 需要保存的偏好设置
  51. */
  52. private _savePreferences(preference: Preferences) {
  53. this.cache?.setItem(STORAGE_KEY, preference);
  54. this.cache?.setItem(STORAGE_KEY_LOCALE, preference.app.locale);
  55. this.cache?.setItem(STORAGE_KEY_THEME, preference.theme.mode);
  56. }
  57. /**
  58. * 处理更新的键值
  59. * 根据更新的键值执行相应的操作。
  60. *
  61. * @param {DeepPartial<Preferences>} updates - 部分更新的偏好设置
  62. */
  63. private handleUpdates(updates: DeepPartial<Preferences>) {
  64. const themeUpdates = updates.theme || {};
  65. const appUpdates = updates.app || {};
  66. if (themeUpdates && Object.keys(themeUpdates).length > 0) {
  67. this.updateTheme(this.state);
  68. }
  69. if (
  70. Reflect.has(appUpdates, 'colorGrayMode') ||
  71. Reflect.has(appUpdates, 'colorWeakMode')
  72. ) {
  73. this.updateColorMode(this.state);
  74. }
  75. }
  76. /**
  77. * 从缓存中加载偏好设置。如果缓存中没有找到对应的偏好设置,则返回默认偏好设置。
  78. */
  79. private loadCachedPreferences() {
  80. return this.cache?.getItem<Preferences>(STORAGE_KEY);
  81. }
  82. /**
  83. * 加载偏好设置
  84. * @returns {Preferences} 加载的偏好设置
  85. */
  86. private loadPreferences(): Preferences {
  87. return this.loadCachedPreferences() || { ...defaultPreferences };
  88. }
  89. /**
  90. * 监听状态和系统偏好设置的变化。
  91. */
  92. private setupWatcher() {
  93. if (this.isInitialized) {
  94. return;
  95. }
  96. // 监听断点,判断是否移动端
  97. const breakpoints = useBreakpoints(breakpointsTailwind);
  98. const isMobile = breakpoints.smaller('md');
  99. watch(
  100. () => isMobile.value,
  101. (val) => {
  102. this.updatePreferences({
  103. app: { isMobile: val },
  104. });
  105. },
  106. { immediate: true },
  107. );
  108. // 监听系统主题偏好设置变化
  109. window
  110. .matchMedia('(prefers-color-scheme: dark)')
  111. .addEventListener('change', ({ matches: isDark }) => {
  112. this.updatePreferences({
  113. theme: { mode: isDark ? 'dark' : 'light' },
  114. });
  115. this.updateTheme(this.state);
  116. });
  117. }
  118. /**
  119. * 更新页面颜色模式(灰色、色弱)
  120. * @param preference
  121. */
  122. private updateColorMode(preference: Preferences) {
  123. if (preference.app) {
  124. const { colorGrayMode, colorWeakMode } = preference.app;
  125. const dom = document.documentElement;
  126. const COLOR_WEAK = 'invert-mode';
  127. const COLOR_GRAY = 'grayscale-mode';
  128. colorWeakMode
  129. ? dom.classList.add(COLOR_WEAK)
  130. : dom.classList.remove(COLOR_WEAK);
  131. colorGrayMode
  132. ? dom.classList.add(COLOR_GRAY)
  133. : dom.classList.remove(COLOR_GRAY);
  134. }
  135. }
  136. /**
  137. * 更新 CSS 变量
  138. * @param preference - 当前偏好设置对象,它的颜色值将被转换成 HSL 格式并设置为 CSS 变量。
  139. */
  140. private updateMainColors(preference: Preferences) {
  141. if (!preference.theme) {
  142. return;
  143. }
  144. const { colorDestructive, colorPrimary, colorSuccess, colorWarning } =
  145. preference.theme;
  146. const colorVariables = generatorColorVariables([
  147. { color: colorPrimary, name: 'primary' },
  148. { alias: 'warning', color: colorWarning, name: 'yellow' },
  149. { alias: 'success', color: colorSuccess, name: 'green' },
  150. { alias: 'destructive', color: colorDestructive, name: 'red' },
  151. ]);
  152. if (colorPrimary) {
  153. document.documentElement.style.setProperty(
  154. '--primary',
  155. colorVariables['--primary-600'],
  156. );
  157. }
  158. if (colorVariables['--green-600']) {
  159. colorVariables['--success'] = colorVariables['--green-600'];
  160. }
  161. if (colorVariables['--yellow-600']) {
  162. colorVariables['--warning'] = colorVariables['--yellow-600'];
  163. }
  164. if (colorVariables['--red-600']) {
  165. colorVariables['--destructive'] = colorVariables['--red-600'];
  166. }
  167. updateCSSVariables(colorVariables);
  168. }
  169. /**
  170. * 更新主题
  171. * @param preferences - 当前偏好设置对象,它的主题值将被用来设置文档的主题。
  172. */
  173. private updateTheme(preferences: Preferences) {
  174. // 当修改到颜色变量时,更新 css 变量
  175. const root = document.documentElement;
  176. if (!root) {
  177. return;
  178. }
  179. const theme = preferences?.theme ?? {};
  180. const { builtinType, colorPrimary, mode, radius } = theme;
  181. if (Reflect.has(theme, 'mode')) {
  182. const dark = isDarkTheme(mode);
  183. root.classList.toggle('dark', dark);
  184. }
  185. if (Reflect.has(theme, 'builtinType')) {
  186. const rootTheme = root.dataset.theme;
  187. if (rootTheme !== builtinType) {
  188. root.dataset.theme = builtinType;
  189. }
  190. }
  191. const currentBuiltType = BUILT_IN_THEME_PRESETS.find(
  192. (item) => item.type === builtinType,
  193. );
  194. let builtinTypeColorPrimary: string | undefined = '';
  195. if (currentBuiltType) {
  196. const isDark = isDarkTheme(this.state.theme.mode);
  197. const color = isDark
  198. ? currentBuiltType.darkPrimaryColor || currentBuiltType.primaryColor
  199. : currentBuiltType.primaryColor;
  200. builtinTypeColorPrimary = color || currentBuiltType.color;
  201. }
  202. if (
  203. builtinTypeColorPrimary ||
  204. Reflect.has(theme, 'colorPrimary') ||
  205. Reflect.has(theme, 'colorDestructive') ||
  206. Reflect.has(theme, 'colorSuccess') ||
  207. Reflect.has(theme, 'colorWarning')
  208. ) {
  209. preferences.theme.colorPrimary = builtinTypeColorPrimary || colorPrimary;
  210. this.updateMainColors(preferences);
  211. }
  212. if (Reflect.has(theme, 'radius')) {
  213. document.documentElement.style.setProperty('--radius', `${radius}rem`);
  214. }
  215. }
  216. clearCache() {
  217. [STORAGE_KEY, STORAGE_KEY_LOCALE, STORAGE_KEY_THEME].forEach((key) => {
  218. this.cache?.removeItem(key);
  219. });
  220. }
  221. public getInitialPreferences() {
  222. return this.initialPreferences;
  223. }
  224. public getPreferences() {
  225. return readonly(this.state);
  226. }
  227. /**
  228. * 覆盖偏好设置
  229. * overrides 要覆盖的偏好设置
  230. * namespace 命名空间
  231. */
  232. public async initPreferences({ namespace, overrides }: initialOptions) {
  233. // 是否初始化过
  234. if (this.isInitialized) {
  235. return;
  236. }
  237. // 初始化存储管理器
  238. this.cache = new StorageManager({ prefix: namespace });
  239. // 合并初始偏好设置
  240. this.initialPreferences = merge({}, overrides, defaultPreferences);
  241. // 加载并合并当前存储的偏好设置
  242. const mergedPreference = merge(
  243. {},
  244. overrides,
  245. this.loadCachedPreferences() || defaultPreferences,
  246. );
  247. // 更新偏好设置
  248. this.updatePreferences(mergedPreference);
  249. this.setupWatcher();
  250. // 标记为已初始化
  251. this.isInitialized = true;
  252. }
  253. /**
  254. * 重置偏好设置
  255. * 偏好设置将被重置为初始值,并从 localStorage 中移除。
  256. *
  257. * @example
  258. * 假设 initialPreferences 为 { theme: 'light', language: 'en' }
  259. * 当前 state 为 { theme: 'dark', language: 'fr' }
  260. * this.resetPreferences();
  261. * 调用后,state 将被重置为 { theme: 'light', language: 'en' }
  262. * 并且 localStorage 中的对应项将被移除
  263. */
  264. resetPreferences() {
  265. // 将状态重置为初始偏好设置
  266. Object.assign(this.state, this.initialPreferences);
  267. // 保存重置后的偏好设置
  268. this.savePreferences(this.state);
  269. // 从存储中移除偏好设置项
  270. [STORAGE_KEY, STORAGE_KEY_THEME, STORAGE_KEY_LOCALE].forEach((key) => {
  271. this.cache?.removeItem(key);
  272. });
  273. this.updatePreferences(this.state);
  274. }
  275. /**
  276. * 更新偏好设置
  277. * @param updates - 要更新的偏好设置
  278. */
  279. public updatePreferences(updates: DeepPartial<Preferences>) {
  280. const mergedState = merge({}, updates, markRaw(this.state));
  281. Object.assign(this.state, mergedState);
  282. // 根据更新的键值执行相应的操作
  283. this.handleUpdates(updates);
  284. this.savePreferences(this.state);
  285. }
  286. }
  287. const preferencesManager = new PreferenceManager();
  288. export { PreferenceManager, isDarkTheme, preferencesManager };