139 changed files with 2205 additions and 1450 deletions
@ -1,7 +0,0 @@ |
|||||
import type { Preference } from '@vben/types'; |
|
||||
|
|
||||
/** |
|
||||
* @description 项目配置文件 |
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置 |
|
||||
*/ |
|
||||
export const overridesPreference: Partial<Preference> = {}; |
|
||||
@ -0,0 +1,11 @@ |
|||||
|
import type { DeepPartial, Preferences } from '@vben/types'; |
||||
|
|
||||
|
/** |
||||
|
* @description 项目配置文件 |
||||
|
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置 |
||||
|
*/ |
||||
|
export const overridesPreferences: DeepPartial<Preferences> = { |
||||
|
app: { |
||||
|
name: 'Vben Admin', |
||||
|
}, |
||||
|
}; |
||||
@ -0,0 +1,16 @@ |
|||||
|
import type { InitStoreOptions } from '@vben-core/stores'; |
||||
|
|
||||
|
import { initStore } from '@vben-core/stores'; |
||||
|
|
||||
|
import type { App } from 'vue'; |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 初始化pinia |
||||
|
* @param app vue app 实例 |
||||
|
*/ |
||||
|
async function setupStore(app: App, options: InitStoreOptions) { |
||||
|
const pinia = await initStore(options); |
||||
|
app.use(pinia); |
||||
|
} |
||||
|
|
||||
|
export { setupStore }; |
||||
@ -0,0 +1,24 @@ |
|||||
|
import { createPinia, setActivePinia } from 'pinia'; |
||||
|
import { |
||||
|
// beforeEach,
|
||||
|
describe, |
||||
|
// expect,
|
||||
|
it, |
||||
|
} from 'vitest'; |
||||
|
|
||||
|
// import { useAccessStore } from '../modules/access';
|
||||
|
|
||||
|
describe('useCounterStore', () => { |
||||
|
it('app Name with test', () => { |
||||
|
setActivePinia(createPinia()); |
||||
|
// let referenceStore = usePreferencesStore();
|
||||
|
|
||||
|
// beforeEach(() => {
|
||||
|
// referenceStore = usePreferencesStore();
|
||||
|
// });
|
||||
|
|
||||
|
// expect(referenceStore.appName).toBe('vben-admin');
|
||||
|
// referenceStore.setAppName('vbenAdmin');
|
||||
|
// expect(referenceStore.getAppName).toBe('vbenAdmin');
|
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,13 @@ |
|||||
|
import { defineStore } from 'pinia'; |
||||
|
|
||||
|
export const useCounterStore = defineStore('counter', { |
||||
|
actions: { |
||||
|
increment() { |
||||
|
this.count++; |
||||
|
}, |
||||
|
}, |
||||
|
getters: { |
||||
|
double: (state) => state.count * 2, |
||||
|
}, |
||||
|
state: () => ({ count: 0 }), |
||||
|
}); |
||||
@ -1,3 +1,3 @@ |
|||||
# @vben-core |
# @vben-core |
||||
|
|
||||
系统一些比较基础的SDK和UI组件库,请勿将任何业务逻辑和业务包放在这里。 |
系统一些比较基础的SDK和UI组件库,该目录后续可能会迁移出去或者发布到npm,请勿将任何业务逻辑和业务包放在该目录。 |
||||
|
|||||
@ -0,0 +1,3 @@ |
|||||
|
# @vben-core/forward |
||||
|
|
||||
|
该目录内的包,可直接被app所引用 |
||||
@ -0,0 +1,77 @@ |
|||||
|
import type { Preferences } from './types'; |
||||
|
|
||||
|
const defaultPreferences: Preferences = { |
||||
|
app: { |
||||
|
authPageLayout: 'panel-right', |
||||
|
colorGrayMode: false, |
||||
|
colorWeakMode: false, |
||||
|
compact: false, |
||||
|
contentCompact: 'wide', |
||||
|
copyright: 'Copyright © 2024 Vben Admin PRO', |
||||
|
defaultAvatar: |
||||
|
'https://cdn.jsdelivr.net/npm/@vbenjs/static-source@0.1.0/source/avatar-v1.webp', |
||||
|
dynamicTitle: true, |
||||
|
isMobile: false, |
||||
|
layout: 'side-nav', |
||||
|
locale: 'zh-CN', |
||||
|
name: 'Vben Admin Pro', |
||||
|
semiDarkMenu: true, |
||||
|
showPreference: true, |
||||
|
themeMode: 'dark', |
||||
|
}, |
||||
|
breadcrumb: { |
||||
|
enable: true, |
||||
|
hideOnlyOne: false, |
||||
|
showHome: false, |
||||
|
showIcon: true, |
||||
|
styleType: 'normal', |
||||
|
}, |
||||
|
footer: { |
||||
|
enable: true, |
||||
|
fixed: true, |
||||
|
}, |
||||
|
header: { |
||||
|
enable: true, |
||||
|
hidden: false, |
||||
|
mode: 'fixed', |
||||
|
}, |
||||
|
logo: { |
||||
|
enable: true, |
||||
|
source: |
||||
|
'https://cdn.jsdelivr.net/npm/@vbenjs/static-source@0.1.0/source/logo-v1.webp', |
||||
|
}, |
||||
|
navigation: { |
||||
|
accordion: true, |
||||
|
split: true, |
||||
|
styleType: 'rounded', |
||||
|
}, |
||||
|
|
||||
|
shortcutKeys: { enable: true }, |
||||
|
sidebar: { |
||||
|
collapse: false, |
||||
|
collapseShowTitle: true, |
||||
|
enable: true, |
||||
|
expandOnHover: true, |
||||
|
extraCollapse: true, |
||||
|
hidden: false, |
||||
|
width: 240, |
||||
|
}, |
||||
|
|
||||
|
tabbar: { |
||||
|
enable: true, |
||||
|
keepAlive: true, |
||||
|
showIcon: true, |
||||
|
}, |
||||
|
|
||||
|
theme: { |
||||
|
colorPrimary: 'hsl(211 91% 39%)', |
||||
|
}, |
||||
|
|
||||
|
transition: { |
||||
|
enable: true, |
||||
|
name: 'fade-slide', |
||||
|
progress: true, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
export { defaultPreferences }; |
||||
@ -0,0 +1,26 @@ |
|||||
|
import type { LocaleSupportType } from './types'; |
||||
|
|
||||
|
interface Language { |
||||
|
key: LocaleSupportType; |
||||
|
text: string; |
||||
|
} |
||||
|
|
||||
|
export const COLOR_PRIMARY_RESETS = [ |
||||
|
'hsl(211 91% 39%)', |
||||
|
'hsl(212 100% 45%)', |
||||
|
'hsl(181 84% 32%)', |
||||
|
'hsl(230 99% 66%)', |
||||
|
'hsl(245 82% 67%)', |
||||
|
'hsl(340 100% 68%)', |
||||
|
]; |
||||
|
|
||||
|
export const SUPPORT_LANGUAGES: Language[] = [ |
||||
|
{ |
||||
|
key: 'zh-CN', |
||||
|
text: '简体中文', |
||||
|
}, |
||||
|
{ |
||||
|
key: 'en-US', |
||||
|
text: 'English', |
||||
|
}, |
||||
|
]; |
||||
@ -0,0 +1,32 @@ |
|||||
|
import type { Flatten } from '@vben-core/typings'; |
||||
|
|
||||
|
import { preferencesManager } from './preferences'; |
||||
|
|
||||
|
import type { Preferences } from './types'; |
||||
|
|
||||
|
// 偏好设置(带有层级关系)
|
||||
|
const preferences: Preferences = preferencesManager.getPreferences(); |
||||
|
|
||||
|
// 扁平化后的偏好设置
|
||||
|
const flatPreferences: Flatten<Preferences> = |
||||
|
preferencesManager.getFlatPreferences(); |
||||
|
|
||||
|
// 更新偏好设置
|
||||
|
const updatePreferences = |
||||
|
preferencesManager.updatePreferences.bind(preferencesManager); |
||||
|
|
||||
|
// 重置偏好设置
|
||||
|
const resetPreferences = |
||||
|
preferencesManager.resetPreferences.bind(preferencesManager); |
||||
|
|
||||
|
export { |
||||
|
flatPreferences, |
||||
|
preferences, |
||||
|
preferencesManager, |
||||
|
resetPreferences, |
||||
|
updatePreferences, |
||||
|
}; |
||||
|
|
||||
|
export * from './constants'; |
||||
|
export type * from './types'; |
||||
|
export * from './use-preferences'; |
||||
@ -0,0 +1,289 @@ |
|||||
|
import type { |
||||
|
DeepPartial, |
||||
|
Flatten, |
||||
|
FlattenObjectKeys, |
||||
|
} from '@vben-core/typings'; |
||||
|
|
||||
|
import { StorageManager } from '@vben-core/cache'; |
||||
|
import { flattenObject, toNestedObject } from '@vben-core/helpers'; |
||||
|
import { convertToHslCssVar, merge } from '@vben-core/toolkit'; |
||||
|
|
||||
|
import { |
||||
|
breakpointsTailwind, |
||||
|
useBreakpoints, |
||||
|
useCssVar, |
||||
|
useDebounceFn, |
||||
|
} from '@vueuse/core'; |
||||
|
import { markRaw, reactive, watch } from 'vue'; |
||||
|
|
||||
|
import { defaultPreferences } from './config'; |
||||
|
|
||||
|
import type { Preferences } from './types'; |
||||
|
|
||||
|
const STORAGE_KEY = 'preferences'; |
||||
|
|
||||
|
interface initialOptions { |
||||
|
namespace: string; |
||||
|
overrides?: DeepPartial<Preferences>; |
||||
|
} |
||||
|
|
||||
|
function isDarkTheme(theme: string) { |
||||
|
let dark = theme === 'dark'; |
||||
|
if (theme === 'auto') { |
||||
|
dark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
||||
|
} |
||||
|
return dark; |
||||
|
} |
||||
|
|
||||
|
class PreferenceManager { |
||||
|
private cache: StorageManager<Preferences> | null = null; |
||||
|
private flattenedState: Flatten<Preferences>; |
||||
|
private initialPreferences: Preferences = defaultPreferences; |
||||
|
private isInitialized: boolean = false; |
||||
|
private savePreferences: (preference: Preferences) => void; |
||||
|
private state: Preferences = reactive<Preferences>({ |
||||
|
...this.loadPreferences(), |
||||
|
}); |
||||
|
constructor() { |
||||
|
this.cache = new StorageManager(); |
||||
|
this.flattenedState = reactive(flattenObject(this.state)); |
||||
|
|
||||
|
this.savePreferences = useDebounceFn( |
||||
|
(preference: Preferences) => this._savePreferences(preference), |
||||
|
100, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 保存偏好设置 |
||||
|
* @param {Preferences} preference - 需要保存的偏好设置 |
||||
|
*/ |
||||
|
private _savePreferences(preference: Preferences) { |
||||
|
this.cache?.setItem(STORAGE_KEY, preference); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理更新的键值 |
||||
|
* 根据更新的键值执行相应的操作。 |
||||
|
* |
||||
|
* @param {DeepPartial<Preferences>} updates - 部分更新的偏好设置 |
||||
|
*/ |
||||
|
private handleUpdates(updates: DeepPartial<Preferences>) { |
||||
|
const themeUpdates = updates.theme || {}; |
||||
|
const appUpdates = updates.app || {}; |
||||
|
|
||||
|
if (themeUpdates.colorPrimary) { |
||||
|
this.updateCssVar(this.state); |
||||
|
} |
||||
|
|
||||
|
if (appUpdates.themeMode) { |
||||
|
this.updateTheme(this.state); |
||||
|
} |
||||
|
|
||||
|
if (appUpdates.colorGrayMode || appUpdates.colorWeakMode) { |
||||
|
this.updateColorMode(this.state); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 加载偏好设置 |
||||
|
* 从缓存中加载偏好设置。如果缓存中没有找到对应的偏好设置,则返回默认偏好设置。 |
||||
|
* @returns {Preferences} 加载的偏好设置 |
||||
|
*/ |
||||
|
private loadPreferences(): Preferences { |
||||
|
const savedPreferences = this.cache?.getItem(STORAGE_KEY); |
||||
|
return savedPreferences || { ...defaultPreferences }; |
||||
|
} |
||||
|
/** |
||||
|
* 监听状态和系统偏好设置的变化。 |
||||
|
*/ |
||||
|
private setupWatcher() { |
||||
|
if (this.isInitialized) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const debounceWaterState = useDebounceFn(() => { |
||||
|
const newFlattenedState = flattenObject(this.state); |
||||
|
for (const k in newFlattenedState) { |
||||
|
const key = k as FlattenObjectKeys<Preferences>; |
||||
|
this.flattenedState[key] = newFlattenedState[key]; |
||||
|
} |
||||
|
this.savePreferences(this.state); |
||||
|
}, 16); |
||||
|
|
||||
|
const debounceWaterFlattenedState = useDebounceFn( |
||||
|
(val: Flatten<Preferences>) => { |
||||
|
this.updateState(val); |
||||
|
this.savePreferences(this.state); |
||||
|
}, |
||||
|
16, |
||||
|
); |
||||
|
|
||||
|
// 监听 state 的变化
|
||||
|
watch(this.state, debounceWaterState, { deep: true }); |
||||
|
|
||||
|
// 监听 flattenedState 的变化并触发 set 方法
|
||||
|
watch(this.flattenedState, debounceWaterFlattenedState, { deep: true }); |
||||
|
|
||||
|
// 监听断点,判断是否移动端
|
||||
|
const breakpoints = useBreakpoints(breakpointsTailwind); |
||||
|
const isMobile = breakpoints.smaller('md'); |
||||
|
watch( |
||||
|
() => isMobile.value, |
||||
|
(val) => { |
||||
|
this.updatePreferences({ |
||||
|
app: { isMobile: val }, |
||||
|
}); |
||||
|
}, |
||||
|
{ immediate: true }, |
||||
|
); |
||||
|
|
||||
|
// 监听系统主题偏好设置变化
|
||||
|
window |
||||
|
.matchMedia('(prefers-color-scheme: dark)') |
||||
|
.addEventListener('change', ({ matches: isDark }) => { |
||||
|
this.updatePreferences({ |
||||
|
app: { themeMode: isDark ? 'dark' : 'light' }, |
||||
|
}); |
||||
|
this.updateTheme(this.state); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新页面颜色模式(灰色、色弱) |
||||
|
* @param preference |
||||
|
*/ |
||||
|
private updateColorMode(preference: Preferences) { |
||||
|
if (preference.app) { |
||||
|
const { colorGrayMode, colorWeakMode } = preference.app; |
||||
|
const body = document.body; |
||||
|
const COLOR_WEAK = 'invert-mode'; |
||||
|
const COLOR_GRAY = 'grayscale-mode'; |
||||
|
colorWeakMode |
||||
|
? body.classList.add(COLOR_WEAK) |
||||
|
: body.classList.remove(COLOR_WEAK); |
||||
|
colorGrayMode |
||||
|
? body.classList.add(COLOR_GRAY) |
||||
|
: body.classList.remove(COLOR_GRAY); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新 CSS 变量 |
||||
|
* @param preference - 当前偏好设置对象,它的颜色值将被转换成 HSL 格式并设置为 CSS 变量。 |
||||
|
*/ |
||||
|
private updateCssVar(preference: Preferences) { |
||||
|
if (preference.theme) { |
||||
|
for (const [key, value] of Object.entries(preference.theme)) { |
||||
|
if (['colorPrimary'].includes(key)) { |
||||
|
const cssVarKey = key.replaceAll(/([A-Z])/g, '-$1').toLowerCase(); |
||||
|
const cssVarValue = useCssVar(`--${cssVarKey}`); |
||||
|
cssVarValue.value = convertToHslCssVar(value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新状态 |
||||
|
* 将新的扁平对象转换为嵌套对象,并与当前状态合并。 |
||||
|
* @param {FlattenObject<Preferences>} newValue - 新的扁平对象 |
||||
|
*/ |
||||
|
private updateState(newValue: Flatten<Preferences>) { |
||||
|
const nestObj = toNestedObject(newValue, 2); |
||||
|
Object.assign(this.state, merge(nestObj, this.state)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新主题 |
||||
|
* @param preferences - 当前偏好设置对象,它的主题值将被用来设置文档的主题。 |
||||
|
*/ |
||||
|
private updateTheme(preferences: Preferences) { |
||||
|
// 当修改到颜色变量时,更新 css 变量
|
||||
|
const root = document.documentElement; |
||||
|
if (root) { |
||||
|
const themeMode = preferences?.app?.themeMode; |
||||
|
if (!themeMode) { |
||||
|
return; |
||||
|
} |
||||
|
const dark = isDarkTheme(themeMode); |
||||
|
root.classList.toggle('dark', dark); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getFlatPreferences() { |
||||
|
return this.flattenedState; |
||||
|
} |
||||
|
|
||||
|
public getInitialPreferences() { |
||||
|
return this.initialPreferences; |
||||
|
} |
||||
|
|
||||
|
public getPreferences() { |
||||
|
return this.state; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 覆盖偏好设置 |
||||
|
* @param overrides - 要覆盖的偏好设置 |
||||
|
* @param namespace - 命名空间 |
||||
|
*/ |
||||
|
public async initPreferences({ namespace, overrides }: initialOptions) { |
||||
|
// 是否初始化过
|
||||
|
if (this.isInitialized) { |
||||
|
return; |
||||
|
} |
||||
|
// 初始化存储管理器
|
||||
|
this.cache = new StorageManager({ prefix: namespace }); |
||||
|
// 合并初始偏好设置
|
||||
|
this.initialPreferences = merge({}, overrides, defaultPreferences); |
||||
|
|
||||
|
// 加载并合并当前存储的偏好设置
|
||||
|
const mergedPreference = merge({}, this.loadPreferences(), overrides); |
||||
|
|
||||
|
// 更新偏好设置
|
||||
|
this.updatePreferences(mergedPreference); |
||||
|
|
||||
|
this.setupWatcher(); |
||||
|
// 标记为已初始化
|
||||
|
this.isInitialized = true; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重置偏好设置 |
||||
|
* 偏好设置将被重置为初始值,并从 localStorage 中移除。 |
||||
|
* |
||||
|
* @example |
||||
|
* 假设 initialPreferences 为 { theme: 'light', language: 'en' } |
||||
|
* 当前 state 为 { theme: 'dark', language: 'fr' } |
||||
|
* this.resetPreferences(); |
||||
|
* 调用后,state 将被重置为 { theme: 'light', language: 'en' } |
||||
|
* 并且 localStorage 中的对应项将被移除 |
||||
|
*/ |
||||
|
resetPreferences() { |
||||
|
// 将状态重置为初始偏好设置
|
||||
|
Object.assign(this.state, this.initialPreferences); |
||||
|
// 保存重置后的偏好设置
|
||||
|
this.savePreferences(this.state); |
||||
|
// 从存储中移除偏好设置项
|
||||
|
this.cache?.removeItem(STORAGE_KEY); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新偏好设置 |
||||
|
* @param updates - 要更新的偏好设置 |
||||
|
*/ |
||||
|
public updatePreferences(updates: DeepPartial<Preferences>) { |
||||
|
const mergedState = merge(updates, markRaw(this.state)); |
||||
|
|
||||
|
Object.assign(this.state, mergedState); |
||||
|
Object.assign(this.flattenedState, flattenObject(this.state)); |
||||
|
|
||||
|
// 根据更新的键值执行相应的操作
|
||||
|
this.handleUpdates(updates); |
||||
|
this.savePreferences(this.state); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const preferencesManager = new PreferenceManager(); |
||||
|
export { isDarkTheme, preferencesManager }; |
||||
@ -0,0 +1,189 @@ |
|||||
|
import type { |
||||
|
ContentCompactType, |
||||
|
LayoutHeaderModeType, |
||||
|
LayoutType, |
||||
|
LocaleSupportType, |
||||
|
ThemeModeType, |
||||
|
} from '@vben-core/typings'; |
||||
|
|
||||
|
type BreadcrumbStyleType = 'background' | 'normal'; |
||||
|
|
||||
|
type NavigationStyleType = 'plain' | 'rounded'; |
||||
|
|
||||
|
type PageTransitionType = 'fade-slide'; |
||||
|
|
||||
|
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right'; |
||||
|
|
||||
|
interface AppPreferences { |
||||
|
/** 登录注册页面布局 */ |
||||
|
authPageLayout: AuthPageLayoutType; |
||||
|
/** 是否开启灰色模式 */ |
||||
|
colorGrayMode: boolean; |
||||
|
/** 是否开启色弱模式 */ |
||||
|
colorWeakMode: boolean; |
||||
|
/** 是否开启紧凑模式 */ |
||||
|
compact: boolean; |
||||
|
/** 是否开启内容紧凑模式 */ |
||||
|
contentCompact: ContentCompactType; |
||||
|
/** 页脚Copyright */ |
||||
|
copyright: string; |
||||
|
// /** 应用默认头像 */
|
||||
|
defaultAvatar: string; |
||||
|
// /** 开启动态标题 */
|
||||
|
dynamicTitle: boolean; |
||||
|
/** 是否移动端 */ |
||||
|
isMobile: boolean; |
||||
|
/** 布局方式 */ |
||||
|
layout: LayoutType; |
||||
|
/** 支持的语言 */ |
||||
|
locale: LocaleSupportType; |
||||
|
/** 应用名 */ |
||||
|
name: string; |
||||
|
/** 是否开启半深色菜单(只在theme='light'时生效) */ |
||||
|
semiDarkMenu: boolean; |
||||
|
/** 是否显示偏好设置 */ |
||||
|
showPreference: boolean; |
||||
|
/** 当前主题 */ |
||||
|
themeMode: ThemeModeType; |
||||
|
} |
||||
|
|
||||
|
interface BreadcrumbPreferences { |
||||
|
/** 面包屑是否启用 */ |
||||
|
enable: boolean; |
||||
|
/** 面包屑是否只有一个时隐藏 */ |
||||
|
hideOnlyOne: boolean; |
||||
|
/** 面包屑首页图标是否可见 */ |
||||
|
showHome: boolean; |
||||
|
/** 面包屑图标是否可见 */ |
||||
|
showIcon: boolean; |
||||
|
/** 面包屑风格 */ |
||||
|
styleType: BreadcrumbStyleType; |
||||
|
} |
||||
|
|
||||
|
interface FooterPreferences { |
||||
|
/** 底栏是否可见 */ |
||||
|
enable: boolean; |
||||
|
/** 底栏是否固定 */ |
||||
|
fixed: boolean; |
||||
|
} |
||||
|
|
||||
|
interface HeaderPreferences { |
||||
|
/** 顶栏是否启用 */ |
||||
|
enable: boolean; |
||||
|
/** 顶栏是否隐藏,css-隐藏 */ |
||||
|
hidden: boolean; |
||||
|
/** header显示模式 */ |
||||
|
mode: LayoutHeaderModeType; |
||||
|
} |
||||
|
|
||||
|
interface LogoPreferences { |
||||
|
/** logo是否可见 */ |
||||
|
enable: boolean; |
||||
|
/** logo地址 */ |
||||
|
source: string; |
||||
|
} |
||||
|
|
||||
|
interface NavigationPreferences { |
||||
|
/** 导航菜单手风琴模式 */ |
||||
|
accordion: boolean; |
||||
|
/** 导航菜单是否切割,只在 layout=mixed-nav 生效 */ |
||||
|
split: boolean; |
||||
|
/** 导航菜单风格 */ |
||||
|
styleType: NavigationStyleType; |
||||
|
} |
||||
|
|
||||
|
interface SidebarPreferences { |
||||
|
/** 侧边栏是否折叠 */ |
||||
|
collapse: boolean; |
||||
|
/** 侧边栏折叠时,是否显示title */ |
||||
|
collapseShowTitle: boolean; |
||||
|
/** 侧边栏是否可见 */ |
||||
|
enable: boolean; |
||||
|
/** 菜单自动展开状态 */ |
||||
|
expandOnHover: boolean; |
||||
|
/** 侧边栏扩展区域是否折叠 */ |
||||
|
extraCollapse: boolean; |
||||
|
/** 侧边栏是否隐藏 - css */ |
||||
|
hidden: boolean; |
||||
|
/** 侧边栏宽度 */ |
||||
|
width: number; |
||||
|
} |
||||
|
|
||||
|
interface ShortcutKeyPreferences { |
||||
|
/** 是否启用快捷键-全局 */ |
||||
|
enable: boolean; |
||||
|
} |
||||
|
|
||||
|
interface TabbarPreferences { |
||||
|
/** 是否开启多标签页 */ |
||||
|
enable: boolean; |
||||
|
/** 开启标签页缓存功能 */ |
||||
|
keepAlive: boolean; |
||||
|
/** 是否开启多标签页图标 */ |
||||
|
showIcon: boolean; |
||||
|
} |
||||
|
|
||||
|
interface ThemePreferences { |
||||
|
/** 主题色 */ |
||||
|
colorPrimary: string; |
||||
|
} |
||||
|
|
||||
|
interface TransitionPreferences { |
||||
|
/** 页面切换动画是否启用 */ |
||||
|
enable: boolean; |
||||
|
/** 页面切换动画 */ |
||||
|
name: PageTransitionType; |
||||
|
/** 是否开启页面加载进度动画 */ |
||||
|
progress: boolean; |
||||
|
} |
||||
|
|
||||
|
interface Preferences { |
||||
|
/** 全局配置 */ |
||||
|
app: AppPreferences; |
||||
|
/** 顶栏配置 */ |
||||
|
breadcrumb: BreadcrumbPreferences; |
||||
|
/** 底栏配置 */ |
||||
|
footer: FooterPreferences; |
||||
|
/** 面包屑配置 */ |
||||
|
header: HeaderPreferences; |
||||
|
/** logo配置 */ |
||||
|
logo: LogoPreferences; |
||||
|
/** 导航配置 */ |
||||
|
navigation: NavigationPreferences; |
||||
|
/** 快捷键配置 */ |
||||
|
shortcutKeys: ShortcutKeyPreferences; |
||||
|
/** 侧边栏配置 */ |
||||
|
sidebar: SidebarPreferences; |
||||
|
/** 标签页配置 */ |
||||
|
tabbar: TabbarPreferences; |
||||
|
/** 主题配置 */ |
||||
|
theme: ThemePreferences; |
||||
|
/** 动画配置 */ |
||||
|
transition: TransitionPreferences; |
||||
|
} |
||||
|
|
||||
|
type PreferencesKeys = keyof Preferences; |
||||
|
|
||||
|
export type { |
||||
|
AppPreferences, |
||||
|
AuthPageLayoutType, |
||||
|
BreadcrumbPreferences, |
||||
|
BreadcrumbStyleType, |
||||
|
ContentCompactType, |
||||
|
FooterPreferences, |
||||
|
HeaderPreferences, |
||||
|
LayoutHeaderModeType, |
||||
|
LayoutType, |
||||
|
LocaleSupportType, |
||||
|
LogoPreferences, |
||||
|
NavigationPreferences, |
||||
|
PageTransitionType, |
||||
|
Preferences, |
||||
|
PreferencesKeys, |
||||
|
ShortcutKeyPreferences, |
||||
|
SidebarPreferences, |
||||
|
TabbarPreferences, |
||||
|
ThemeModeType, |
||||
|
ThemePreferences, |
||||
|
TransitionPreferences, |
||||
|
}; |
||||
@ -0,0 +1,7 @@ |
|||||
|
import { defineBuildConfig } from 'unbuild'; |
||||
|
|
||||
|
export default defineBuildConfig({ |
||||
|
clean: true, |
||||
|
declaration: true, |
||||
|
entries: ['src/index'], |
||||
|
}); |
||||
@ -0,0 +1,45 @@ |
|||||
|
{ |
||||
|
"name": "@vben-core/helpers", |
||||
|
"version": "1.0.0", |
||||
|
"type": "module", |
||||
|
"license": "MIT", |
||||
|
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
||||
|
"directory": "packages/@vben-core/helpers" |
||||
|
}, |
||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
||||
|
"scripts": { |
||||
|
"build": "pnpm unbuild", |
||||
|
"stub": "pnpm unbuild --stub" |
||||
|
}, |
||||
|
"files": [ |
||||
|
"dist" |
||||
|
], |
||||
|
"sideEffects": false, |
||||
|
"main": "./dist/index.mjs", |
||||
|
"module": "./dist/index.mjs", |
||||
|
"imports": { |
||||
|
"#*": "./src/*" |
||||
|
}, |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./src/index.ts", |
||||
|
"development": "./src/index.ts", |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
}, |
||||
|
"publishConfig": { |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./dist/index.d.ts", |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vben-core/toolkit": "workspace:*", |
||||
|
"@vben-core/typings": "workspace:*" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './object'; |
||||
@ -0,0 +1,245 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { flattenObject, toCamelCase, toNestedObject } from './object'; |
||||
|
|
||||
|
describe('toCamelCase', () => { |
||||
|
it('should return the key if parentKey is empty', () => { |
||||
|
expect(toCamelCase('child', '')).toBe('child'); |
||||
|
}); |
||||
|
|
||||
|
it('should combine parentKey and key in camel case', () => { |
||||
|
expect(toCamelCase('child', 'parent')).toBe('parentChild'); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty key and parentKey', () => { |
||||
|
expect(toCamelCase('', '')).toBe(''); |
||||
|
}); |
||||
|
|
||||
|
it('should handle key with capital letters', () => { |
||||
|
expect(toCamelCase('Child', 'parent')).toBe('parentChild'); |
||||
|
expect(toCamelCase('Child', 'Parent')).toBe('ParentChild'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('flattenObject', () => { |
||||
|
it('should flatten a nested object correctly', () => { |
||||
|
const nestedObject = { |
||||
|
language: 'en', |
||||
|
notifications: { |
||||
|
email: true, |
||||
|
push: { |
||||
|
sound: true, |
||||
|
vibration: false, |
||||
|
}, |
||||
|
}, |
||||
|
theme: 'light', |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
language: 'en', |
||||
|
notificationsEmail: true, |
||||
|
notificationsPushSound: true, |
||||
|
notificationsPushVibration: false, |
||||
|
theme: 'light', |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty objects', () => { |
||||
|
const nestedObject = {}; |
||||
|
const expected = {}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle objects with primitive values', () => { |
||||
|
const nestedObject = { |
||||
|
active: true, |
||||
|
age: 30, |
||||
|
name: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
active: true, |
||||
|
age: 30, |
||||
|
name: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle objects with null values', () => { |
||||
|
const nestedObject = { |
||||
|
user: { |
||||
|
age: null, |
||||
|
name: null, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
userAge: null, |
||||
|
userName: null, |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle nested empty objects', () => { |
||||
|
const nestedObject = { |
||||
|
a: {}, |
||||
|
b: { c: {} }, |
||||
|
}; |
||||
|
|
||||
|
const expected = {}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle arrays within objects', () => { |
||||
|
const nestedObject = { |
||||
|
hobbies: ['reading', 'gaming'], |
||||
|
name: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
hobbies: ['reading', 'gaming'], |
||||
|
name: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
it('should flatten objects with nested arrays correctly', () => { |
||||
|
const nestedObject = { |
||||
|
person: { |
||||
|
hobbies: ['reading', 'gaming'], |
||||
|
name: 'Alice', |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
personHobbies: ['reading', 'gaming'], |
||||
|
personName: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
|
||||
|
it('should handle objects with undefined values', () => { |
||||
|
const nestedObject = { |
||||
|
user: { |
||||
|
age: undefined, |
||||
|
name: 'Alice', |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const expected = { |
||||
|
userAge: undefined, |
||||
|
userName: 'Alice', |
||||
|
}; |
||||
|
|
||||
|
const result = flattenObject(nestedObject); |
||||
|
expect(result).toEqual(expected); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('toNestedObject', () => { |
||||
|
it('should convert flat object to nested object with level 1', () => { |
||||
|
const flatObject = { |
||||
|
anotherKeyExample: 2, |
||||
|
commonAppName: 1, |
||||
|
someOtherKey: 3, |
||||
|
}; |
||||
|
|
||||
|
const expectedNestedObject = { |
||||
|
anotherKeyExample: 2, |
||||
|
commonAppName: 1, |
||||
|
someOtherKey: 3, |
||||
|
}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 1)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
|
||||
|
it('should convert flat object to nested object with level 2', () => { |
||||
|
const flatObject = { |
||||
|
appAnotherKeyExample: 2, |
||||
|
appCommonName: 1, |
||||
|
appSomeOtherKey: 3, |
||||
|
}; |
||||
|
|
||||
|
const expectedNestedObject = { |
||||
|
app: { |
||||
|
anotherKeyExample: 2, |
||||
|
commonName: 1, |
||||
|
someOtherKey: 3, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 2)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
|
||||
|
it('should convert flat object to nested object with level 3', () => { |
||||
|
const flatObject = { |
||||
|
appAnotherKeyExampleValue: 2, |
||||
|
appCommonNameKey: 1, |
||||
|
appSomeOtherKeyItem: 3, |
||||
|
}; |
||||
|
|
||||
|
const expectedNestedObject = { |
||||
|
app: { |
||||
|
another: { |
||||
|
keyExampleValue: 2, |
||||
|
}, |
||||
|
common: { |
||||
|
nameKey: 1, |
||||
|
}, |
||||
|
some: { |
||||
|
otherKeyItem: 3, |
||||
|
}, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 3)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty object', () => { |
||||
|
const flatObject = {}; |
||||
|
|
||||
|
const expectedNestedObject = {}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 1)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
|
||||
|
it('should handle single key object', () => { |
||||
|
const flatObject = { |
||||
|
singleKey: 1, |
||||
|
}; |
||||
|
|
||||
|
const expectedNestedObject = { |
||||
|
singleKey: 1, |
||||
|
}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 1)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
|
||||
|
it('should handle complex keys', () => { |
||||
|
const flatObject = { |
||||
|
anotherComplexKeyWithParts: 2, |
||||
|
complexKeyWithMultipleParts: 1, |
||||
|
}; |
||||
|
|
||||
|
const expectedNestedObject = { |
||||
|
anotherComplexKeyWithParts: 2, |
||||
|
complexKeyWithMultipleParts: 1, |
||||
|
}; |
||||
|
|
||||
|
expect(toNestedObject(flatObject, 1)).toEqual(expectedNestedObject); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,164 @@ |
|||||
|
import type { Flatten } from '@vben-core/typings'; |
||||
|
|
||||
|
import { |
||||
|
capitalizeFirstLetter, |
||||
|
toLowerCaseFirstLetter, |
||||
|
} from '@vben-core/toolkit'; |
||||
|
|
||||
|
/** |
||||
|
* 生成驼峰命名法的键名 |
||||
|
* @param key |
||||
|
* @param parentKey |
||||
|
*/ |
||||
|
function toCamelCase(key: string, parentKey: string): string { |
||||
|
if (!parentKey) { |
||||
|
return key; |
||||
|
} |
||||
|
return parentKey + key.charAt(0).toUpperCase() + key.slice(1); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将嵌套对象扁平化 |
||||
|
* @param obj - 需要扁平化的对象 |
||||
|
* @param parentKey - 父键名,用于递归时拼接键名 |
||||
|
* @param result - 存储结果的对象 |
||||
|
* @returns 扁平化后的对象 |
||||
|
* |
||||
|
* 示例: |
||||
|
* const nestedObj = { |
||||
|
* user: { |
||||
|
* name: 'Alice', |
||||
|
* address: { |
||||
|
* city: 'Wonderland', |
||||
|
* zip: '12345' |
||||
|
* } |
||||
|
* }, |
||||
|
* items: [ |
||||
|
* { id: 1, name: 'Item 1' }, |
||||
|
* { id: 2, name: 'Item 2' } |
||||
|
* ], |
||||
|
* active: true |
||||
|
* }; |
||||
|
* const flatObj = flattenObject(nestedObj); |
||||
|
* console.log(flatObj); |
||||
|
* 输出: |
||||
|
* { |
||||
|
* userName: 'Alice', |
||||
|
* userAddressCity: 'Wonderland', |
||||
|
* userAddressZip: '12345', |
||||
|
* items: [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' } ], |
||||
|
* active: true |
||||
|
* } |
||||
|
*/ |
||||
|
function flattenObject<T extends Record<string, any>>( |
||||
|
obj: T, |
||||
|
parentKey: string = '', |
||||
|
result: Record<string, any> = {}, |
||||
|
): Flatten<T> { |
||||
|
Object.keys(obj).forEach((key) => { |
||||
|
const newKey = parentKey |
||||
|
? `${parentKey}${capitalizeFirstLetter(key)}` |
||||
|
: key; |
||||
|
const value = obj[key]; |
||||
|
|
||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) { |
||||
|
flattenObject(value, newKey, result); |
||||
|
} else { |
||||
|
result[newKey] = value; |
||||
|
} |
||||
|
}); |
||||
|
return result as Flatten<T>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将扁平对象转换为嵌套对象。 |
||||
|
* |
||||
|
* @template T - 输入对象值的类型 |
||||
|
* @param {Record<string, T>} obj - 要转换的扁平对象 |
||||
|
* @param {number} level - 嵌套的层级 |
||||
|
* @returns {T} 嵌套对象 |
||||
|
* |
||||
|
* @example |
||||
|
* 将扁平对象转换为嵌套对象,嵌套层级为 1 |
||||
|
* const flatObject = { |
||||
|
* 'commonAppName': 1, |
||||
|
* 'anotherKeyExample': 2, |
||||
|
* 'someOtherKey': 3 |
||||
|
* }; |
||||
|
* const nestedObject = toNestedObject(flatObject, 1); |
||||
|
* console.log(nestedObject); |
||||
|
* 输出: |
||||
|
* { |
||||
|
* commonAppName: 1, |
||||
|
* anotherKeyExample: 2, |
||||
|
* someOtherKey: 3 |
||||
|
* } |
||||
|
* |
||||
|
* @example |
||||
|
* 将扁平对象转换为嵌套对象,嵌套层级为 2 |
||||
|
* const flatObject = { |
||||
|
* 'appCommonName': 1, |
||||
|
* 'appAnotherKeyExample': 2, |
||||
|
* 'appSomeOtherKey': 3 |
||||
|
* }; |
||||
|
* const nestedObject = toNestedObject(flatObject, 2); |
||||
|
* console.log(nestedObject); |
||||
|
* 输出: |
||||
|
* { |
||||
|
* app: { |
||||
|
* commonName: 1, |
||||
|
* anotherKeyExample: 2, |
||||
|
* someOtherKey: 3 |
||||
|
* } |
||||
|
* } |
||||
|
*/ |
||||
|
|
||||
|
function toNestedObject<T>(obj: Record<string, T>, level: number): T { |
||||
|
const result: any = {}; |
||||
|
|
||||
|
for (const key in obj) { |
||||
|
const keys = key.split(/(?=[A-Z])/); |
||||
|
// 将驼峰式分割为数组;
|
||||
|
let current = result; |
||||
|
|
||||
|
for (let i = 0; i < keys.length; i++) { |
||||
|
const lowerKey = keys[i].toLowerCase(); |
||||
|
if (i === level - 1) { |
||||
|
const remainingKeys = keys.slice(i).join(''); // 保留后续部分作为键的一部分
|
||||
|
current[toLowerCaseFirstLetter(remainingKeys)] = obj[key]; |
||||
|
break; |
||||
|
} else { |
||||
|
current[lowerKey] = current[lowerKey] || {}; |
||||
|
current = current[lowerKey]; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return result as T; |
||||
|
} |
||||
|
|
||||
|
export { flattenObject, toCamelCase, toNestedObject }; |
||||
|
|
||||
|
// 定义递归类型,用于推断扁平化后的对象类型
|
||||
|
// 限制递归深度的辅助类型
|
||||
|
// type FlattenDepth<
|
||||
|
// T,
|
||||
|
// Depth extends number,
|
||||
|
// CurrentDepth extends number[] = [],
|
||||
|
// > = {
|
||||
|
// [K in keyof T as CurrentDepth['length'] extends Depth
|
||||
|
// ? K
|
||||
|
// : T[K] extends object
|
||||
|
// ? `${CurrentDepth['length'] extends 0 ? Uncapitalize<K & string> : Capitalize<K & string>}${keyof FlattenDepth<T[K], Depth, [...CurrentDepth, 1]> extends string ? Capitalize<keyof FlattenDepth<T[K], Depth, [...CurrentDepth, 1]>> : ''}`
|
||||
|
// : `${CurrentDepth['length'] extends 0 ? Uncapitalize<K & string> : Capitalize<K & string>}`]: CurrentDepth['length'] extends Depth
|
||||
|
// ? T[K]
|
||||
|
// : T[K] extends object
|
||||
|
// ? FlattenDepth<T[K], Depth, [...CurrentDepth, 1]>[keyof FlattenDepth<
|
||||
|
// T[K],
|
||||
|
// Depth,
|
||||
|
// [...CurrentDepth, 1]
|
||||
|
// >]
|
||||
|
// : T[K];
|
||||
|
// };
|
||||
|
|
||||
|
// type Flatten<T, Depth extends number = 4> = FlattenDepth<T, Depth>;
|
||||
@ -0,0 +1,5 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/tsconfig", |
||||
|
"extends": "@vben/tsconfig/library.json", |
||||
|
"include": ["src"] |
||||
|
} |
||||
@ -1 +1 @@ |
|||||
export * from './storage-cache'; |
export * from './storage-manager'; |
||||
|
|||||
@ -1,104 +0,0 @@ |
|||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
|
||||
|
|
||||
import { StorageCache } from './storage-cache'; |
|
||||
|
|
||||
describe('storageCache', () => { |
|
||||
let localStorageCache: StorageCache; |
|
||||
let sessionStorageCache: StorageCache; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
localStorageCache = new StorageCache('prefix_', 'localStorage'); |
|
||||
sessionStorageCache = new StorageCache('prefix_', 'sessionStorage'); |
|
||||
localStorage.clear(); |
|
||||
sessionStorage.clear(); |
|
||||
vi.useFakeTimers(); |
|
||||
}); |
|
||||
|
|
||||
afterEach(() => { |
|
||||
vi.useRealTimers(); |
|
||||
}); |
|
||||
|
|
||||
it('should set and get an item with prefix in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey', 'testValue'); |
|
||||
const value = localStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBe('testValue'); |
|
||||
expect(localStorage.getItem('prefix_testKey')).not.toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should set and get an item with prefix in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey', 'testValue'); |
|
||||
const value = sessionStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBe('testValue'); |
|
||||
expect(sessionStorage.getItem('prefix_testKey')).not.toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should return null for expired item in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey', 'testValue', 1 / 60); // 1 second expiry
|
|
||||
vi.advanceTimersByTime(2000); // Fast-forward 2 seconds
|
|
||||
const value = localStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should return null for expired item in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey', 'testValue', 1 / 60); // 1 second expiry
|
|
||||
vi.advanceTimersByTime(2000); // Fast-forward 2 seconds
|
|
||||
const value = sessionStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should remove an item with prefix in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey', 'testValue'); |
|
||||
localStorageCache.removeItem('testKey'); |
|
||||
const value = localStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBeNull(); |
|
||||
expect(localStorage.getItem('prefix_testKey')).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should remove an item with prefix in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey', 'testValue'); |
|
||||
sessionStorageCache.removeItem('testKey'); |
|
||||
const value = sessionStorageCache.getItem<string>('testKey'); |
|
||||
expect(value).toBeNull(); |
|
||||
expect(sessionStorage.getItem('prefix_testKey')).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('should clear all items in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
localStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
localStorageCache.clear(); |
|
||||
expect(localStorageCache.length()).toBe(0); |
|
||||
}); |
|
||||
|
|
||||
it('should clear all items in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
sessionStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
sessionStorageCache.clear(); |
|
||||
expect(sessionStorageCache.length()).toBe(0); |
|
||||
}); |
|
||||
|
|
||||
it('should return correct length in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
localStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
expect(localStorageCache.length()).toBe(2); |
|
||||
}); |
|
||||
|
|
||||
it('should return correct length in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
sessionStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
expect(sessionStorageCache.length()).toBe(2); |
|
||||
}); |
|
||||
|
|
||||
it('should return correct key by index in localStorage', () => { |
|
||||
localStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
localStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
expect(localStorageCache.key(0)).toBe('prefix_testKey1'); |
|
||||
expect(localStorageCache.key(1)).toBe('prefix_testKey2'); |
|
||||
}); |
|
||||
|
|
||||
it('should return correct key by index in sessionStorage', () => { |
|
||||
sessionStorageCache.setItem('testKey1', 'testValue1'); |
|
||||
sessionStorageCache.setItem('testKey2', 'testValue2'); |
|
||||
expect(sessionStorageCache.key(0)).toBe('prefix_testKey1'); |
|
||||
expect(sessionStorageCache.key(1)).toBe('prefix_testKey2'); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,145 +0,0 @@ |
|||||
import type { IStorageCache, StorageType, StorageValue } from './types'; |
|
||||
|
|
||||
class StorageCache implements IStorageCache { |
|
||||
protected prefix: string; |
|
||||
protected storage: Storage; |
|
||||
|
|
||||
constructor(prefix: string = '', storageType: StorageType = 'localStorage') { |
|
||||
this.prefix = prefix; |
|
||||
this.storage = |
|
||||
storageType === 'localStorage' ? localStorage : sessionStorage; |
|
||||
} |
|
||||
|
|
||||
// 获取带前缀的键名
|
|
||||
private getFullKey(key: string): string { |
|
||||
return this.prefix + key; |
|
||||
} |
|
||||
|
|
||||
// 获取项之后的钩子方法
|
|
||||
protected afterGetItem<T>(_key: string, _value: T | null): void {} |
|
||||
|
|
||||
// 设置项之后的钩子方法
|
|
||||
protected afterSetItem<T>( |
|
||||
_key: string, |
|
||||
_value: T, |
|
||||
_expiryInMinutes?: number, |
|
||||
): void {} |
|
||||
|
|
||||
// 获取项之前的钩子方法
|
|
||||
protected beforeGetItem(_key: string): void {} |
|
||||
|
|
||||
// 设置项之前的钩子方法
|
|
||||
protected beforeSetItem<T>( |
|
||||
_key: string, |
|
||||
_value: T, |
|
||||
_expiryInMinutes?: number, |
|
||||
): void {} |
|
||||
|
|
||||
/** |
|
||||
* 清空存储 |
|
||||
*/ |
|
||||
clear(): void { |
|
||||
try { |
|
||||
this.storage.clear(); |
|
||||
} catch (error) { |
|
||||
console.error('Error clearing storage', error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取存储项 |
|
||||
* @param key 存储键 |
|
||||
* @returns 存储值或 null |
|
||||
*/ |
|
||||
getItem<T>(key: string): T | null { |
|
||||
const fullKey = this.getFullKey(key); |
|
||||
this.beforeGetItem(fullKey); |
|
||||
|
|
||||
let value: T | null = null; |
|
||||
try { |
|
||||
const item = this.storage.getItem(fullKey); |
|
||||
if (item) { |
|
||||
const storageValue: StorageValue<T> = JSON.parse(item); |
|
||||
if (storageValue.expiry && storageValue.expiry < Date.now()) { |
|
||||
this.storage.removeItem(fullKey); |
|
||||
} else { |
|
||||
value = storageValue.data; |
|
||||
} |
|
||||
} |
|
||||
} catch (error) { |
|
||||
console.error('Error getting item from storage', error); |
|
||||
} |
|
||||
|
|
||||
this.afterGetItem(fullKey, value); |
|
||||
return value; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取存储中的键 |
|
||||
* @param index 键的索引 |
|
||||
* @returns 存储键或 null |
|
||||
*/ |
|
||||
key(index: number): null | string { |
|
||||
try { |
|
||||
return this.storage.key(index); |
|
||||
} catch (error) { |
|
||||
console.error('Error getting key from storage', error); |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取存储项的数量 |
|
||||
* @returns 存储项的数量 |
|
||||
*/ |
|
||||
length(): number { |
|
||||
try { |
|
||||
return this.storage.length; |
|
||||
} catch (error) { |
|
||||
console.error('Error getting storage length', error); |
|
||||
return 0; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 删除存储项 |
|
||||
* @param key 存储键 |
|
||||
*/ |
|
||||
removeItem(key: string): void { |
|
||||
const fullKey = this.getFullKey(key); |
|
||||
try { |
|
||||
this.storage.removeItem(fullKey); |
|
||||
} catch (error) { |
|
||||
console.error('Error removing item from storage', error); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置存储项 |
|
||||
* @param key 存储键 |
|
||||
* @param value 存储值 |
|
||||
* @param expiryInMinutes 过期时间(分钟) |
|
||||
*/ |
|
||||
setItem<T>(key: string, value: T, expiryInMinutes?: number): void { |
|
||||
const fullKey = this.getFullKey(key); |
|
||||
this.beforeSetItem(fullKey, value, expiryInMinutes); |
|
||||
|
|
||||
const now = Date.now(); |
|
||||
const expiry = expiryInMinutes ? now + expiryInMinutes * 60_000 : null; |
|
||||
|
|
||||
const storageValue: StorageValue<T> = { |
|
||||
data: value, |
|
||||
expiry, |
|
||||
}; |
|
||||
|
|
||||
try { |
|
||||
this.storage.setItem(fullKey, JSON.stringify(storageValue)); |
|
||||
} catch (error) { |
|
||||
console.error('Error setting item in storage', error); |
|
||||
} |
|
||||
|
|
||||
this.afterSetItem(fullKey, value, expiryInMinutes); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export { StorageCache }; |
|
||||
@ -0,0 +1,130 @@ |
|||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { StorageManager } from './storage-manager'; |
||||
|
|
||||
|
describe('storageManager', () => { |
||||
|
let storageManager: StorageManager<{ age: number; name: string }>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
vi.useFakeTimers(); |
||||
|
localStorage.clear(); |
||||
|
storageManager = new StorageManager<{ age: number; name: string }>({ |
||||
|
prefix: 'test_', |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('should set and get an item', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 30, name: 'John Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should return default value if item does not exist', () => { |
||||
|
const user = storageManager.getItem('nonexistent', { |
||||
|
age: 0, |
||||
|
name: 'Default User', |
||||
|
}); |
||||
|
expect(user).toEqual({ age: 0, name: 'Default User' }); |
||||
|
}); |
||||
|
|
||||
|
it('should remove an item', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }); |
||||
|
storageManager.removeItem('user'); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('should clear all items with the prefix', () => { |
||||
|
storageManager.setItem('user1', { age: 30, name: 'John Doe' }); |
||||
|
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); |
||||
|
storageManager.clear(); |
||||
|
expect(storageManager.getItem('user1')).toBeNull(); |
||||
|
expect(storageManager.getItem('user2')).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('should clear expired items', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
||||
|
vi.advanceTimersByTime(1001); // 快进时间
|
||||
|
storageManager.clearExpiredItems(); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('should not clear non-expired items', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
|
||||
|
vi.advanceTimersByTime(5000); // 快进时间
|
||||
|
storageManager.clearExpiredItems(); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 30, name: 'John Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should handle JSON parse errors gracefully', () => { |
||||
|
localStorage.setItem('test_user', '{ invalid JSON }'); |
||||
|
const user = storageManager.getItem('user', { |
||||
|
age: 0, |
||||
|
name: 'Default User', |
||||
|
}); |
||||
|
expect(user).toEqual({ age: 0, name: 'Default User' }); |
||||
|
}); |
||||
|
it('should return null for non-existent items without default value', () => { |
||||
|
const user = storageManager.getItem('nonexistent'); |
||||
|
expect(user).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('should overwrite existing items', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }); |
||||
|
storageManager.setItem('user', { age: 25, name: 'Jane Doe' }); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 25, name: 'Jane Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should handle items without expiry correctly', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }); |
||||
|
vi.advanceTimersByTime(5000); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 30, name: 'John Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should remove expired items when accessed', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
||||
|
vi.advanceTimersByTime(1001); // 快进时间
|
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('should not remove non-expired items when accessed', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
|
||||
|
vi.advanceTimersByTime(5000); // 快进时间
|
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 30, name: 'John Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should handle multiple items with different expiry times', () => { |
||||
|
storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
|
||||
|
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
|
||||
|
vi.advanceTimersByTime(1500); // 快进时间
|
||||
|
storageManager.clearExpiredItems(); |
||||
|
const user1 = storageManager.getItem('user1'); |
||||
|
const user2 = storageManager.getItem('user2'); |
||||
|
expect(user1).toBeNull(); |
||||
|
expect(user2).toEqual({ age: 25, name: 'Jane Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should handle items with no expiry', () => { |
||||
|
storageManager.setItem('user', { age: 30, name: 'John Doe' }); |
||||
|
vi.advanceTimersByTime(10_000); // 快进时间
|
||||
|
storageManager.clearExpiredItems(); |
||||
|
const user = storageManager.getItem('user'); |
||||
|
expect(user).toEqual({ age: 30, name: 'John Doe' }); |
||||
|
}); |
||||
|
|
||||
|
it('should clear all items correctly', () => { |
||||
|
storageManager.setItem('user1', { age: 30, name: 'John Doe' }); |
||||
|
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); |
||||
|
storageManager.clear(); |
||||
|
const user1 = storageManager.getItem('user1'); |
||||
|
const user2 = storageManager.getItem('user2'); |
||||
|
expect(user1).toBeNull(); |
||||
|
expect(user2).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,118 @@ |
|||||
|
type StorageType = 'localStorage' | 'sessionStorage'; |
||||
|
|
||||
|
interface StorageManagerOptions { |
||||
|
prefix?: string; |
||||
|
storageType?: StorageType; |
||||
|
} |
||||
|
|
||||
|
interface StorageItem<T> { |
||||
|
expiry?: number; |
||||
|
value: T; |
||||
|
} |
||||
|
|
||||
|
class StorageManager<T> { |
||||
|
private prefix: string; |
||||
|
private storage: Storage; |
||||
|
|
||||
|
constructor({ |
||||
|
prefix = '', |
||||
|
storageType = 'localStorage', |
||||
|
}: StorageManagerOptions = {}) { |
||||
|
this.prefix = prefix; |
||||
|
this.storage = |
||||
|
storageType === 'localStorage' |
||||
|
? window.localStorage |
||||
|
: window.sessionStorage; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取完整的存储键 |
||||
|
* @param key 原始键 |
||||
|
* @returns 带前缀的完整键 |
||||
|
*/ |
||||
|
private getFullKey(key: string): string { |
||||
|
return `${this.prefix}-${key}`; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除所有带前缀的存储项 |
||||
|
*/ |
||||
|
clear(): void { |
||||
|
const keysToRemove: string[] = []; |
||||
|
for (let i = 0; i < this.storage.length; i++) { |
||||
|
const key = this.storage.key(i); |
||||
|
if (key && key.startsWith(this.prefix)) { |
||||
|
keysToRemove.push(key); |
||||
|
} |
||||
|
} |
||||
|
keysToRemove.forEach((key) => this.storage.removeItem(key)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除所有过期的存储项 |
||||
|
*/ |
||||
|
clearExpiredItems(): void { |
||||
|
for (let i = 0; i < this.storage.length; i++) { |
||||
|
const key = this.storage.key(i); |
||||
|
if (key && key.startsWith(this.prefix)) { |
||||
|
const shortKey = key.replace(this.prefix, ''); |
||||
|
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取存储项 |
||||
|
* @param key 键 |
||||
|
* @param defaultValue 当项不存在或已过期时返回的默认值 |
||||
|
* @returns 值,如果项已过期或解析错误则返回默认值 |
||||
|
*/ |
||||
|
getItem(key: string, defaultValue: T | null = null): T | null { |
||||
|
const fullKey = this.getFullKey(key); |
||||
|
const itemStr = this.storage.getItem(fullKey); |
||||
|
if (!itemStr) { |
||||
|
return defaultValue; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const item: StorageItem<T> = JSON.parse(itemStr); |
||||
|
if (item.expiry && Date.now() > item.expiry) { |
||||
|
this.storage.removeItem(fullKey); |
||||
|
return defaultValue; |
||||
|
} |
||||
|
return item.value; |
||||
|
} catch (error) { |
||||
|
console.error(`Error parsing item with key "${fullKey}":`, error); |
||||
|
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
|
||||
|
return defaultValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 移除存储项 |
||||
|
* @param key 键 |
||||
|
*/ |
||||
|
removeItem(key: string): void { |
||||
|
const fullKey = this.getFullKey(key); |
||||
|
this.storage.removeItem(fullKey); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置存储项 |
||||
|
* @param key 键 |
||||
|
* @param value 值 |
||||
|
* @param ttl 存活时间(毫秒) |
||||
|
*/ |
||||
|
setItem(key: string, value: T, ttl?: number): void { |
||||
|
const fullKey = this.getFullKey(key); |
||||
|
const expiry = ttl ? Date.now() + ttl : undefined; |
||||
|
const item: StorageItem<T> = { expiry, value }; |
||||
|
try { |
||||
|
this.storage.setItem(fullKey, JSON.stringify(item)); |
||||
|
} catch (error) { |
||||
|
console.error(`Error setting item with key "${fullKey}":`, error); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { StorageManager }; |
||||
@ -0,0 +1,55 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { capitalizeFirstLetter, toLowerCaseFirstLetter } from './letter'; |
||||
|
|
||||
|
// 编写测试用例
|
||||
|
describe('capitalizeFirstLetter', () => { |
||||
|
it('should capitalize the first letter of a string', () => { |
||||
|
expect(capitalizeFirstLetter('hello')).toBe('Hello'); |
||||
|
expect(capitalizeFirstLetter('world')).toBe('World'); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty strings', () => { |
||||
|
expect(capitalizeFirstLetter('')).toBe(''); |
||||
|
}); |
||||
|
|
||||
|
it('should handle single character strings', () => { |
||||
|
expect(capitalizeFirstLetter('a')).toBe('A'); |
||||
|
expect(capitalizeFirstLetter('b')).toBe('B'); |
||||
|
}); |
||||
|
|
||||
|
it('should not change the case of other characters', () => { |
||||
|
expect(capitalizeFirstLetter('hElLo')).toBe('HElLo'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('toLowerCaseFirstLetter', () => { |
||||
|
it('should convert the first letter to lowercase', () => { |
||||
|
expect(toLowerCaseFirstLetter('CommonAppName')).toBe('commonAppName'); |
||||
|
expect(toLowerCaseFirstLetter('AnotherKeyExample')).toBe( |
||||
|
'anotherKeyExample', |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('should return the same string if the first letter is already lowercase', () => { |
||||
|
expect(toLowerCaseFirstLetter('alreadyLowerCase')).toBe('alreadyLowerCase'); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty strings', () => { |
||||
|
expect(toLowerCaseFirstLetter('')).toBe(''); |
||||
|
}); |
||||
|
|
||||
|
it('should handle single character strings', () => { |
||||
|
expect(toLowerCaseFirstLetter('A')).toBe('a'); |
||||
|
expect(toLowerCaseFirstLetter('a')).toBe('a'); |
||||
|
}); |
||||
|
|
||||
|
it('should handle strings with only one uppercase letter', () => { |
||||
|
expect(toLowerCaseFirstLetter('A')).toBe('a'); |
||||
|
}); |
||||
|
|
||||
|
it('should handle strings with special characters', () => { |
||||
|
expect(toLowerCaseFirstLetter('!Special')).toBe('!Special'); |
||||
|
expect(toLowerCaseFirstLetter('123Number')).toBe('123Number'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,20 @@ |
|||||
|
/** |
||||
|
* 将字符串的首字母大写 |
||||
|
* @param string |
||||
|
*/ |
||||
|
function capitalizeFirstLetter(string: string): string { |
||||
|
return string.charAt(0).toUpperCase() + string.slice(1); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将字符串的首字母转换为小写。 |
||||
|
* |
||||
|
* @param str 要转换的字符串 |
||||
|
* @returns 首字母小写的字符串 |
||||
|
*/ |
||||
|
function toLowerCaseFirstLetter(str: string): string { |
||||
|
if (!str) return str; // 如果字符串为空,直接返回
|
||||
|
return str.charAt(0).toLowerCase() + str.slice(1); |
||||
|
} |
||||
|
|
||||
|
export { capitalizeFirstLetter, toLowerCaseFirstLetter }; |
||||
@ -0,0 +1,22 @@ |
|||||
|
type LocaleSupportType = 'en-US' | 'zh-CN'; |
||||
|
|
||||
|
type LayoutType = |
||||
|
| 'full-content' |
||||
|
| 'header-nav' |
||||
|
| 'mixed-nav' |
||||
|
| 'side-mixed-nav' |
||||
|
| 'side-nav'; |
||||
|
|
||||
|
type ThemeModeType = 'auto' | 'dark' | 'light'; |
||||
|
|
||||
|
type ContentCompactType = 'compact' | 'wide'; |
||||
|
|
||||
|
type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static'; |
||||
|
|
||||
|
export type { |
||||
|
ContentCompactType, |
||||
|
LayoutHeaderModeType, |
||||
|
LayoutType, |
||||
|
LocaleSupportType, |
||||
|
ThemeModeType, |
||||
|
}; |
||||
@ -0,0 +1,40 @@ |
|||||
|
// `Prev` 类型用于表示递归深度的递减。它是一个元组,其索引代表了递归的层数,通过索引访问可以得到减少后的层数。
|
||||
|
// 例如,Prev[3] 等于 2,表示递归深度从 3 减少到 2。
|
||||
|
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]]; |
||||
|
|
||||
|
// `FlattenDepth` 类型用于将一个嵌套的对象类型“展平”,同时考虑到了递归的深度。
|
||||
|
// 它接受三个泛型参数:T(要处理的类型),Prefix(属性名前缀,默认为空字符串),Depth(递归深度,默认为3)。
|
||||
|
// 如果当前深度(Depth)为 0,则停止递归并返回 `never`。否则,如果属性值是对象类型,则递归调用 `FlattenDepth` 并递减深度。
|
||||
|
// 对于非对象类型的属性,将其直接映射到结果类型中,并根据前缀构造属性名。
|
||||
|
|
||||
|
type FlattenDepth<T, Prefix extends string = '', Depth extends number = 4> = { |
||||
|
[K in keyof T]: T[K] extends object |
||||
|
? Depth extends 0 |
||||
|
? never |
||||
|
: FlattenDepth< |
||||
|
T[K], |
||||
|
`${Prefix}${K extends string ? (Prefix extends '' ? K : Capitalize<K>) : ''}`, |
||||
|
Prev[Depth] |
||||
|
> |
||||
|
: { |
||||
|
[P in `${Prefix}${K extends string ? (Prefix extends '' ? K : Capitalize<K>) : ''}`]: T[K]; |
||||
|
}; |
||||
|
}[keyof T] extends infer O |
||||
|
? { [P in keyof O]: O[P] } |
||||
|
: never; |
||||
|
|
||||
|
// `UnionToIntersection` 类型用于将一个联合类型转换为交叉类型。
|
||||
|
// 这个类型通过条件类型和类型推断的方式来实现。它先尝试将输入类型(U)映射为一个函数类型,
|
||||
|
// 然后通过推断这个函数类型的返回类型(infer I),最终得到一个交叉类型。
|
||||
|
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ( |
||||
|
k: infer I, |
||||
|
) => void |
||||
|
? I |
||||
|
: never; |
||||
|
|
||||
|
type Flatten<T> = UnionToIntersection<FlattenDepth<T>>; |
||||
|
|
||||
|
type FlattenObject<T> = FlattenDepth<T>; |
||||
|
type FlattenObjectKeys<T> = keyof FlattenObject<T>; |
||||
|
|
||||
|
export type { Flatten, FlattenObject, FlattenObjectKeys, UnionToIntersection }; |
||||
@ -1,5 +1,6 @@ |
|||||
export type * from './access'; |
export type * from './access'; |
||||
|
export type * from './app'; |
||||
|
export type * from './flatten'; |
||||
export type * from './menu-record'; |
export type * from './menu-record'; |
||||
export type * from './preference'; |
|
||||
export type * from './tabs'; |
export type * from './tabs'; |
||||
export type * from './tools'; |
export type * from './tools'; |
||||
|
|||||
@ -1,144 +0,0 @@ |
|||||
type LayoutType = |
|
||||
| 'full-content' |
|
||||
| 'header-nav' |
|
||||
| 'mixed-nav' |
|
||||
| 'side-mixed-nav' |
|
||||
| 'side-nav'; |
|
||||
|
|
||||
type BreadcrumbStyle = 'background' | 'normal'; |
|
||||
|
|
||||
type NavigationStyle = 'plain' | 'rounded'; |
|
||||
|
|
||||
type ThemeType = 'auto' | 'dark' | 'light'; |
|
||||
|
|
||||
type ContentCompactType = 'compact' | 'wide'; |
|
||||
|
|
||||
type LayoutHeaderMode = 'auto' | 'auto-scroll' | 'fixed' | 'static'; |
|
||||
|
|
||||
type PageTransitionType = 'fade-slide'; |
|
||||
|
|
||||
type AuthPageLayout = 'panel-center' | 'panel-left' | 'panel-right'; |
|
||||
|
|
||||
type SupportLocale = 'en-US' | 'zh-CN'; |
|
||||
|
|
||||
interface Language { |
|
||||
key: SupportLocale; |
|
||||
text: string; |
|
||||
} |
|
||||
|
|
||||
interface Preference { |
|
||||
/** 应用名 */ |
|
||||
appName: string; |
|
||||
/** 登录注册页面布局 */ |
|
||||
authPageLayout: AuthPageLayout; |
|
||||
/** 面包屑是否只有一个时隐藏 */ |
|
||||
breadcrumbHideOnlyOne: boolean; |
|
||||
/** 面包屑首页图标是否可见 */ |
|
||||
breadcrumbHome: boolean; |
|
||||
/** 面包屑图标是否可见 */ |
|
||||
breadcrumbIcon: boolean; |
|
||||
/** 面包屑类型 */ |
|
||||
breadcrumbStyle: BreadcrumbStyle; |
|
||||
/** 面包屑是否可见 */ |
|
||||
breadcrumbVisible: boolean; |
|
||||
/** 是否开启灰色模式 */ |
|
||||
colorGrayMode: boolean; |
|
||||
/** 主题色 */ |
|
||||
colorPrimary: string; |
|
||||
/** 是否开启色弱模式 */ |
|
||||
colorWeakMode: boolean; |
|
||||
/** 是否开启紧凑模式 */ |
|
||||
compact: boolean; |
|
||||
/** 是否开启内容紧凑模式 */ |
|
||||
contentCompact: ContentCompactType; |
|
||||
/** 页脚Copyright */ |
|
||||
copyright: string; |
|
||||
/** 应用默认头像 */ |
|
||||
defaultAvatar: string; |
|
||||
/** 开启动态标题 */ |
|
||||
dynamicTitle: boolean; |
|
||||
/** 页脚是否固定 */ |
|
||||
footerFixed: boolean; |
|
||||
/** 页脚是否可见 */ |
|
||||
footerVisible: boolean; |
|
||||
/** 顶栏是否隐藏 */ |
|
||||
headerHidden: boolean; |
|
||||
/** header显示模式 */ |
|
||||
headerMode: LayoutHeaderMode; |
|
||||
/** 顶栏是否可见 */ |
|
||||
headerVisible: boolean; |
|
||||
/** 是否移动端 */ |
|
||||
isMobile: boolean; |
|
||||
/** 开启标签页缓存功能 */ |
|
||||
keepAlive: boolean; |
|
||||
/** 布局方式 */ |
|
||||
layout: LayoutType; |
|
||||
/** 支持的语言 */ |
|
||||
locale: SupportLocale; |
|
||||
/** 应用Logo */ |
|
||||
logo: string; |
|
||||
/** logo是否可见 */ |
|
||||
logoVisible: boolean; |
|
||||
/** 导航菜单手风琴模式 */ |
|
||||
navigationAccordion: boolean; |
|
||||
/** 导航菜单是否切割,只在 layout=mixed-nav 生效 */ |
|
||||
navigationSplit: boolean; |
|
||||
/** 导航菜单风格 */ |
|
||||
navigationStyle: NavigationStyle; |
|
||||
/** 是否开启页面加载进度条 */ |
|
||||
pageProgress: boolean; |
|
||||
/** 页面切换动画 */ |
|
||||
pageTransition: PageTransitionType; |
|
||||
/** 页面切换动画是否启用 */ |
|
||||
pageTransitionEnable: boolean; |
|
||||
/** 是否开启半深色菜单(只在theme='light'时生效) */ |
|
||||
semiDarkMenu: boolean; |
|
||||
/** 是否启用快捷键 */ |
|
||||
shortcutKeys: boolean; |
|
||||
/** 是否显示偏好设置 */ |
|
||||
showPreference: boolean; |
|
||||
/** 侧边栏是否折叠 */ |
|
||||
sideCollapse: boolean; |
|
||||
/** 侧边栏折叠时,是否显示title */ |
|
||||
sideCollapseShowTitle: boolean; |
|
||||
/** 菜单自动展开状态 */ |
|
||||
sideExpandOnHover: boolean; |
|
||||
/** 侧边栏扩展区域是否折叠 */ |
|
||||
sideExtraCollapse: boolean; |
|
||||
/** 侧边栏是否隐藏 */ |
|
||||
sideHidden: boolean; |
|
||||
/** 侧边栏是否可见 */ |
|
||||
sideVisible: boolean; |
|
||||
/** 侧边栏宽度 */ |
|
||||
sideWidth: number; |
|
||||
/** 是否开启多标签页图标 */ |
|
||||
tabsIcon: boolean; |
|
||||
/** 是否开启多标签页 */ |
|
||||
tabsVisible: boolean; |
|
||||
/** 当前主题 */ |
|
||||
theme: ThemeType; |
|
||||
} |
|
||||
|
|
||||
// 这些属性是静态的,不会随着用户的操作而改变
|
|
||||
interface StaticPreference { |
|
||||
/** 主题色预设 */ |
|
||||
colorPrimaryPresets: string[]; |
|
||||
/** 支持的语言 */ |
|
||||
supportLanguages: Language[]; |
|
||||
} |
|
||||
|
|
||||
type PreferenceKeys = keyof Preference; |
|
||||
|
|
||||
export type { |
|
||||
AuthPageLayout, |
|
||||
BreadcrumbStyle, |
|
||||
ContentCompactType, |
|
||||
LayoutHeaderMode, |
|
||||
LayoutType, |
|
||||
PageTransitionType, |
|
||||
Preference, |
|
||||
PreferenceKeys, |
|
||||
StaticPreference, |
|
||||
SupportLocale, |
|
||||
ThemeType, |
|
||||
}; |
|
||||
@ -0,0 +1 @@ |
|||||
|
# packages |
||||
@ -1 +0,0 @@ |
|||||
export { default as PreferenceWidget } from './preference-widget.vue'; |
|
||||
@ -1,102 +0,0 @@ |
|||||
<script lang="ts" setup> |
|
||||
import type { PreferenceKeys, SupportLocale } from '@vben/types'; |
|
||||
|
|
||||
import { loadLocaleMessages } from '@vben/locales'; |
|
||||
import { |
|
||||
preference, |
|
||||
staticPreference, |
|
||||
updatePreference, |
|
||||
} from '@vben/preference'; |
|
||||
|
|
||||
import Preference from './preference.vue'; |
|
||||
|
|
||||
function handleUpdate(key: PreferenceKeys, value: boolean | string) { |
|
||||
updatePreference({ |
|
||||
[key]: value, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
function updateLocale(value: string) { |
|
||||
const locale = value as SupportLocale; |
|
||||
updatePreference({ |
|
||||
locale, |
|
||||
}); |
|
||||
// 更改预览 |
|
||||
loadLocaleMessages(locale); |
|
||||
} |
|
||||
</script> |
|
||||
<template> |
|
||||
<Preference |
|
||||
:color-primary-presets="staticPreference.colorPrimaryPresets" |
|
||||
:breadcrumb-visible="preference.breadcrumbVisible" |
|
||||
:breadcrumb-style="preference.breadcrumbStyle" |
|
||||
:color-gray-mode="preference.colorGrayMode" |
|
||||
:breadcrumb-icon="preference.breadcrumbIcon" |
|
||||
:color-primary="preference.colorPrimary" |
|
||||
:color-weak-mode="preference.colorWeakMode" |
|
||||
:content-compact="preference.contentCompact" |
|
||||
:breadcrumb-home="preference.breadcrumbHome" |
|
||||
:side-collapse="preference.sideCollapse" |
|
||||
:layout="preference.layout" |
|
||||
:semi-dark-menu="preference.semiDarkMenu" |
|
||||
:side-visible="preference.sideVisible" |
|
||||
:footer-visible="preference.footerVisible" |
|
||||
:tabs-visible="preference.tabsVisible" |
|
||||
:header-visible="preference.headerVisible" |
|
||||
:footer-fixed="preference.footerFixed" |
|
||||
:header-mode="preference.headerMode" |
|
||||
:theme="preference.theme" |
|
||||
:dynamic-title="preference.dynamicTitle" |
|
||||
:breadcrumb-hide-only-one="preference.breadcrumbHideOnlyOne" |
|
||||
:page-transition="preference.pageTransition" |
|
||||
:page-progress="preference.pageProgress" |
|
||||
:tabs-icon="preference.tabsIcon" |
|
||||
:locale="preference.locale" |
|
||||
:navigation-accordion="preference.navigationAccordion" |
|
||||
:navigation-style="preference.navigationStyle" |
|
||||
:shortcut-keys="preference.shortcutKeys" |
|
||||
:navigation-split="preference.navigationSplit" |
|
||||
:side-collapse-show-title="preference.sideCollapseShowTitle" |
|
||||
:page-transition-enable="preference.pageTransitionEnable" |
|
||||
@update:shortcut-keys="(value) => handleUpdate('shortcutKeys', value)" |
|
||||
@update:navigation-style="(value) => handleUpdate('navigationStyle', value)" |
|
||||
@update:navigation-accordion=" |
|
||||
(value) => handleUpdate('navigationAccordion', value) |
|
||||
" |
|
||||
@update:navigation-split="(value) => handleUpdate('navigationSplit', value)" |
|
||||
@update:dynamic-title="(value) => handleUpdate('dynamicTitle', value)" |
|
||||
@update:tabs-icon="(value) => handleUpdate('tabsIcon', value)" |
|
||||
@update:side-collapse="(value) => handleUpdate('sideCollapse', value)" |
|
||||
@update:locale="updateLocale" |
|
||||
@update:header-visible="(value) => handleUpdate('headerVisible', value)" |
|
||||
@update:side-visible="(value) => handleUpdate('sideVisible', value)" |
|
||||
@update:footer-visible="(value) => handleUpdate('footerVisible', value)" |
|
||||
@update:tabs-visible="(value) => handleUpdate('tabsVisible', value)" |
|
||||
@update:header-mode="(value) => handleUpdate('headerMode', value)" |
|
||||
@update:footer-fixed="(value) => handleUpdate('footerFixed', value)" |
|
||||
@update:breadcrumb-visible=" |
|
||||
(value) => handleUpdate('breadcrumbVisible', value) |
|
||||
" |
|
||||
@update:breadcrumb-hide-only-one=" |
|
||||
(value) => handleUpdate('breadcrumbHideOnlyOne', value) |
|
||||
" |
|
||||
@update:side-collapse-show-title=" |
|
||||
(value) => handleUpdate('sideCollapseShowTitle', value) |
|
||||
" |
|
||||
@update:breadcrumb-home="(value) => handleUpdate('breadcrumbHome', value)" |
|
||||
@update:breadcrumb-icon="(value) => handleUpdate('breadcrumbIcon', value)" |
|
||||
@update:breadcrumb-style="(value) => handleUpdate('breadcrumbStyle', value)" |
|
||||
@update:page-transition-enable=" |
|
||||
(value) => handleUpdate('pageTransitionEnable', value) |
|
||||
" |
|
||||
@update:color-gray-mode="(value) => handleUpdate('colorGrayMode', value)" |
|
||||
@update:page-transition="(value) => handleUpdate('pageTransition', value)" |
|
||||
@update:page-progress="(value) => handleUpdate('pageProgress', value)" |
|
||||
@update:color-primary="(value) => handleUpdate('colorPrimary', value)" |
|
||||
@update:color-weak-mode="(value) => handleUpdate('colorWeakMode', value)" |
|
||||
@update:content-compact="(value) => handleUpdate('contentCompact', value)" |
|
||||
@update:layout="(value) => handleUpdate('layout', value)" |
|
||||
@update:semi-dark-menu="(value) => handleUpdate('semiDarkMenu', value)" |
|
||||
@update:theme="(value) => handleUpdate('theme', value)" |
|
||||
/> |
|
||||
</template> |
|
||||
@ -1,16 +0,0 @@ |
|||||
import { ref } from 'vue'; |
|
||||
|
|
||||
const openPreference = ref(false); |
|
||||
|
|
||||
function useOpenPreference() { |
|
||||
function handleOpenPreference() { |
|
||||
openPreference.value = true; |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
handleOpenPreference, |
|
||||
openPreference, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
export { useOpenPreference }; |
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue