preferences.ts 8.6 KB

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