This commit is contained in:
xingyu4j
2025-12-26 14:00:55 +08:00
26 changed files with 447 additions and 49 deletions

View File

@@ -14,8 +14,9 @@
}
html {
@apply text-foreground bg-background font-sans text-[100%];
@apply text-foreground bg-background font-sans;
font-size: var(--font-size-base, 16px);
font-variation-settings: normal;
line-height: 1.15;
text-size-adjust: 100%;

View File

@@ -93,6 +93,7 @@
/* 基本文字大小 */
--font-size-base: 16px;
--menu-font-size: calc(var(--font-size-base) * 0.875);
/* =============component & UI============= */

View File

@@ -208,4 +208,39 @@ function treeToString(tree: any[], nodeId: number | string) {
return str;
}
export { filterTree, handleTree, mapTree, traverseTreeValues, treeToString };
/**
* 对树形结构数据进行递归排序
* @param treeData - 树形数据数组
* @param sortFunction - 排序函数,用于定义排序规则
* @param options - 配置选项,包括子节点属性名
* @returns 排序后的树形数据
*/
function sortTree<T extends Record<string, any>>(
treeData: T[],
sortFunction: (a: T, b: T) => number,
options?: TreeConfigOptions,
): T[] {
const { childProps } = options || {
childProps: 'children',
};
return treeData.toSorted(sortFunction).map((item) => {
const children = item[childProps];
if (children && Array.isArray(children) && children.length > 0) {
return {
...item,
[childProps]: sortTree(children, sortFunction, options),
};
}
return item;
});
}
export {
filterTree,
handleTree,
mapTree,
sortTree,
traverseTreeValues,
treeToString,
};

View File

@@ -113,6 +113,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"colorPrimary": "hsl(212 100% 45%)",
"colorSuccess": "hsl(144 57% 58%)",
"colorWarning": "hsl(42 84% 61%)",
"fontSize": 16,
"mode": "dark",
"radius": "0.5",
"semiDarkHeader": false,

View File

@@ -116,6 +116,7 @@ const defaultPreferences: Preferences = {
colorWarning: 'hsl(42 84% 61%)',
mode: 'dark',
radius: '0.5',
fontSize: 16,
semiDarkHeader: false,
semiDarkSidebar: false,
},

View File

@@ -141,7 +141,10 @@ class PreferenceManager {
private handleUpdates(updates: DeepPartial<Preferences>) {
const themeUpdates = updates.theme || {};
const appUpdates = updates.app || {};
if (themeUpdates && Object.keys(themeUpdates).length > 0) {
if (
(themeUpdates && Object.keys(themeUpdates).length > 0) ||
Reflect.has(themeUpdates, 'fontSize')
) {
updateCSSVariables(this.state);
}

View File

@@ -239,6 +239,8 @@ interface ThemePreferences {
colorSuccess: string;
/** 警告色 */
colorWarning: string;
/** 字体大小单位px */
fontSize: number;
/** 当前主题 */
mode: ThemeModeType;
/** 圆角 */

View File

@@ -66,6 +66,19 @@ function updateCSSVariables(preferences: Preferences) {
if (Reflect.has(theme, 'radius')) {
document.documentElement.style.setProperty('--radius', `${radius}rem`);
}
// 更新字体大小
if (Reflect.has(theme, 'fontSize')) {
const fontSize = theme.fontSize;
document.documentElement.style.setProperty(
'--font-size-base',
`${fontSize}px`,
);
document.documentElement.style.setProperty(
'--menu-font-size',
`calc(${fontSize}px * 0.875)`,
);
}
}
/**

View File

@@ -388,7 +388,7 @@ $namespace: vben;
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
margin: 0 var(--menu-item-margin-x) var(--menu-item-margin-y)
var(--menu-item-margin-x);
font-size: var(--menu-font-size);
font-size: var(--menu-font-size) !important;
color: var(--menu-item-color);
white-space: nowrap;
text-decoration: none;
@@ -433,6 +433,7 @@ $namespace: vben;
max-width: var(--menu-title-width);
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--menu-font-size) !important;
white-space: nowrap;
opacity: 1;
}
@@ -444,7 +445,7 @@ $namespace: vben;
.#{$namespace}-menu__popup-container,
.#{$namespace}-menu {
--menu-title-width: 140px;
--menu-item-icon-size: 16px;
--menu-item-icon-size: var(--font-size-base, 16px);
--menu-item-height: 38px;
--menu-item-padding-y: 21px;
--menu-item-padding-x: 12px;
@@ -458,7 +459,6 @@ $namespace: vben;
--menu-item-collapse-margin-x: 0px;
--menu-item-radius: 0px;
--menu-item-indent: 16px;
--menu-font-size: 14px;
&.is-dark {
--menu-background-color: hsl(var(--menu));
@@ -752,7 +752,7 @@ $namespace: vben;
}
.#{$namespace}-menu__icon {
display: block;
font-size: 20px !important;
font-size: calc(var(--font-size-base, 16px) * 1.25) !important;
transition: all 0.25s ease;
}
@@ -760,7 +760,7 @@ $namespace: vben;
display: inline-flex;
margin-top: 8px;
margin-bottom: 0;
font-size: 12px;
font-size: calc(var(--font-size-base, 16px) * 0.75);
font-weight: 400;
line-height: normal;
transition: all 0.25s ease;
@@ -785,7 +785,7 @@ $namespace: vben;
width: 100%;
height: 100%;
padding: 0 var(--menu-item-padding-x);
font-size: var(--menu-font-size);
font-size: var(--menu-font-size) !important;
line-height: var(--menu-item-height);
}
}
@@ -812,9 +812,14 @@ $namespace: vben;
.#{$namespace}-sub-menu-content {
height: var(--menu-item-height);
font-size: var(--menu-font-size) !important;
@include menu-item;
* {
font-size: inherit !important;
}
&__icon-arrow {
position: absolute;
top: 50%;

View File

@@ -102,7 +102,7 @@ $namespace: vben;
}
.#{$namespace}-normal-menu__icon {
font-size: 20px;
font-size: calc(var(--font-size-base, 16px) * 1.25);
}
}
@@ -146,14 +146,14 @@ $namespace: vben;
&__icon {
max-height: 20px;
font-size: 20px;
font-size: calc(var(--font-size-base, 16px) * 1.25);
transition: all 0.25s ease;
}
&__name {
margin-top: 8px;
margin-bottom: 0;
font-size: 12px;
font-size: calc(var(--font-size-base, 16px) * 0.75);
font-weight: 400;
transition: all 0.25s ease;
}

View File

@@ -36,6 +36,8 @@ interface Props {
childrenField?: string;
/** value字段名 */
valueField?: string;
/** disabled字段名 */
disabledField?: string;
/** 组件接收options数据的属性名 */
optionsPropName?: string;
/** 是否立即调用api */
@@ -75,6 +77,7 @@ defineOptions({ name: 'ApiComponent', inheritAttrs: false });
const props = withDefaults(defineProps<Props>(), {
labelField: 'label',
valueField: 'value',
disabledField: 'disabled',
childrenField: '',
optionsPropName: 'options',
resultField: '',
@@ -108,17 +111,25 @@ const isFirstLoaded = ref(false);
const hasPendingRequest = ref(false);
const getOptions = computed(() => {
const { labelField, valueField, childrenField, numberToString } = props;
const {
labelField,
valueField,
disabledField,
childrenField,
numberToString,
} = props;
const refOptionsData = unref(refOptions);
function transformData(data: OptionsItem[]): OptionsItem[] {
return data.map((item) => {
const value = get(item, valueField);
const disabled = get(item, disabledField);
return {
...objectOmit(item, [labelField, valueField, childrenField]),
...objectOmit(item, [labelField, valueField, disabled, childrenField]),
label: get(item, labelField),
value: numberToString ? `${value}` : value,
disabled: get(item, disabledField),
...(childrenField && item[childrenField]
? { children: transformData(item[childrenField]) }
: {}),

View File

@@ -15,5 +15,6 @@ export { default as GlobalShortcutKeys } from './shortcut-keys/global.vue';
export { default as SwitchItem } from './switch-item.vue';
export { default as BuiltinTheme } from './theme/builtin.vue';
export { default as ColorMode } from './theme/color-mode.vue';
export { default as FontSize } from './theme/font-size.vue';
export { default as Radius } from './theme/radius.vue';
export { default as Theme } from './theme/theme.vue';

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { watch } from 'vue';
import { $t } from '@vben/locales';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceFontSize',
});
const modelValue = defineModel<number>({
default: 16,
});
const min = 15;
const max = 22;
const step = 1;
// 限制输入值在 min 和 max 之间
watch(
modelValue,
(newValue) => {
if (newValue < min) {
modelValue.value = min;
} else if (newValue > max) {
modelValue.value = max;
}
},
{ immediate: true },
);
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex items-center gap-2">
<NumberField
v-model="modelValue"
:max="max"
:min="min"
:step="step"
class="w-full"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span class="text-muted-foreground whitespace-nowrap text-xs">px</span>
</div>
<div class="text-muted-foreground text-xs">
{{ $t('preferences.theme.fontSizeTip') }}
</div>
</div>
</template>

View File

@@ -43,6 +43,7 @@ import {
ColorMode,
Content,
Copyright,
FontSize,
Footer,
General,
GlobalShortcutKeys,
@@ -85,6 +86,7 @@ const themeColorPrimary = defineModel<string>('themeColorPrimary');
const themeBuiltinType = defineModel<BuiltinThemeType>('themeBuiltinType');
const themeMode = defineModel<ThemeModeType>('themeMode');
const themeRadius = defineModel<string>('themeRadius');
const themeFontSize = defineModel<number>('themeFontSize');
const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar');
const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader');
@@ -328,6 +330,9 @@ async function handleReset() {
<Block :title="$t('preferences.theme.radius')">
<Radius v-model="themeRadius" />
</Block>
<Block :title="$t('preferences.theme.fontSize')">
<FontSize v-model="themeFontSize" />
</Block>
<Block :title="$t('preferences.other')">
<ColorMode
v-model:app-color-gray-mode="appColorGrayMode"

View File

@@ -13,10 +13,28 @@ function parseSvg(svgData: string): IconifyIconStructure {
const xmlDoc = parser.parseFromString(svgData, 'image/svg+xml');
const svgElement = xmlDoc.documentElement;
// 提取 SVG 根元素的关键样式属性
const getAttrs = (el: Element, attrs: string[]) =>
attrs
.map((attr) =>
el.hasAttribute(attr) ? `${attr}="${el.getAttribute(attr)}"` : '',
)
.filter(Boolean)
.join(' ');
const rootAttrs = getAttrs(svgElement, [
'fill',
'stroke',
'fill-rule',
'stroke-width',
]);
const svgContent = [...svgElement.childNodes]
.filter((node) => node.nodeType === Node.ELEMENT_NODE)
.map((node) => new XMLSerializer().serializeToString(node))
.join('');
// 若根有属性,用一个 g 标签包裹内容并继承属性
const body = rootAttrs ? `<g ${rootAttrs}>${svgContent}</g>` : svgContent;
const viewBoxValue = svgElement.getAttribute('viewBox') || '';
const [left, top, width, height] = viewBoxValue.split(' ').map((val) => {
@@ -25,7 +43,7 @@ function parseSvg(svgData: string): IconifyIconStructure {
});
return {
body: svgContent,
body,
height,
left,
top,

View File

@@ -120,6 +120,8 @@
"theme": {
"title": "Theme",
"radius": "Radius",
"fontSize": "Font Size",
"fontSizeTip": "Adjust global font size with real-time preview",
"light": "Light",
"dark": "Dark",
"darkSidebar": "Semi Dark Sidebar",

View File

@@ -120,6 +120,8 @@
"theme": {
"title": "主题",
"radius": "圆角",
"fontSize": "字体大小",
"fontSizeTip": "调整全局字体大小,实时预览效果",
"light": "浅色",
"dark": "深色",
"darkSidebar": "深色侧边栏",

View File

@@ -8,7 +8,12 @@ import type {
RouteRecordStringComponent,
} from '@vben-core/typings';
import { filterTree, isHttpUrl, mapTree } from '@vben-core/shared/utils';
import {
filterTree,
isHttpUrl,
mapTree,
sortTree,
} from '@vben-core/shared/utils';
/**
* 根据 routes 生成菜单列表
@@ -83,7 +88,7 @@ function generateMenus(
});
// 对菜单进行排序避免order=0时被替换成999的问题
menus = menus.toSorted((a, b) => (a?.order ?? 999) - (b?.order ?? 999));
menus = sortTree(menus, (a, b) => (a?.order ?? 999) - (b?.order ?? 999));
// 过滤掉隐藏的菜单项
return filterTree(menus, (menu) => !!menu.show);