!310 chore: new tag

Merge pull request !310 from xingyu/dev
This commit is contained in:
芋道源码
2025-12-27 03:40:30 +00:00
committed by Gitee
36 changed files with 2453 additions and 2187 deletions

View File

@@ -9,7 +9,7 @@
## 🐶 新手必读 ## 🐶 新手必读
- nodejs > 20.12.0 && pnpm > 10.14.0 (强制使用pnpm) - nodejs > 20.12.0 && pnpm > 10.22.0 (强制使用pnpm)
- 演示地址【Vue3 + element-plus】<http://dashboard-vue3.yudao.iocoder.cn> - 演示地址【Vue3 + element-plus】<http://dashboard-vue3.yudao.iocoder.cn>
- 演示地址【Vue3 + vben5(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn> - 演示地址【Vue3 + vben5(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn>
- 演示地址【Vue2 + element-ui】<http://dashboard.yudao.iocoder.cn> - 演示地址【Vue2 + element-ui】<http://dashboard.yudao.iocoder.cn>
@@ -41,22 +41,22 @@
| 框架 | 说明 | 版本 | | 框架 | 说明 | 版本 |
| --- | --- | --- | | --- | --- | --- |
| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.17 | | [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.24 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.1.2 | | [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.2.2 |
| [Ant Design Vue](https://www.antdv.com/) | Ant Design Vue | 4.2.6 | | [Ant Design Vue](https://www.antdv.com/) | Ant Design Vue | 4.2.6 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.10.2 | | [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.10.2 |
| [Naive UI](https://www.naiveui.com/) | Naive UI | 2.42.0 | | [Naive UI](https://www.naiveui.com/) | Naive UI | 2.42.0 |
| [TDesign](https://tdesign.tencent.com/) | TDesign | 1.17.1 | | [TDesign](https://tdesign.tencent.com/) | TDesign | 1.17.1 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.8.3 | | [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.9.3 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.3 | | [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.3 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 13.4.0 | | [vueuse](https://vueuse.org/) | 常用工具集 | 13.4.0 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.1.7 | | [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.1.7 |
| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.5.1 | | [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.5.1 |
| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.17 | | [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.18 |
| [Iconify](https://iconify.design/) | 图标组件 | 5.0.0 | | [Iconify](https://iconify.design/) | 图标组件 | 5.0.0 |
| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.354 | | [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.406 |
| [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 6.1.0 | | [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 6.1.0 |
| [Echarts](https://echarts.apache.org/) | 图表库 | 5.6.0 | | [Echarts](https://echarts.apache.org/) | 图表库 | 6.0.0 |
| [axios](https://axios-http.com/) | http客户端 | 1.10.0 | | [axios](https://axios-http.com/) | http客户端 | 1.10.0 |
| [dayjs](https://day.js.org/) | 日期处理库 | 1.11.13 | | [dayjs](https://day.js.org/) | 日期处理库 | 1.11.13 |
| [vee-validate](https://vee-validate.logaretm.com/) | 表单验证 | 4.15.1 | | [vee-validate](https://vee-validate.logaretm.com/) | 表单验证 | 4.15.1 |

View File

@@ -3,15 +3,31 @@
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用, * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/ */
import type { Component } from 'vue'; import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui'; import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types'; import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue'; import {
defineAsyncComponent,
defineComponent,
h,
ref,
render,
unref,
watch,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
import { notification } from 'ant-design-vue'; import { notification } from 'ant-design-vue';
@@ -22,9 +38,6 @@ const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'), () => import('ant-design-vue/es/auto-complete'),
); );
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Checkbox = defineAsyncComponent( const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'), () => import('ant-design-vue/es/checkbox'),
); );
@@ -68,7 +81,14 @@ const TimeRangePicker = defineAsyncComponent(() =>
const TreeSelect = defineAsyncComponent( const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'), () => import('ant-design-vue/es/tree-select'),
); );
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>( const withDefaultPlaceholder = <T extends Component>(
component: T, component: T,
@@ -104,12 +124,223 @@ const withDefaultPlaceholder = <T extends Component>(
}); });
}; };
const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 可以监听到表单API设置的值
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
};
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'webp',
]);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
};
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件没有可用的URL或预览地址');
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType = export type ComponentType =
| 'ApiCascader' | 'ApiCascader'
| 'ApiSelect' | 'ApiSelect'
| 'ApiTreeSelect' | 'ApiTreeSelect'
| 'AutoComplete' | 'AutoComplete'
| 'Cascader'
| 'Checkbox' | 'Checkbox'
| 'CheckboxGroup' | 'CheckboxGroup'
| 'DatePicker' | 'DatePicker'
@@ -143,21 +374,13 @@ async function initComponentAdapter() {
// 如果你的组件体积比较大,可以使用异步加载 // 如果你的组件体积比较大,可以使用异步加载
// Button: () => // Button: () =>
// import('xxx').then((res) => res.Button), // import('xxx').then((res) => res.Button),
ApiCascader: withDefaultPlaceholder( ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
{ component: Cascader,
...ApiComponent, fieldNames: { label: 'label', value: 'value', children: 'children' },
name: 'ApiCascader', loadingSlot: 'suffixIcon',
}, modelPropName: 'value',
'select', visibleEvent: 'onVisibleChange',
{ }),
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
),
ApiSelect: withDefaultPlaceholder( ApiSelect: withDefaultPlaceholder(
{ {
...ApiComponent, ...ApiComponent,
@@ -187,6 +410,7 @@ async function initComponentAdapter() {
}, },
), ),
AutoComplete, AutoComplete,
Cascader,
Checkbox, Checkbox,
CheckboxGroup, CheckboxGroup,
DatePicker, DatePicker,
@@ -221,6 +445,7 @@ async function initComponentAdapter() {
TimeRangePicker, TimeRangePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload, Upload,
PreviewUpload: withPreviewUpload(),
FileUpload, FileUpload,
ImageUpload, ImageUpload,
}; };

View File

@@ -84,9 +84,10 @@ setupVbenVxeTable({
// 表格配置项可以用 cellRender: { name: 'CellImage' }, // 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', { vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) { renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params; const { column, row } = params;
return h(Image, { src: row[column.field] }); return h(Image, { src: row[column.field], ...props });
}, },
}); });

View File

@@ -15,8 +15,8 @@ import {
Tooltip, Tooltip,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getDevicePage } from '#/api/iot/device/device'; import { getDevicePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
interface Props { interface Props {
products: any[]; products: any[];

View File

@@ -16,8 +16,8 @@ import {
Tooltip, Tooltip,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getProductPage } from '#/api/iot/product/product'; import { getProductPage } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
interface Props { interface Props {
categoryList: any[]; categoryList: any[];

View File

@@ -84,7 +84,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
rowConfig: { rowConfig: {
keyField: 'id', keyField: 'id',
isHover: true, isHover: true,
height: type.value === UploadType.Image ? 220 : 'auto', },
cellConfig: {
height: type.value === UploadType.Image ? 220 : undefined,
}, },
toolbarConfig: { toolbarConfig: {
refresh: true, refresh: true,
@@ -101,8 +103,10 @@ async function onTabChange() {
rowConfig: { rowConfig: {
keyField: 'id', keyField: 'id',
isHover: true, isHover: true,
height: type.value === UploadType.Image ? 220 : 'auto',
}, },
cellConfig: {
height: type.value === UploadType.Image ? 220 : undefined,
}
}); });
await gridApi.reload(); await gridApi.reload();
} }

View File

@@ -91,6 +91,7 @@ setupVbenVxeTable({
}, },
previewTeleported: true, previewTeleported: true,
}); });
// return h(ElImage, { src, previewSrcList: [src], ...props });
}, },
}); });

