refactor: refacotr preference

This commit is contained in:
vben
2024-06-01 23:15:29 +08:00
parent f7b97e8a83
commit fed47f5e05
139 changed files with 2205 additions and 1450 deletions

View File

@@ -1,3 +1,3 @@
# @vben-core
系统一些比较基础的SDK和UI组件库请勿将任何业务逻辑和业务包放在这里
系统一些比较基础的SDK和UI组件库该目录后续可能会迁移出去或者发布到npm请勿将任何业务逻辑和业务包放在该目录

View File

@@ -0,0 +1,3 @@
# @vben-core/forward
该目录内的包可直接被app所引用

View File

@@ -1,5 +1,5 @@
{
"name": "@vben/preference",
"name": "@vben-core/preferences",
"version": "1.0.0",
"type": "module",
"license": "MIT",
@@ -7,7 +7,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/preference"
"directory": "packages/@vben-core/preferences"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
@@ -32,6 +32,8 @@
}
},
"dependencies": {
"@vben-core/cache": "workspace:*",
"@vben-core/helpers": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.10.0",

View File

@@ -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 };

View File

@@ -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',
},
];

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -2,28 +2,26 @@ import { diff } from '@vben-core/toolkit';
import { computed } from 'vue';
import {
initialPreference,
isDarkTheme,
currentPreference as preference,
} from './preference';
import { isDarkTheme, preferencesManager } from './preferences';
function usePreference() {
function usePreferences() {
const preferences = preferencesManager.getPreferences();
const flatPreferences = preferencesManager.getFlatPreferences();
const initialPreferences = preferencesManager.getInitialPreferences();
/**
* @zh_CN
*/
const diffPreference = computed(() => {
return diff(initialPreference.value, preference);
return diff(initialPreferences, preferences);
});
/**
* @zh_CN
* @param preference -
* @param preferences -
* @returns true false
*/
const isDark = computed(() => {
const theme = preference.theme;
return isDarkTheme(theme);
return isDarkTheme(flatPreferences.appThemeMode);
});
const theme = computed(() => {
@@ -34,33 +32,39 @@ function usePreference() {
* @zh_CN
*/
const layout = computed(() =>
preference.isMobile ? 'side-nav' : preference.layout,
flatPreferences.appIsMobile ? 'side-nav' : flatPreferences.appLayout,
);
/**
* @zh_CN contenttab区域
*/
const isFullContent = computed(() => preference.layout === 'full-content');
const isFullContent = computed(
() => flatPreferences.appLayout === 'full-content',
);
/**
* @zh_CN
*/
const isSideNav = computed(() => preference.layout === 'side-nav');
const isSideNav = computed(() => flatPreferences.appLayout === 'side-nav');
/**
* @zh_CN
*/
const isSideMixedNav = computed(() => preference.layout === 'side-mixed-nav');
const isSideMixedNav = computed(
() => flatPreferences.appLayout === 'side-mixed-nav',
);
/**
* @zh_CN
*/
const isHeaderNav = computed(() => preference.layout === 'header-nav');
const isHeaderNav = computed(
() => flatPreferences.appLayout === 'header-nav',
);
/**
* @zh_CN
*/
const isMixedNav = computed(() => preference.layout === 'mixed-nav');
const isMixedNav = computed(() => flatPreferences.appLayout === 'mixed-nav');
/**
* @zh_CN
@@ -74,28 +78,28 @@ function usePreference() {
* tabs可见以及开启keep-alive的情况下才开启
*/
const keepAlive = computed(
() => preference.keepAlive && preference.tabsVisible,
() => flatPreferences.tabbarKeepAlive && flatPreferences.tabbarEnable,
);
/**
* @zh_CN
*/
const authPanelLeft = computed(() => {
return preference.authPageLayout === 'panel-left';
return flatPreferences.appAuthPageLayout === 'panel-left';
});
/**
* @zh_CN
*/
const authPanelRight = computed(() => {
return preference.authPageLayout === 'panel-right';
return flatPreferences.appAuthPageLayout === 'panel-right';
});
/**
* @zh_CN
*/
const authPanelCenter = computed(() => {
return preference.authPageLayout === 'panel-center';
return flatPreferences.appAuthPageLayout === 'panel-center';
});
return {
@@ -116,4 +120,4 @@ function usePreference() {
};
}
export { usePreference };
export { usePreferences };

View File

@@ -1,5 +1,5 @@
{
"name": "@vben/stores",
"name": "@vben-core/stores",
"version": "1.0.0",
"type": "module",
"license": "MIT",
@@ -7,7 +7,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/stores"
"directory": "packages/@vben-core/stores"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {

View File

@@ -11,10 +11,10 @@ import {
describe('useAccessStore', () => {
it('app Name with test', () => {
setActivePinia(createPinia());
// let referenceStore = usePreferenceStore();
// let referenceStore = usePreferencesStore();
// beforeEach(() => {
// referenceStore = usePreferenceStore();
// referenceStore = usePreferencesStore();
// });
// expect(referenceStore.appName).toBe('vben-admin');

View File

@@ -1,10 +1,8 @@
import type { App } from 'vue';
import { createPinia } from 'pinia';
interface SetupStoreOptions {
interface InitStoreOptions {
/**
* @zh_CN , @vben/stores appapp缓存冲突
* @zh_CN , @vben-core/stores appapp缓存冲突
*
*/
namespace: string;
@@ -12,20 +10,21 @@ interface SetupStoreOptions {
/**
* @zh_CN pinia
* @param app vue app
*/
async function setupStore(app: App, options: SetupStoreOptions) {
async function initStore(options: InitStoreOptions) {
const { createPersistedState } = await import('pinia-plugin-persistedstate');
const pinia = createPinia();
const { namespace } = options;
pinia.use(
createPersistedState({
// key $appName-$store.id
key: (storeKey) => `__${namespace}-${storeKey}__`,
key: (storeKey) => `${namespace}-${storeKey}`,
storage: localStorage,
}),
);
app.use(pinia);
return pinia;
}
export { setupStore };
export { initStore };
export type { InitStoreOptions };

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -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:*"
}
}

View File

@@ -0,0 +1 @@
export * from './object';

View File

@@ -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);
});
});

View File

@@ -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>;

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"]
}

View File

@@ -1 +1 @@
export * from './storage-cache';
export * from './storage-manager';

View File

@@ -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');
});
});

View File

@@ -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 };

View File

@@ -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();
});
});

View File

@@ -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 };

View File

@@ -18,7 +18,7 @@
}
.outline-box {
@apply outline-border relative cursor-pointer rounded-md p-1 outline outline-1;
@apply outline-border relative cursor-pointer rounded-md p-1 outline outline-1;
&::after {
@apply absolute left-1/2 top-1/2 z-20 h-0 w-[1px] rounded-sm opacity-0 outline outline-2 outline-transparent transition-all duration-300 content-[''];

View File

@@ -3,6 +3,7 @@ export * from './date';
export * from './diff';
export * from './hash';
export * from './inference';
export * from './letter';
export * from './merge';
export * from './namespace';
export * from './nprogress';

View File

@@ -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');
});
});

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -1,5 +1,6 @@
export type * from './access';
export type * from './app';
export type * from './flatten';
export type * from './menu-record';
export type * from './preference';
export type * from './tabs';
export type * from './tools';

View File

@@ -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,
};

View File

@@ -1,8 +1,8 @@
import type {
ContentCompactType,
LayoutHeaderMode,
LayoutHeaderModeType,
LayoutType,
ThemeType,
ThemeModeType,
} from '@vben-core/typings';
interface VbenLayoutProps {
@@ -86,7 +86,7 @@ interface VbenLayoutProps {
* header 显示模式
* @default 'fixed'
*/
headerMode?: LayoutHeaderMode;
headerMode?: LayoutHeaderModeType;
/**
* header是否显示
* @default true
@@ -146,7 +146,7 @@ interface VbenLayoutProps {
* 侧边栏
* @default dark
*/
sideTheme?: ThemeType;
sideTheme?: ThemeModeType;
/**
* 侧边栏是否可见
* @default true

View File

@@ -460,7 +460,7 @@ function handleOpenMenu() {
<template>
<div class="relative flex min-h-full w-full">
<slot name="preference"></slot>
<slot name="preferences"></slot>
<slot name="floating-button-group"></slot>
<LayoutSide
v-if="sideVisibleState"

View File

@@ -1,4 +1,4 @@
import type { MenuRecordBadgeRaw, ThemeType } from '@vben-core/typings';
import type { MenuRecordBadgeRaw, ThemeModeType } from '@vben-core/typings';
import type { Ref } from 'vue';
@@ -46,7 +46,7 @@ interface MenuProps {
* @zh_CN 菜单主题
* @default dark
*/
theme?: ThemeType;
theme?: ThemeModeType;
}
interface SubMenuProps extends MenuRecordBadgeRaw {

View File

@@ -16,7 +16,7 @@ const props = withDefaults(
<Primitive
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot></slot>
</Primitive>

View File

@@ -37,7 +37,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 w-72 rounded-md border p-4 shadow-md outline-none',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 w-72 rounded-md border p-4 shadow-md outline-none',
props.class,
)
"

1
packages/README.md Normal file
View File

@@ -0,0 +1 @@
# packages

View File

@@ -46,10 +46,10 @@
"dependencies": {
"@vben-core/design": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/preferences": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/preference": "workspace:*",
"@vueuse/core": "^10.10.0",
"@vueuse/integrations": "^10.10.0",
"qrcode": "^1.5.3",

View File

@@ -1,20 +1,21 @@
<script setup lang="ts">
import { IcRoundColorLens } from '@vben-core/iconify';
import { VbenIconButton } from '@vben-core/shadcn-ui';
import {
preference,
staticPreference,
updatePreference,
} from '@vben/preference';
COLOR_PRIMARY_RESETS,
preferences,
updatePreferences,
} from '@vben-core/preferences';
import { VbenIconButton } from '@vben-core/shadcn-ui';
defineOptions({
name: 'AuthenticationColorToggle',
});
function handleUpdate(value: string) {
updatePreference({
colorPrimary: value,
updatePreferences({
theme: {
colorPrimary: value,
},
});
}
</script>
@@ -24,10 +25,7 @@ function handleUpdate(value: string) {
<div
class="ease-ou flex w-0 overflow-hidden transition-all duration-500 group-hover:w-48"
>
<template
v-for="color in staticPreference.colorPrimaryPresets"
:key="color"
>
<template v-for="color in COLOR_PRIMARY_RESETS" :key="color">
<VbenIconButton
class="flex-center flex-shrink-0"
@click="handleUpdate(color)"
@@ -35,7 +33,9 @@ function handleUpdate(value: string) {
<div
class="relative h-3.5 w-3.5 rounded-[2px] before:absolute before:left-0.5 before:top-0.5 before:h-2.5 before:w-2.5 before:rounded-[2px] before:border before:border-gray-900 before:opacity-0 before:transition-all before:duration-150 before:content-[''] hover:scale-110"
:class="[
preference.colorPrimary === color ? `before:opacity-100` : '',
preferences.theme.colorPrimary === color
? `before:opacity-100`
: '',
]"
:style="{ backgroundColor: color }"
></div>

View File

@@ -1,17 +1,15 @@
<script setup lang="ts">
import type { AuthPageLayout } from '@vben/types';
import type { VbenDropdownMenuItem } from '@vben-core/shadcn-ui';
import { MdiDockBottom, MdiDockLeft, MdiDockRight } from '@vben-core/iconify';
import { preferences, usePreferences } from '@vben-core/preferences';
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
import { $t } from '@vben/locales';
import { preference, updatePreference, usePreference } from '@vben/preference';
import { computed } from 'vue';
defineOptions({
name: 'AuthenticationLayoutToggle',
// inheritAttrs: false,
});
const menus = computed((): VbenDropdownMenuItem[] => [
@@ -32,20 +30,13 @@ const menus = computed((): VbenDropdownMenuItem[] => [
},
]);
function handleUpdate(value: string) {
updatePreference({
authPageLayout: value as AuthPageLayout,
});
}
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreference();
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
</script>
<template>
<VbenDropdownRadioMenu
v-model="preferences.app.authPageLayout"
:menus="menus"
:model-value="preference.authPageLayout"
@update:model-value="handleUpdate"
>
<VbenIconButton>
<MdiDockRight v-if="authPanelRight" class="size-5" />

View File

@@ -4,7 +4,7 @@ export * from './global-provider';
export * from './global-search';
export * from './language-toggle';
export * from './notification';
export * from './preference';
export * from './preferences';
export * from './spinner';
export * from './theme-toggle';
export * from './user-dropdown';

View File

@@ -1,26 +1,28 @@
<script setup lang="ts">
import type { SupportLocale } from '@vben/types';
import type { LocaleSupportType } from '@vben/types';
import { IcBaselineLanguage } from '@vben-core/iconify';
import {
SUPPORT_LANGUAGES,
preferences,
updatePreferences,
} from '@vben-core/preferences';
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
import { loadLocaleMessages } from '@vben/locales';
import {
preference,
staticPreference,
updatePreference,
} from '@vben/preference';
defineOptions({
name: 'LanguageToggle',
});
const menus = staticPreference.supportLanguages;
const menus = SUPPORT_LANGUAGES;
async function handleUpdate(value: string) {
const locale = value as SupportLocale;
updatePreference({
locale,
const locale = value as LocaleSupportType;
updatePreferences({
app: {
locale,
},
});
// 更改预览
await loadLocaleMessages(locale);
@@ -31,7 +33,7 @@ async function handleUpdate(value: string) {
<div>
<VbenDropdownRadioMenu
:menus="menus"
:model-value="preference.locale"
:model-value="preferences.app.locale"
@update:model-value="handleUpdate"
>
<VbenIconButton>

View File

@@ -1 +0,0 @@
export { default as PreferenceWidget } from './preference-widget.vue';

View File

@@ -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>

View File

@@ -1,16 +0,0 @@
import { ref } from 'vue';
const openPreference = ref(false);
function useOpenPreference() {
function handleOpenPreference() {
openPreference.value = true;
}
return {
handleOpenPreference,
openPreference,
};
}
export { useOpenPreference };

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { SelectListItem } from '@vben/types';
import { SUPPORT_LANGUAGES } from '@vben-core/preferences';
import { $t } from '@vben/locales';
import { staticPreference } from '@vben/preference';
import SelectItem from '../select-item.vue';
import SwitchItem from '../switch-item.vue';
@@ -15,12 +16,10 @@ const locale = defineModel<string>('locale');
const dynamicTitle = defineModel<boolean>('dynamicTitle');
const shortcutKeys = defineModel<boolean>('shortcutKeys');
const localeItems: SelectListItem[] = staticPreference.supportLanguages.map(
(item) => ({
label: item.text,
value: item.key,
}),
);
const localeItems: SelectListItem[] = SUPPORT_LANGUAGES.map((item) => ({
label: item.text,
value: item.key,
}));
</script>
<template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LayoutHeaderMode, SelectListItem } from '@vben/types';
import type { LayoutHeaderModeType, SelectListItem } from '@vben/types';
import { $t } from '@vben/locales';
@@ -13,7 +13,7 @@ defineOptions({
defineProps<{ disabled: boolean }>();
const headerVisible = defineModel<boolean>('headerVisible');
const headerMode = defineModel<LayoutHeaderMode>('headerMode');
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
const localeItems: SelectListItem[] = [
{

View File

@@ -0,0 +1 @@
export { default as PreferencesWidget } from './preferences-widget.vue';

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { LocaleSupportType } from '@vben/types';
import {
COLOR_PRIMARY_RESETS,
flatPreferences,
updatePreferences,
} from '@vben-core/preferences';
import { loadLocaleMessages } from '@vben/locales';
import Preferences from './preferences.vue';
function updateLocale(value: string) {
const locale = value as LocaleSupportType;
updatePreferences({
app: { locale },
});
// 更改预览
loadLocaleMessages(locale);
}
</script>
<template>
<Preferences
v-model:breadcrumb-visible="flatPreferences.breadcrumbEnable"
v-model:breadcrumb-style="flatPreferences.breadcrumbStyleType"
v-model:color-gray-mode="flatPreferences.appColorGrayMode"
v-model:breadcrumb-icon="flatPreferences.breadcrumbShowIcon"
v-model:color-primary="flatPreferences.themeColorPrimary"
v-model:color-weak-mode="flatPreferences.appColorWeakMode"
v-model:content-compact="flatPreferences.appContentCompact"
v-model:breadcrumb-home="flatPreferences.breadcrumbShowHome"
v-model:side-collapse="flatPreferences.sidebarCollapse"
v-model:layout="flatPreferences.appLayout"
v-model:semi-dark-menu="flatPreferences.appSemiDarkMenu"
v-model:side-visible="flatPreferences.sidebarEnable"
v-model:footer-visible="flatPreferences.footerEnable"
v-model:tabs-visible="flatPreferences.tabbarEnable"
v-model:header-visible="flatPreferences.headerEnable"
v-model:header-mode="flatPreferences.headerMode"
v-model:footer-fixed="flatPreferences.footerFixed"
v-model:theme="flatPreferences.appThemeMode"
v-model:dynamic-title="flatPreferences.appDynamicTitle"
v-model:breadcrumb-hide-only-one="flatPreferences.breadcrumbHideOnlyOne"
v-model:page-transition="flatPreferences.transitionName"
v-model:page-progress="flatPreferences.transitionProgress"
v-model:tabs-icon="flatPreferences.tabbarShowIcon"
v-model:navigation-accordion="flatPreferences.navigationAccordion"
v-model:navigation-style="flatPreferences.navigationStyleType"
v-model:shortcut-keys="flatPreferences.shortcutKeysEnable"
v-model:navigation-split="flatPreferences.navigationSplit"
v-model:page-transition-enable="flatPreferences.transitionEnable"
v-model:side-collapse-show-title="flatPreferences.sidebarCollapseShowTitle"
:color-primary-presets="COLOR_PRIMARY_RESETS"
:locale="flatPreferences.appLocale"
@update:locale="updateLocale"
/>
</template>

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import type { LayoutHeaderMode, LayoutType } from '@vben/types';
import type { LayoutHeaderModeType, LayoutType } from '@vben/types';
import type { SegmentedItem } from '@vben-core/shadcn-ui';
import { IcRoundFolderCopy, IcRoundRestartAlt } from '@vben-core/iconify';
import {
preferences,
resetPreferences,
usePreferences,
} from '@vben-core/preferences';
import {
VbenButton,
VbenIconButton,
@@ -12,7 +17,6 @@ import {
} from '@vben-core/shadcn-ui';
import { $t } from '@vben/locales';
import { preference, resetPreference, usePreference } from '@vben/preference';
import { useClipboard } from '@vueuse/core';
import { computed } from 'vue';
@@ -33,7 +37,7 @@ import {
ThemeColor,
} from './blocks';
import Trigger from './trigger.vue';
import { useOpenPreference } from './use-open-preference';
import { useOpenPreferences } from './use-open-preferences';
withDefaults(defineProps<{ colorPrimaryPresets: string[] }>(), {
colorPrimaryPresets: () => [],
@@ -67,7 +71,7 @@ const tabsVisible = defineModel<boolean>('tabsVisible');
const tabsIcon = defineModel<boolean>('tabsIcon');
// const logoVisible = defineModel<boolean>('logoVisible');
const headerVisible = defineModel<boolean>('headerVisible');
const headerMode = defineModel<LayoutHeaderMode>('headerMode');
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
const footerVisible = defineModel<boolean>('footerVisible');
const footerFixed = defineModel<boolean>('footerFixed');
@@ -79,7 +83,7 @@ const {
isSideMixedNav,
isSideMode,
isSideNav,
} = usePreference();
} = usePreferences();
const { copy } = useClipboard();
const tabs = computed((): SegmentedItem[] => {
@@ -108,11 +112,11 @@ const showBreadcrumbConfig = computed(() => {
!isFullContent.value &&
!isMixedNav.value &&
!isHeaderNav.value &&
preference.headerVisible
preferences.header.enable
);
});
const { openPreference } = useOpenPreference();
const { openPreferences } = useOpenPreferences();
async function handleCopy() {
await copy(JSON.stringify(diffPreference.value, null, 2));
@@ -124,7 +128,7 @@ function handleReset() {
if (!diffPreference.value) {
return;
}
resetPreference();
resetPreferences();
toast($t('preference.reset-success'));
}
</script>
@@ -132,7 +136,7 @@ function handleReset() {
<template>
<div class="z-100 fixed right-0 top-1/3">
<VbenSheet
v-model:open="openPreference"
v-model:open="openPreferences"
:description="$t('preference.preferences-subtitle')"
:title="$t('preference.preferences')"
>

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue';
const openPreferences = ref(false);
function useOpenPreferences() {
function handleOpenPreference() {
openPreferences.value = true;
}
return {
handleOpenPreference,
openPreferences,
};
}
export { useOpenPreferences };

View File

@@ -4,6 +4,11 @@ import {
IcRoundWbSunny,
MdiMoonAndStars,
} from '@vben-core/iconify';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben-core/preferences';
import {
ToggleGroup,
ToggleGroupItem,
@@ -11,7 +16,6 @@ import {
} from '@vben-core/shadcn-ui';
import { $t } from '@vben/locales';
import { preference, updatePreference, usePreference } from '@vben/preference';
import ThemeButton from './theme-button.vue';
@@ -24,10 +28,12 @@ withDefaults(defineProps<{ shouldOnHover?: boolean }>(), {
});
function handleChange(isDark: boolean) {
updatePreference({ theme: isDark ? 'dark' : 'light' });
updatePreferences({
app: { themeMode: isDark ? 'dark' : 'light' },
});
}
const { isDark } = usePreference();
const { isDark } = usePreferences();
const PRESETS = [
{
@@ -58,7 +64,7 @@ const PRESETS = [
/>
</template>
<ToggleGroup
:model-value="preference.theme"
:model-value="preferences.app.themeMode"
type="single"
variant="outline"
class="gap-2"

View File

@@ -2,6 +2,7 @@
import type { AnyFunction } from '@vben/types';
import { IcRoundLogout, IcRoundSettingsSuggest } from '@vben-core/iconify';
import { preferences } from '@vben-core/preferences';
import {
Badge,
DropdownMenu,
@@ -20,11 +21,10 @@ import { isWindowsOs } from '@vben-core/toolkit';
import type { Component } from 'vue';
import { $t } from '@vben/locales';
import { preference } from '@vben/preference';
import { useMagicKeys, whenever } from '@vueuse/core';
import { computed, ref } from 'vue';
import { useOpenPreference } from '../preference/use-open-preference';
import { useOpenPreferences } from '../preferences/use-open-preferences';
interface Props {
/**
@@ -72,12 +72,12 @@ const emit = defineEmits<{ logout: [] }>();
const openPopover = ref(false);
const openDialog = ref(false);
const { handleOpenPreference } = useOpenPreference();
const { handleOpenPreference } = useOpenPreferences();
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
const shortcutKeys = computed(() => {
return props.enableShortcutKey && preference.shortcutKeys;
return props.enableShortcutKey && preferences.shortcutKeys.enable;
});
function handleLogout() {
@@ -161,7 +161,7 @@ if (shortcutKeys.value) {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="preference"
v-if="preferences.shortcutKeys.enable"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenPreference"
>

View File

@@ -44,13 +44,13 @@
"@vben-core/iconify": "workspace:*",
"@vben-core/layout-ui": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/preferences": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/stores": "workspace:*",
"@vben-core/tabs-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/preference": "workspace:*",
"@vben/stores": "workspace:*",
"vue": "3.4.27",
"vue-router": "^4.3.2"
},

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { preferences, usePreferences } from '@vben-core/preferences';
import { $t } from '@vben/locales';
import { preference, usePreference } from '@vben/preference';
import { computed } from 'vue';
import AuthenticationFromView from './from-view.vue';
import SloganIcon from './icons/slogan.vue';
@@ -10,7 +12,8 @@ defineOptions({
name: 'Authentication',
});
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreference();
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
const appName = computed(() => preferences.app.name);
</script>
<template>
@@ -33,13 +36,13 @@ const { authPanelCenter, authPanelLeft, authPanelRight } = usePreference();
"
>
<img
:alt="preference.appName"
:src="preference.logo"
:alt="appName"
:src="preferences.logo.source"
:width="42"
class="mr-2"
/>
<p class="text-xl font-medium">
{{ preference.appName }}
{{ appName }}
</p>
</div>
</div>
@@ -48,10 +51,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight } = usePreference();
class="absolute inset-0 h-full w-full bg-[var(--color-authentication)]"
>
<div class="flex-col-center mr-20 h-full">
<SloganIcon
:alt="preference.appName"
class="animate-float h-64 w-2/5"
/>
<SloganIcon :alt="appName" class="animate-float h-64 w-2/5" />
<div class="text-1xl mt-6 font-sans text-white lg:text-2xl">
{{ $t('authentication.layout-title') }}
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { preference } from '@vben/preference';
import { preferences } from '@vben-core/preferences';
import Toolbar from './toolbar.vue';
@@ -28,7 +28,7 @@ defineOptions({
<div
class="text-muted-foreground absolute bottom-3 flex text-center text-xs"
>
{{ preference.copyright }}
{{ preferences.app.copyright }}
</div>
</div>
</template>

View File

@@ -1 +1 @@
export { default as AuthPageLayout } from './authentication.vue';
export { default as AuthPageLayoutType } from './authentication.vue';

View File

@@ -1,14 +1,13 @@
<script lang="ts" setup>
import { preferences, usePreferences } from '@vben-core/preferences';
import { storeToRefs, useTabsStore } from '@vben-core/stores';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { preference, usePreference } from '@vben/preference';
import { storeToRefs, useTabsStore } from '@vben/stores';
import { IFrameRouterView } from '../../iframe';
defineOptions({ name: 'LayoutContent' });
const { keepAlive } = usePreference();
const { keepAlive } = usePreferences();
const tabsStore = useTabsStore();
const { getCacheTabs, getExcludeTabs, renderRouteView } =
@@ -17,15 +16,15 @@ const { getCacheTabs, getExcludeTabs, renderRouteView } =
// 页面切换动画
function getTransitionName(route: RouteLocationNormalizedLoaded) {
// 如果偏好设置未设置,则不使用动画
const { keepAlive, pageTransition, pageTransitionEnable, tabsVisible } =
preference;
if (!pageTransition || !pageTransitionEnable) {
const { tabbar, transition } = preferences;
const transitionName = transition.name;
if (!transitionName || !transition.enable) {
return;
}
// 标签页未启用或者未开启缓存,则使用全局配置动画
if (!tabsVisible || !keepAlive) {
return pageTransition;
if (!tabbar.enable || !keepAlive) {
return transitionName;
}
// 如果页面已经加载过,则不使用动画
@@ -34,7 +33,7 @@ function getTransitionName(route: RouteLocationNormalizedLoaded) {
}
// 已经打开且已经加载过的页面不使用动画
const inTabs = getCacheTabs.value.includes(route.name as string);
return inTabs && route.meta.loaded ? undefined : pageTransition;
return inTabs && route.meta.loaded ? undefined : transitionName;
}
</script>

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup>
import { preferences } from '@vben-core/preferences';
import { VbenFullScreen } from '@vben-core/shadcn-ui';
import { useAccessStore } from '@vben-core/stores';
import { GlobalSearch, LanguageToggle, ThemeToggle } from '@vben/common-ui';
import { preference } from '@vben/preference';
import { useAccessStore } from '@vben/stores';
interface Props {
/**
@@ -33,7 +33,7 @@ const accessStore = useAccessStore();
<div class="flex h-full min-w-0 flex-shrink-0 items-center">
<GlobalSearch
class="mr-4"
:enable-shortcut-key="preference.shortcutKeys"
:enable-shortcut-key="preferences.shortcutKeys.enable"
:menus="accessStore.getAccessMenus"
/>
<ThemeToggle class="mr-2" />

View File

@@ -1,5 +1,11 @@
<script lang="ts" setup>
import { VbenAdminLayout } from '@vben-core/layout-ui';
import {
flatPreferences,
preferences,
updatePreferences,
usePreferences,
} from '@vben-core/preferences';
import {
VbenBackTop,
// VbenFloatingButtonGroup,
@@ -8,9 +14,8 @@ import {
import { mapTree } from '@vben-core/toolkit';
import { MenuRecordRaw } from '@vben-core/typings';
import { PreferenceWidget } from '@vben/common-ui';
import { PreferencesWidget } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { preference, updatePreference, usePreference } from '@vben/preference';
import { computed } from 'vue';
import { LayoutContent } from './content';
@@ -29,27 +34,24 @@ import { Breadcrumb } from './widgets';
defineOptions({ name: 'BasicLayout' });
const { isDark, isHeaderNav, isMixedNav, isSideMixedNav, layout } =
usePreference();
usePreferences();
const headerMenuTheme = computed(() => {
return isDark.value ? 'dark' : 'light';
});
const theme = computed(() => {
const dark = isDark.value || preference.semiDarkMenu;
const dark = isDark.value || preferences.app.semiDarkMenu;
return dark ? 'dark' : 'light';
});
const logoClass = computed(() => {
return preference.sideCollapseShowTitle &&
preference.sideCollapse &&
!isMixedNav.value
? 'mx-auto'
: '';
const { collapse, collapseShowTitle } = preferences.sidebar;
return collapseShowTitle && collapse && !isMixedNav.value ? 'mx-auto' : '';
});
const isMenuRounded = computed(() => {
return preference.navigationStyle === 'rounded';
return preferences.navigation.styleType === 'rounded';
});
const logoCollapse = computed(() => {
@@ -57,12 +59,12 @@ const logoCollapse = computed(() => {
return false;
}
const { isMobile, sideCollapse } = preference;
const { appIsMobile, sidebarCollapse } = flatPreferences;
if (!sideCollapse && isMobile) {
if (!sidebarCollapse && appIsMobile) {
return false;
}
return sideCollapse || isSideMixedNav.value;
return sidebarCollapse || isSideMixedNav.value;
});
const showHeaderNav = computed(() => {
@@ -101,40 +103,36 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<template>
<VbenAdminLayout
v-model:side-extra-visible="extraVisible"
:side-collapse-show-title="preference.sideCollapseShowTitle"
:side-collapse="preference.sideCollapse"
:side-extra-collapse="preference.sideExtraCollapse"
:content-compact="preference.contentCompact"
:is-mobile="preference.isMobile"
v-model:side-collapse="flatPreferences.sidebarCollapse"
v-model:side-expand-on-hover="flatPreferences.sidebarExpandOnHover"
v-model:side-extra-collapse="flatPreferences.sidebarExtraCollapse"
:side-collapse-show-title="preferences.sidebar.collapseShowTitle"
:content-compact="preferences.app.contentCompact"
:is-mobile="preferences.app.isMobile"
:layout="layout"
:header-mode="preference.headerMode"
:footer-fixed="preference.footerFixed"
:side-semi-dark="preference.semiDarkMenu"
:header-mode="preferences.header.mode"
:footer-fixed="preferences.footer.fixed"
:side-semi-dark="preferences.app.semiDarkMenu"
:side-theme="theme"
:side-hidden="preference.sideHidden"
:side-hidden="preferences.sidebar.hidden"
:side-visible="sideVisible"
:footer-visible="preference.footerVisible"
:header-visible="preference.headerVisible"
:header-hidden="preference.headerHidden"
:side-width="preference.sideWidth"
:tabs-visible="preference.tabsVisible"
:side-expand-on-hover="preference.sideExpandOnHover"
:footer-visible="preferences.footer.enable"
:header-visible="preferences.header.enable"
:header-hidden="preferences.header.hidden"
:side-width="preferences.sidebar.width"
:tabs-visible="preferences.tabbar.enable"
@side-mouse-leave="handleSideMouseLeave"
@update:side-collapse="
(value: boolean) => updatePreference('sideCollapse', value)
"
@update:side-extra-collapse="
(value: boolean) => updatePreference('sideExtraCollapse', value)
"
@update:side-visible="
(value: boolean) => updatePreference('sideVisible', value)
"
@update:side-expand-on-hover="
(value: boolean) => updatePreference('sideExpandOnHover', value)
(value: boolean) =>
updatePreferences({
sidebar: {
enable: value,
},
})
"
>
<template v-if="preference.showPreference" #preference>
<PreferenceWidget />
<template v-if="preferences.app.showPreference" #preferences>
<PreferencesWidget />
</template>
<template #floating-button-group>
@@ -146,10 +144,10 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<template #logo>
<VbenLogo
:collapse="logoCollapse"
:src="preference.logo"
:text="preference.appName"
:src="preferences.logo.source"
:text="preferences.app.name"
:theme="showHeaderNav ? headerMenuTheme : theme"
:alt="preference.appName"
:alt="preferences.app.name"
:class="logoClass"
/>
</template>
@@ -157,14 +155,14 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<template #header>
<LayoutHeader :theme="theme">
<template
v-if="!showHeaderNav && preference.breadcrumbVisible"
v-if="!showHeaderNav && preferences.breadcrumb.enable"
#breadcrumb
>
<Breadcrumb
:hide-when-only-one="preference.breadcrumbHideOnlyOne"
:type="preference.breadcrumbStyle"
:show-icon="preference.breadcrumbIcon"
:show-home="preference.breadcrumbHome"
:hide-when-only-one="preferences.breadcrumb.hideOnlyOne"
:type="preferences.breadcrumb.styleType"
:show-icon="preferences.breadcrumb.showIcon"
:show-home="preferences.breadcrumb.showHome"
/>
</template>
<template v-if="showHeaderNav" #menu>
@@ -190,10 +188,10 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<template #menu>
<LayoutMenu
mode="vertical"
:accordion="preference.navigationAccordion"
:accordion="preferences.navigation.accordion"
:rounded="isMenuRounded"
:collapse-show-title="preference.sideCollapseShowTitle"
:collapse="preference.sideCollapse"
:collapse-show-title="preferences.sidebar.collapseShowTitle"
:collapse="preferences.sidebar.collapse"
:theme="theme"
:menus="wrapperMenus(sideMenus)"
:default-active="sideActive"
@@ -203,7 +201,7 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<template #mixed-menu>
<LayoutMixedMenu
:rounded="isMenuRounded"
:collapse="!preference.sideCollapseShowTitle"
:collapse="!preferences.sidebar.collapseShowTitle"
:active-path="extraActiveMenu"
:theme="theme"
@select="handleMixedMenuSelect"
@@ -214,30 +212,30 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<!-- 侧边额外区域 -->
<template #side-extra>
<LayoutExtraMenu
:accordion="preference.navigationAccordion"
:accordion="preferences.navigation.accordion"
:rounded="isMenuRounded"
:menus="wrapperMenus(extraMenus)"
:collapse="preference.sideExtraCollapse"
:collapse="preferences.sidebar.extraCollapse"
:theme="theme"
/>
</template>
<template #side-extra-title>
<VbenLogo
v-if="preference.logoVisible"
:text="preference.appName"
v-if="preferences.logo.enable"
:text="preferences.app.name"
:theme="theme"
:alt="preference.appName"
:alt="preferences.app.name"
/>
</template>
<template #tabs>
<LayoutTabs
v-if="preference.tabsVisible"
:show-icon="preference.tabsIcon"
v-if="preferences.tabbar.enable"
:show-icon="preferences.tabbar.showIcon"
/>
</template>
<template #tabs-toolbar>
<LayoutTabsToolbar v-if="preference.tabsVisible" />
<LayoutTabsToolbar v-if="preferences.tabbar.enable" />
</template>
<!-- 主体内容 -->
@@ -245,9 +243,9 @@ function wrapperMenus(menus: MenuRecordRaw[]) {
<LayoutContent />
</template>
<!-- 页脚 -->
<template v-if="preference.footerVisible" #footer>
<LayoutFooter v-if="preference.copyright">
{{ preference.copyright }}
<template v-if="preferences.footer.enable" #footer>
<LayoutFooter v-if="preferences.app.copyright">
{{ preferences.app.copyright }}
</LayoutFooter>
</template>
</VbenAdminLayout>

View File

@@ -3,8 +3,8 @@ import type { NormalMenuProps } from '@vben-core/menu-ui';
import type { MenuRecordRaw } from '@vben-core/typings';
import { NormalMenu } from '@vben-core/menu-ui';
import { useAccessStore } from '@vben-core/stores';
import { useAccessStore } from '@vben/stores';
import { computed, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';

View File

@@ -1,7 +1,8 @@
import type { MenuRecordRaw } from '@vben-core/typings';
import { preference } from '@vben/preference';
import { useAccessStore } from '@vben/stores';
import { preferences } from '@vben-core/preferences';
import { useAccessStore } from '@vben-core/stores';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
@@ -46,7 +47,7 @@ function useExtraMenu() {
extraMenus.value = rootMenu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
if (preference.sideExpandOnHover) {
if (preferences.sidebar.expandOnHover) {
extraVisible.value = extraMenus.value.length > 0;
}
};
@@ -55,7 +56,7 @@ function useExtraMenu() {
* 侧边菜单鼠标移出事件
*/
const handleSideMouseLeave = () => {
if (preference.sideExpandOnHover) {
if (preferences.sidebar.expandOnHover) {
return;
}
extraVisible.value = false;
@@ -69,7 +70,7 @@ function useExtraMenu() {
};
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
if (!preference.sideExpandOnHover) {
if (!preferences.sidebar.expandOnHover) {
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
extraMenus.value = findMenu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;

View File

@@ -1,7 +1,8 @@
import type { MenuRecordRaw } from '@vben-core/typings';
import { preference, usePreference } from '@vben/preference';
import { useAccessStore } from '@vben/stores';
import { preferences, usePreferences } from '@vben-core/preferences';
import { useAccessStore } from '@vben-core/stores';
import { computed, onBeforeMount, ref } from 'vue';
import { useRoute } from 'vue-router';
@@ -15,17 +16,18 @@ function useMixedMenu() {
const splitSideMenus = ref<MenuRecordRaw[]>([]);
const rootMenuPath = ref<string>('');
const { isMixedNav } = usePreference();
const { isMixedNav } = usePreferences();
const needSplit = computed(
() => preference.navigationSplit && isMixedNav.value,
() => preferences.navigation.split && isMixedNav.value,
);
const sideVisible = computed(() => {
const enableSidebar = preferences.sidebar.enable;
if (needSplit.value) {
return preference.sideVisible && splitSideMenus.value.length > 0;
return enableSidebar && splitSideMenus.value.length > 0;
}
return preference.sideVisible;
return enableSidebar;
});
const menus = computed(() => accessStore.getAccessMenus);

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { preferences, updatePreferences } from '@vben-core/preferences';
import { TabsMore, TabsScreen } from '@vben-core/tabs-ui';
import { preference, updatePreference } from '@vben/preference';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
@@ -16,9 +16,13 @@ const menus = computed(() => {
});
function handleScreenChange(screen: boolean) {
updatePreference({
headerHidden: !!screen,
sideHidden: !!screen,
updatePreferences({
header: {
hidden: !!screen,
},
sidebar: {
hidden: !!screen,
},
});
}
</script>
@@ -27,7 +31,7 @@ function handleScreenChange(screen: boolean) {
<div class="flex-center h-full">
<TabsMore :menus="menus" />
<TabsScreen
:screen="preference.sideHidden && preference.sideHidden"
:screen="preferences.sidebar.hidden"
@change="handleScreenChange"
@update:screen="handleScreenChange"
/>

Some files were not shown because too many files have changed in this diff Show More