// libs/util/config-provider.ts // 全局配置类:管理 Theme 的初始化、切换、持久化 import { ref } from 'vue'; import type { DarkMode, Theme, ThemeColor } from '../../types/global'; import config from '../config/config'; import { defaultThemes } from '../config/theme-tokens'; import { color as reactiveColor } from '../config/color'; import { getSystemDarkMode as getNativeSystemDarkMode } from './system-theme'; import * as localePack from '../../locale'; declare const uni: any; const THEME_STORAGE_KEY = 'uview-pro-theme'; const DARK_MODE_STORAGE_KEY = 'uview-pro-dark-mode'; const LOCALE_STORAGE_KEY = 'uview-pro-locale'; const DEFAULT_LIGHT_TOKENS = (defaultThemes[0]?.color || {}) as Partial; const DEFAULT_DARK_TOKENS = (defaultThemes[0]?.darkColor || {}) as Partial; const STRUCTURAL_TOKENS = new Set([ 'bgColor', 'bgWhite', 'bgGrayLight', 'bgGrayDark', 'bgBlack', 'borderColor', 'lightColor', 'mainColor', 'contentColor', 'tipsColor', 'whiteColor', 'blackColor', 'dividerColor', 'maskColor', 'shadowColor' ]); export type DefaultThemeConfig = { /** * 默认主题 */ defaultTheme?: string; /** * 默认暗黑模式 */ defaultDarkMode?: DarkMode; }; /** * ConfigProvider: 管理全局主题 * - init(themes, defaultName): 初始化主题系统 * - setTheme(name): 切换主题并持久化 * - getThemes/getCurrentTheme: 读取当前数据 * - setDarkMode/getDarkMode: 管理暗黑模式 */ export class ConfigProvider { // 响应式状态,供外部直接引用 public themesRef = ref([]); public currentThemeRef = ref(null); public darkModeRef = ref(config.defaultDarkMode); public cssVarsRef = ref>({}); // 国际化 i18n 状态 public localesRef = ref([]); public currentLocaleRef = ref(null); private baseColorTokens: Partial = DEFAULT_LIGHT_TOKENS; private baseDarkColorTokens: Partial = DEFAULT_DARK_TOKENS; private debug: boolean = false; private systemDarkModeMediaQuery: MediaQueryList | null = null; private lastAppliedCssKeys: string[] = []; private interval: ReturnType | number = 0; constructor() { // 默认不自动初始化,调用 init 以传入主题列表 this.initSystemDarkModeListener(); } /** * 初始化系统暗黑模式监听器 * 支持 H5、App、小程序等平台 */ private initSystemDarkModeListener() { // H5 平台:使用 matchMedia API try { if (typeof window !== 'undefined' && window.matchMedia) { this.systemDarkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const listener = () => { if (this.darkModeRef.value === 'auto') { this.applyTheme(this.currentThemeRef.value); } }; if (this.systemDarkModeMediaQuery.addEventListener) { this.systemDarkModeMediaQuery.addEventListener('change', listener); } else if (this.systemDarkModeMediaQuery.addListener) { this.systemDarkModeMediaQuery.addListener(listener); } } } catch (e) { if (this.debug) console.warn('[ConfigProvider] H5 system dark mode listener failed', e); } // uni-app 平台:使用 uni.onThemeChange API try { if (typeof uni !== 'undefined' && typeof uni.onThemeChange === 'function') { uni.onThemeChange((res: { theme: string }) => { console.log('[ConfigProvider] system theme changed', res); if (this.darkModeRef.value === 'auto') { // 系统主题变化时,重新应用主题 this.applyTheme(this.currentThemeRef.value); } }); } } catch (e) { if (this.debug) console.warn('[ConfigProvider] uni-app system dark mode listener failed', e); } this.initAppEvent(); } /** * App 平台事件监听 * 经测试 uni.onThemeChange 在 App 平台目前没生效,暂时只能通过定时检查 */ private initAppEvent(): void { // #ifdef APP try { if (this.interval) clearInterval(this.interval); this.interval = setInterval(() => { if (this.darkModeRef.value === 'auto') { // 系统主题变化时,重新应用主题 this.applyTheme(this.currentThemeRef.value); } }, 5000); } catch (e) { if (this.debug) console.warn('[ConfigProvider] setInterval failed', e); } // #endif } /** * 检测当前是否应该使用暗黑模式 */ private isSystemDarkMode(): boolean { try { if (this.systemDarkModeMediaQuery) { return this.systemDarkModeMediaQuery.matches; } } catch (e) { if (this.debug) console.warn('[ConfigProvider] matchMedia check failed', e); } try { return getNativeSystemDarkMode() === 'dark'; } catch (e) { if (this.debug) console.warn('[ConfigProvider] native system theme check failed', e); return false; } } /** * 初始化主题系统 * @param themes 可用主题数组 * @param defaultTheme 可选默认主题名 */ initTheme(themes?: Theme[], defaultConfig?: string | DefaultThemeConfig, isForce?: boolean) { const normalizedThemes = this.normalizeThemes(themes); if (!normalizedThemes.length) { console.warn('[ConfigProvider] init called with empty themes'); return; } // 配置默认主题 if (defaultConfig) { if (typeof defaultConfig === 'string') { config.defaultTheme = defaultConfig || config.defaultTheme; } else if (typeof defaultConfig === 'object') { const { defaultTheme, defaultDarkMode } = defaultConfig; config.defaultTheme = defaultTheme || config.defaultTheme; config.defaultDarkMode = defaultDarkMode || config.defaultDarkMode; } } // 设置主题列表,响应式 this.themesRef.value = normalizedThemes.slice(); // 先尝试从 Storage 读取已保存主题名 const saved = this.readStorage(THEME_STORAGE_KEY); let initialName = saved || config.defaultTheme || this.themesRef.value[0].name; if (isForce && config.defaultTheme) initialName = config.defaultTheme; let found = this.themesRef.value.find(t => t.name === initialName); if (!found) found = this.themesRef.value.find(t => t.name === config.defaultTheme); if (!found) found = this.themesRef.value[0]; // 设置当前主题,响应式 this.currentThemeRef.value = found; // 初始化暗黑模式设置 this.initDarkMode(config.defaultDarkMode, isForce); // 应用主题 this.applyTheme(found); if (this.debug) console.log('[ConfigProvider] initialized, theme=', found.name, 'darkMode=', this.darkModeRef.value); return this; } /** * 初始化暗黑模式设置 * @param darkMode */ initDarkMode(darkMode?: DarkMode, isForce?: boolean) { // 尝试从 Storage 读取暗黑模式设置 const savedDarkMode = this.readStorage(DARK_MODE_STORAGE_KEY); let darkModeValue = savedDarkMode || darkMode || config.defaultDarkMode; if (isForce && darkMode) darkModeValue = darkMode; this.darkModeRef.value = darkModeValue; } /** * 初始化国际化数据 * @param locales 可选的 locale 列表(对象数组,包含 name 字段) * @param defaultLocaleName 可选默认 locale 名称 */ initLocales(locales?: any[], defaultLocaleName?: string, isForce?: boolean) { const normalized = this.normalizeLocales(locales); if (!normalized.length) { if (this.debug) console.warn('[ConfigProvider] initLocales called with empty locales'); return; } this.localesRef.value = normalized.slice(); // 尝试从 Storage 读取已保存 locale 名称 const saved = this.readStorage(LOCALE_STORAGE_KEY); // 根据传入的 defaultLocaleName 或 saved 或 config.defaultLocale 查找 locale let initialName = saved || defaultLocaleName || config.defaultLocale; if (isForce && defaultLocaleName) initialName = defaultLocaleName; let found = this.localesRef.value.find((l: any) => l.name === initialName); if (!found) found = this.localesRef.value.find(l => l.name === config.defaultLocale); if (!found) found = this.localesRef.value[0]; this.currentLocaleRef.value = found; if (this.debug) console.log('[ConfigProvider] locales initialized, locale=', found?.name); return this; } /** * 归一化 locale 配置,保证始终至少有一个默认 locale */ private normalizeLocales(locales?: any[]) { // 获取内置语言包(可能包含 zh-CN / en-US 等) let builtinList: any[] = []; try { builtinList = Object.values(localePack || {}).filter(v => v && typeof v === 'object'); } catch (e) { if (this.debug) console.warn('[ConfigProvider] normalizeLocales read builtin failed', e); } // 如果没有传入自定义 locales,直接返回内置列表 if (!Array.isArray(locales) || !locales.length) { return builtinList.slice(); } // 将 builtin 按 name 映射,便于合并 const map = new Map(); builtinList.forEach(item => { if (item && item.name) { map.set(item.name, { ...(item || {}) }); } }); // 合并用户传入的 locales:若 name 相同则对对象字段进行浅合并(嵌套对象尝试合并) locales.forEach(loc => { if (!loc || !loc.name) return; const existing = map.get(loc.name); if (!existing) { // 新增语言直接加入 map.set(loc.name, { ...(loc || {}) }); return; } // 合并:对每个 key,如果是对象且现有为对象,则进行浅合并,否则覆盖 const merged: any = { ...existing }; Object.keys(loc).forEach(k => { const v = (loc as any)[k]; if (v != null && typeof v === 'object' && !Array.isArray(v) && typeof merged[k] === 'object') { merged[k] = { ...(merged[k] || {}), ...(v || {}) }; } else { merged[k] = v; } }); map.set(loc.name, merged); }); return Array.from(map.values()); } /** * 获取所有可用 locale */ getLocales() { return this.localesRef.value.slice(); } /** * 获取当前 locale 对象 */ getCurrentLocale() { return this.currentLocaleRef.value; } /** * 切换 locale 并持久化 */ setLocale(localeName: string) { if (!this.localesRef.value || this.localesRef.value.length === 0) { console.warn('[ConfigProvider] setLocale called but locales list empty'); return; } const locale = this.localesRef.value.find(l => l.name === localeName); if (!locale) { console.warn('[ConfigProvider] locale not found:', localeName); return; } this.currentLocaleRef.value = locale; this.writeStorage(LOCALE_STORAGE_KEY, localeName); if (this.debug) console.log('[ConfigProvider] setLocale ->', localeName); } /** * 翻译函数 * 支持 key 路径,例如 'calendar.placeholder' * replacements 支持数组或对象替换占位符 {0} 或 {name} */ t(key: string, replacements?: any, localeName?: string): string { // 如果 locales 尚未初始化,尝试懒初始化,使用内置语言包作为回退 try { if (!Array.isArray(this.localesRef.value) || this.localesRef.value.length === 0) { this.initLocales(); } } catch (e) { if (this.debug) console.warn('[ConfigProvider] lazy initLocales failed', e); } const localeObj = (localeName && this.localesRef.value.find(l => l.name === localeName)) || this.currentLocaleRef.value; if (!localeObj) return key; const parts = key.split('.'); let cur: any = localeObj; for (let i = 0; i < parts.length; i++) { if (cur == null) break; cur = cur[parts[i]]; } let text = typeof cur === 'string' ? cur : key; if (replacements != null) { if (Array.isArray(replacements)) { replacements.forEach((val, idx) => { text = text.split(`{${idx}}`).join(String(val)); }); } else if (typeof replacements === 'object') { Object.keys(replacements).forEach(k => { text = text.split(`{${k}}`).join(String(replacements[k])); }); } } return text; } /** * 获取所有可用主题 */ getThemes(): Theme[] { return this.themesRef.value.slice(); } /** * 获取当前主题 */ getCurrentTheme(): Theme | null { return this.currentThemeRef.value; } /** * 切换主题并持久化 */ setTheme(themeName: string) { if (!this.themesRef.value || this.themesRef.value.length === 0) { console.warn('[ConfigProvider] setTheme called but themes list empty'); return; } const theme = this.themesRef.value.find(t => t.name === themeName); if (!theme) { console.warn('[ConfigProvider] theme not found:', themeName); return; } this.currentThemeRef.value = theme; // 应用 this.applyTheme(theme); // 持久化 this.writeStorage(THEME_STORAGE_KEY, themeName); if (this.debug) console.log('[ConfigProvider] setTheme ->', themeName); } /** * 运行时更新当前主题颜色并应用(不持久化) * @param colors 主题颜色键值,支持部分更新 */ public setThemeColor(colors: Partial) { if (!colors || Object.keys(colors).length === 0) return; if (!this.currentThemeRef.value) { console.warn('[ConfigProvider] setThemeColor called but no current theme'); return; } const mode = this.getActiveMode(); if (mode === 'dark') { const existing = this.currentThemeRef.value.darkColor || {}; this.currentThemeRef.value.darkColor = { ...existing, ...colors }; } else { const existing = this.currentThemeRef.value.color || {}; this.currentThemeRef.value.color = { ...existing, ...colors }; } // 重新应用当前主题以同步运行时 color、CSS 变量等 this.applyTheme(this.currentThemeRef.value); if (this.debug) console.log('[ConfigProvider] setThemeColor ->', colors); } /** * 获取当前暗黑模式设置 */ getDarkMode(): DarkMode { return this.darkModeRef.value; } /** * 设置暗黑模式 * @param mode 'auto' (跟随系统) | 'light' (强制亮色) | 'dark' (强制暗黑) */ setDarkMode(mode: DarkMode) { this.darkModeRef.value = mode; // 持久化 this.writeStorage(DARK_MODE_STORAGE_KEY, mode); // 重新应用主题 this.applyTheme(this.currentThemeRef.value); if (this.debug) console.log('[ConfigProvider] setDarkMode ->', mode); } /** * 检查当前是否处于暗黑模式 */ isInDarkMode(): boolean { const mode = this.darkModeRef.value; if (mode === 'dark') return true; if (mode === 'light') return false; // auto 模式下检查系统设置 return this.isSystemDarkMode(); } /** * 归一化主题配置,保证始终至少有一个默认主题 */ private normalizeThemes(themes?: Theme[]): Theme[] { if (Array.isArray(themes) && themes.length) { return this.mergeThemes(defaultThemes, themes); } return defaultThemes.slice(); } private mergeThemes(...lists: Array): Theme[] { const map = new Map(); lists .filter((list): list is Theme[] => Array.isArray(list) && list.length > 0) .forEach(list => { list.forEach(theme => { const normalized = this.ensureDarkVariant({ ...theme, color: this.applyColorFallbacks(theme.color), darkColor: theme.darkColor ? { ...theme.darkColor } : undefined, css: theme.css ? { ...theme.css } : undefined, darkCss: theme.darkCss ? { ...theme.darkCss } : undefined }); map.set(normalized.name, normalized); }); }); return Array.from(map.values()); } private ensureDarkVariant(theme: Theme): Theme { const finalDark = this.buildDarkPalette(theme); return { ...theme, darkColor: this.applyDarkFallbacks(finalDark) }; } private buildDarkPalette(theme: Theme): Partial { const provided = theme.darkColor || {}; const generated = this.generateDarkFromLight(theme.color || {}, provided); return { ...generated, ...provided }; } /** * 应用亮色主题 */ private applyColorFallbacks(color?: Partial): Partial { return { ...(this.baseColorTokens || {}), ...(color || {}) }; } /** * 应用暗黑主题 */ private applyDarkFallbacks(color?: Partial): Partial { return { ...(this.baseDarkColorTokens || {}), ...(color || {}) }; } private filterNonStructuralTokens(palette: Partial): Partial { const result: Partial = {}; Object.entries(palette || {}).forEach(([key, value]) => { if (!this.isStructuralToken(key)) { (result as any)[key] = value; } }); return result; } private generateDarkFromLight(palette: Partial, provided: Partial): Partial { const result: Partial = {}; const nonStructural = this.filterNonStructuralTokens(palette); Object.entries(nonStructural).forEach(([key, value]) => { if (typeof value !== 'string') return; if (provided && Object.prototype.hasOwnProperty.call(provided, key)) { return; } const fallback = (this.baseDarkColorTokens as any)?.[key]; (result as any)[key] = this.createDarkVariantFromLight(value, fallback); }); return result; } private createDarkVariantFromLight(color: string, fallback?: string): string { const normalized = this.normalizeHex(color); const fallbackHex = fallback ? this.normalizeHex(fallback) : null; if (normalized && fallbackHex) { return this.mixHex(normalized, fallbackHex, 0.6); } if (fallbackHex) return fallbackHex; return normalized || color; } private normalizeHex(color: string): string | null { if (!color) return null; const hex = color.trim(); if (/^#([0-9a-fA-F]{6})$/.test(hex)) return hex.toLowerCase(); return null; } private mixHex(fromHex: string, toHex: string, ratio: number): string { const from = this.hexToRgb(fromHex); const to = this.hexToRgb(toHex); if (!from || !to) return toHex; const clamp = (val: number) => Math.min(255, Math.max(0, Math.round(val))); const r = clamp(from.r * (1 - ratio) + to.r * ratio); const g = clamp(from.g * (1 - ratio) + to.g * ratio); const b = clamp(from.b * (1 - ratio) + to.b * ratio); return this.rgbToHex(r, g, b); } private hexToRgb(hex: string): { r: number; g: number; b: number } | null { const match = /^#([0-9a-fA-F]{6})$/.exec(hex); if (!match) return null; return { r: parseInt(match[1].slice(0, 2), 16), g: parseInt(match[1].slice(2, 4), 16), b: parseInt(match[1].slice(4, 6), 16) }; } private rgbToHex(r: number, g: number, b: number): string { const toHex = (val: number) => val.toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } private isStructuralToken(token: string): boolean { return STRUCTURAL_TOKENS.has(token); } /** * 运行时同步主题颜色($u.color) * 更新响应式 color 对象,确保所有使用 $u.color 的地方都能响应式更新 */ private syncRuntimeTheme(palette: Partial) { try { // 合并默认值,确保所有颜色都有值 const defaultPalette = this.getActiveMode() === 'dark' ? this.baseDarkColorTokens : this.baseColorTokens; const mergedPalette = { ...defaultPalette, ...palette }; // 更新响应式 color 对象 Object.keys(mergedPalette).forEach(key => { const value = (mergedPalette as any)[key]; if (value != null) { (reactiveColor as any)[key] = value; } }); // 同步到 uni.$u.color(如果存在) if (typeof uni !== 'undefined' && uni?.$u?.color) { uni.$u.color = reactiveColor; } } catch (e) { if (this.debug) console.warn('[ConfigProvider] sync runtime theme failed', e); } } /** * 获取当前激活的模式 */ private getActiveMode(): 'light' | 'dark' { return this.isInDarkMode() ? 'dark' : 'light'; } /** * 获取当前主题的配色方案 */ private getPaletteForMode(theme: Theme, mode: 'light' | 'dark') { if (mode === 'dark') { return theme.darkColor && Object.keys(theme.darkColor).length ? theme.darkColor : theme.color || {}; } return theme.color || {}; } /** * 获取当前主题的CSS变量覆盖 */ private getCssOverrides(theme: Theme, mode: 'light' | 'dark') { if (mode === 'dark') { return (theme.darkCss && Object.keys(theme.darkCss).length ? theme.darkCss : theme.css) || {}; } return theme.css || {}; } /** * 读取Storage key */ private readStorage(key: string): T | null { try { if (typeof uni === 'undefined' || typeof uni.getStorageSync !== 'function') return null; const value = uni.getStorageSync(key); return (value ?? null) as T | null; } catch (e) { if (this.debug) console.warn('[ConfigProvider] failed to read storage', e); return null; } } /** * 写入Storage key value */ private writeStorage(key: string, value: any) { try { if (typeof uni === 'undefined' || typeof uni.setStorageSync !== 'function') return; uni.setStorageSync(key, value); } catch (e) { if (this.debug) console.warn('[ConfigProvider] failed to write storage', e); } } /** * 更新文档主题模式 H5 */ private updateDocumentMode(mode: 'light' | 'dark') { if (typeof document === 'undefined' || !document.documentElement) return; const root = document.documentElement; root.dataset.uThemeMode = mode; root.classList.remove('u-theme-light', 'u-theme-dark'); root.classList.add(`u-theme-${mode}`); } /** * 转换为 CSS 变量名称 */ private toCssVarName(key: string, prefix: string = '--u'): string { const types = config.type; if (types.some(type => key.startsWith(type))) { prefix += '-type'; } const kebab = key .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/[\s_]+/g, '-') .toLowerCase(); return `${prefix}-${kebab}`; } /** * 添加 RGB 值 */ private attachRgbVar(target: Record, varName: string, value: string) { if (typeof value !== 'string') return; const hex = value.startsWith('#') ? value.slice(1) : ''; if (!/^[0-9a-fA-F]{6}$/.test(hex)) return; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); target[`${varName}-rgb`] = `${r}, ${g}, ${b}`; } /** * 构建 CSS 变量映射表 * 生成格式: */ private buildCssVarMap(theme: Theme, mode: 'light' | 'dark'): Record { const map: Record = { '--u-theme-mode': mode }; const palette = this.getPaletteForMode(theme, mode); const cssOverrides = this.getCssOverrides(theme, mode); const applyEntry = (key: string, value: string | number | undefined | null) => { if (value == null) return; const strValue = String(value); if (key.startsWith('--')) { map[key] = strValue; this.attachRgbVar(map, key, strValue); return; } const cssVarName = this.toCssVarName(key); // const typeVarName = this.toCssVarName(key, '--u-type'); map[cssVarName] = strValue; // map[typeVarName] = strValue; this.attachRgbVar(map, cssVarName, strValue); // this.attachRgbVar(map, typeVarName, strValue); }; Object.entries(palette || {}).forEach(([key, value]) => applyEntry(key, value as any)); Object.entries(cssOverrides || {}).forEach(([key, value]) => applyEntry(key, value as any)); return map; } /** * 刷新 CSS 变量 H5 */ private flushCssVars(vars: Record) { if (typeof document === 'undefined' || !document.documentElement) return; const root = document.documentElement; this.lastAppliedCssKeys.forEach(key => { root.style.removeProperty(key); }); Object.entries(vars).forEach(([key, value]) => { root.style.setProperty(key, value); }); this.lastAppliedCssKeys = Object.keys(vars); } /** * 将主题应用到运行时: * - 1) 调用 uni.$u.setColor(theme.color) 如果存在 * - 2) 在 H5 环境中,将 css map 注入到 document.documentElement 的 CSS 变量中 * - 3) 支持暗黑模式:根据 darkColor 或 color 应用相应的颜色 */ private applyTheme(theme: Theme | null) { if (!theme) return; const mode = this.getActiveMode(); const palette = this.getPaletteForMode(theme, mode); this.syncRuntimeTheme(palette); const cssVarMap = this.buildCssVarMap(theme, mode); this.cssVarsRef.value = cssVarMap; this.flushCssVars(cssVarMap); this.updateDocumentMode(mode); } } // 单例导出 export const configProvider = new ConfigProvider(); export default configProvider;