preferences.ts 11 KB

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