View File

@@ -66,9 +66,10 @@ setupVbenVxeTable({
// 表格配置项可以用 cellRender: { name: 'CellImage' }, // 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', { vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) { renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params; const { column, row } = params;
return h(NImage, { src: row[column.field] }); return h(NImage, { src: row[column.field], ...props });
}, },
}); });

View File

@@ -72,9 +72,10 @@ setupVbenVxeTable({
// 表格配置项可以用 cellRender: { name: 'CellImage' }, // 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', { vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) { renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params; const { column, row } = params;
return h(Image, { src: row[column.field] }); return h(Image, { src: row[column.field], ...props });
}, },
}); });

View File

@@ -5,6 +5,8 @@ import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores'; import { initStores } from '@vben/stores';
import '@vben/styles'; import '@vben/styles';
// import '@vben/styles/antd';
// 引入组件库的少量全局样式变量
import { useTitle } from '@vueuse/core'; import { useTitle } from '@vueuse/core';
@@ -15,8 +17,6 @@ import { initSetupVbenForm } from './adapter/form';
import App from './app.vue'; import App from './app.vue';
import { router } from './router'; import { router } from './router';
// import '@vben/styles/antd';
// 引入组件库的少量全局样式变量
import 'tdesign-vue-next/es/style/index.css'; import 'tdesign-vue-next/es/style/index.css';
async function bootstrap(namespace: string) { async function bootstrap(namespace: string) {

View File

@@ -40,9 +40,10 @@ if (!import.meta.env.SSR) {
// 表格配置项可以用 cellRender: { name: 'CellImage' }, // 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', { vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) { renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params; const { column, row } = params;
return h(Image, { src: row[column.field] }); return h(Image, { src: row[column.field], ...props });
}, },
}); });

View File

@@ -24,7 +24,7 @@ apps/web-naive
## 演示代码精简 ## 演示代码精简
如果你不需要演示代码,你可以直接删除`playground`文件夹。 如果你不需要演示代码,你可以直接删除 `playground` 文件夹。
## 文档精简 ## 文档精简
@@ -88,7 +88,7 @@ pnpm install
- 在应用的 `src/router/routes` 文件中,你可以删除不需要的路由。其中 `core` 文件夹内,如果只需要登录和忘记密码,你可以删除其他路由,如忘记密码、注册等。路由删除后,你可以删除对应的页面文件,在 `src/views/_core` 文件夹中。 - 在应用的 `src/router/routes` 文件中,你可以删除不需要的路由。其中 `core` 文件夹内,如果只需要登录和忘记密码,你可以删除其他路由,如忘记密码、注册等。路由删除后,你可以删除对应的页面文件,在 `src/views/_core` 文件夹中。
- 在应用的 `src/router/routes` 文件中,你可以按需求删除不需要的路由,如`demos``vben` 目录等。路由删除后,你可以删除对应的页面文件,`src/views` 文件夹中。 - 在应用的 `src/router/routes` 文件中,你可以按需求删除不需要的路由,如`demos``vben` 目录等。路由删除后,你可以在 `src/views` 文件夹中删除对应的页面文件
### 删除不需要的组件 ### 删除不需要的组件

View File

@@ -14,8 +14,9 @@
} }
html { 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; font-variation-settings: normal;
line-height: 1.15; line-height: 1.15;
text-size-adjust: 100%; text-size-adjust: 100%;

View File

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

View File

@@ -208,4 +208,39 @@ function treeToString(tree: any[], nodeId: number | string) {
return str; 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%)", "colorPrimary": "hsl(212 100% 45%)",
"colorSuccess": "hsl(144 57% 58%)", "colorSuccess": "hsl(144 57% 58%)",
"colorWarning": "hsl(42 84% 61%)", "colorWarning": "hsl(42 84% 61%)",
"fontSize": 16,
"mode": "dark", "mode": "dark",
"radius": "0.5", "radius": "0.5",
"semiDarkHeader": false, "semiDarkHeader": false,

View File

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

View File

@@ -141,7 +141,10 @@ class PreferenceManager {
private handleUpdates(updates: DeepPartial<Preferences>) { private handleUpdates(updates: DeepPartial<Preferences>) {
const themeUpdates = updates.theme || {}; const themeUpdates = updates.theme || {};
const appUpdates = updates.app || {}; 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); updateCSSVariables(this.state);
} }

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,8 @@ interface Props {
childrenField?: string; childrenField?: string;
/** value字段名 */ /** value字段名 */
valueField?: string; valueField?: string;
/** disabled字段名 */
disabledField?: string;
/** 组件接收options数据的属性名 */ /** 组件接收options数据的属性名 */
optionsPropName?: string; optionsPropName?: string;
/** 是否立即调用api */ /** 是否立即调用api */
@@ -75,6 +77,7 @@ defineOptions({ name: 'ApiComponent', inheritAttrs: false });
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
labelField: 'label', labelField: 'label',
valueField: 'value', valueField: 'value',
disabledField: 'disabled',
childrenField: '', childrenField: '',
optionsPropName: 'options', optionsPropName: 'options',
resultField: '', resultField: '',
@@ -108,17 +111,25 @@ const isFirstLoaded = ref(false);
const hasPendingRequest = ref(false); const hasPendingRequest = ref(false);
const getOptions = computed(() => { const getOptions = computed(() => {
const { labelField, valueField, childrenField, numberToString } = props; const {
labelField,
valueField,
disabledField,
childrenField,
numberToString,
} = props;
const refOptionsData = unref(refOptions); const refOptionsData = unref(refOptions);
function transformData(data: OptionsItem[]): OptionsItem[] { function transformData(data: OptionsItem[]): OptionsItem[] {
return data.map((item) => { return data.map((item) => {
const value = get(item, valueField); const value = get(item, valueField);
const disabled = get(item, disabledField);
return { return {
...objectOmit(item, [labelField, valueField, childrenField]), ...objectOmit(item, [labelField, valueField, disabled, childrenField]),
label: get(item, labelField), label: get(item, labelField),
value: numberToString ? `${value}` : value, value: numberToString ? `${value}` : value,
disabled: get(item, disabledField),
...(childrenField && item[childrenField] ...(childrenField && item[childrenField]
? { children: transformData(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 SwitchItem } from './switch-item.vue';
export { default as BuiltinTheme } from './theme/builtin.vue'; export { default as BuiltinTheme } from './theme/builtin.vue';
export { default as ColorMode } from './theme/color-mode.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 Radius } from './theme/radius.vue';
export { default as Theme } from './theme/theme.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, ColorMode,
Content, Content,
Copyright, Copyright,
FontSize,
Footer, Footer,
General, General,
GlobalShortcutKeys, GlobalShortcutKeys,
@@ -85,6 +86,7 @@ const themeColorPrimary = defineModel<string>('themeColorPrimary');
const themeBuiltinType = defineModel<BuiltinThemeType>('themeBuiltinType'); const themeBuiltinType = defineModel<BuiltinThemeType>('themeBuiltinType');
const themeMode = defineModel<ThemeModeType>('themeMode'); const themeMode = defineModel<ThemeModeType>('themeMode');
const themeRadius = defineModel<string>('themeRadius'); const themeRadius = defineModel<string>('themeRadius');
const themeFontSize = defineModel<number>('themeFontSize');
const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar'); const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar');
const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader'); const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader');
@@ -328,6 +330,9 @@ async function handleReset() {
<Block :title="$t('preferences.theme.radius')"> <Block :title="$t('preferences.theme.radius')">
<Radius v-model="themeRadius" /> <Radius v-model="themeRadius" />
</Block> </Block>
<Block :title="$t('preferences.theme.fontSize')">
<FontSize v-model="themeFontSize" />
</Block>
<Block :title="$t('preferences.other')"> <Block :title="$t('preferences.other')">
<ColorMode <ColorMode
v-model:app-color-gray-mode="appColorGrayMode" 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 xmlDoc = parser.parseFromString(svgData, 'image/svg+xml');
const svgElement = xmlDoc.documentElement; 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] const svgContent = [...svgElement.childNodes]
.filter((node) => node.nodeType === Node.ELEMENT_NODE) .filter((node) => node.nodeType === Node.ELEMENT_NODE)
.map((node) => new XMLSerializer().serializeToString(node)) .map((node) => new XMLSerializer().serializeToString(node))
.join(''); .join('');
// 若根有属性,用一个 g 标签包裹内容并继承属性
const body = rootAttrs ? `<g ${rootAttrs}>${svgContent}</g>` : svgContent;
const viewBoxValue = svgElement.getAttribute('viewBox') || ''; const viewBoxValue = svgElement.getAttribute('viewBox') || '';
const [left, top, width, height] = viewBoxValue.split(' ').map((val) => { const [left, top, width, height] = viewBoxValue.split(' ').map((val) => {
@@ -25,7 +43,7 @@ function parseSvg(svgData: string): IconifyIconStructure {
}); });
return { return {
body: svgContent, body,
height, height,
left, left,
top, top,

View File

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

View File

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

View File

@@ -8,7 +8,12 @@ import type {
RouteRecordStringComponent, RouteRecordStringComponent,
} from '@vben-core/typings'; } from '@vben-core/typings';
import { filterTree, isHttpUrl, mapTree } from '@vben-core/shared/utils'; import {
filterTree,
isHttpUrl,
mapTree,
sortTree,
} from '@vben-core/shared/utils';
/** /**
* 根据 routes 生成菜单列表 * 根据 routes 生成菜单列表
@@ -83,7 +88,7 @@ function generateMenus(
}); });
// 对菜单进行排序避免order=0时被替换成999的问题 // 对菜单进行排序避免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); return filterTree(menus, (menu) => !!menu.show);
@@ -111,7 +116,7 @@ function convertServerMenuToRouteRecordStringComponent(
hideInMenu: !menu.visible, hideInMenu: !menu.visible,
icon: menu.icon, icon: menu.icon,
link: menu.path, link: menu.path,
orderNo: menu.sort, order: menu.sort,
title: menu.name, title: menu.name,
}, },
name: menu.name, name: menu.name,
@@ -155,7 +160,7 @@ function convertServerMenuToRouteRecordStringComponent(
hideInMenu: !menu.visible, hideInMenu: !menu.visible,
icon: menu.icon, icon: menu.icon,
keepAlive: menu.keepAlive, keepAlive: menu.keepAlive,
orderNo: menu.sort, order: menu.sort,
title: menu.name, title: menu.name,
}, },
name: finalName, name: finalName,

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'ant-design-vue';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

3860
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff