fix: 修复文件管理/菜单/权限/工作流多处 UI 问题并优化弹窗

- 文件管理:列表高度自适应、文件大小展示、文件夹递归总大小
- 菜单/权限管理:树表默认折叠、菜单列宽自适应
- 菜单编辑弹窗:加宽、移除允许角色/组件路径/重定向字段
- 工作流:节点选中框位置偏移修复
- 弹窗:统一默认宽度 640px,修正 width prop 失效
- 图标选择:Popover 小浮层改为居中 Dialog 弹窗

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ljw559 2026-07-06 00:45:34 +08:00
parent c6d3079e54
commit d5084cdf70
13 changed files with 203 additions and 185 deletions

View File

@ -110,6 +110,8 @@ export namespace FolderApi {
parentId: null | string;
name: string;
ownerId: string;
/** 该文件夹下所有文件递归汇总的字节大小(getFolderTree 计算)。 */
size: number;
createdAt: string;
updatedAt: string;
}

View File

@ -1,6 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { setDefaultModalProps } from '@vben/common-ui';
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
@ -23,14 +24,11 @@ async function bootstrap(namespace: string) {
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 1020,
// });
// 设置弹窗的默认配置:统一默认宽度 640px(覆盖框架写死的 w-130/520px)。
// 个别弹窗可在自身 <Modal class="w-[Npx]"> 上覆盖(per-modal class 优先级高于此默认)。
setDefaultModalProps({
class: 'w-[640px]',
});
const app = createApp(App);

View File

@ -39,6 +39,7 @@ export function buildFileManagerRows(
itemType: 'folder',
name: folder.name,
rowKey: `folder:${folder.id}`,
size: folder.size,
}));
const fileRows: FileManagerRow[] = files

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { FileManagerRow } from './data';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { FolderApi, ShareApi } from '#/api/core/file';
import type { FileManagerRow } from './data';
import { computed, nextTick, reactive, ref } from 'vue';
@ -54,16 +55,16 @@ import {
} from '#/api/core/file';
import { uploadFileWithResume } from '#/api/core/multipart-uploader';
import {
canMoveFileToFolder,
getDraggedFileId,
setDraggedFileId,
} from './drag';
import {
buildFileManagerRows,
useFileManagerColumns,
useGridFormSchema,
} from './data';
import {
canMoveFileToFolder,
getDraggedFileId,
setDraggedFileId,
} from './drag';
defineOptions({ name: 'FileManagerPage' });
@ -98,7 +99,10 @@ async function loadCurrentRows(keyword?: string) {
getFolderTree().catch(() => allFolders.value),
]);
allFolders.value = treeRes;
subFolders.value = folderRes.subFolders;
// 子文件夹从 tree 派生(tree 里的 FolderDto 带 size;getFolder.subFolders 不带 size)
subFolders.value = treeRes.filter(
(f) => f.parentId === currentFolderId.value,
);
folderFiles.value = folderRes.files;
rebuildBreadcrumbs(currentFolderId.value!);
}
@ -805,6 +809,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
submitOnChange: true,
},
gridOptions: {
// 用于 :deep(.fm-grid) 定位到 Grid 的包装 div,让其 flex 填充剩余高度
class: 'fm-grid',
columns: useFileManagerColumns(onActionClick),
height: 'auto',
keepSource: true,
@ -930,7 +936,9 @@ function refreshGrid() {
icon="lucide:grip-vertical"
/>
<IconifyIcon
:icon="row.itemType === 'folder' ? 'lucide:folder' : 'lucide:file'"
:icon="
row.itemType === 'folder' ? 'lucide:folder' : 'lucide:file'
"
:class="
row.itemType === 'folder'
? 'name-icon-folder'
@ -949,7 +957,7 @@ function refreshGrid() {
</template>
<template #size="{ row }">
{{ row.itemType === 'folder' ? '-' : formatSize(row.size ?? 0) }}
{{ formatSize(row.size ?? 0) }}
</template>
<template #type="{ row }">
@ -1122,6 +1130,18 @@ function refreshGrid() {
display: flex;
flex-direction: column;
gap: 16px;
/* 填满 Page(auto-content-height) 内容区,让内部 Vxe Grid 的 height:'auto'
能拿到确定的父级高度(否则首屏表格体高度计算异常,需点全屏再还原才恢复) */
height: 100%;
}
/* Grid 包装 div:占据面包屑/上传进度条之外的剩余高度,
min-height:0 允许表格体按 height:'auto' 正确收缩计算 */
.file-manager :deep(.fm-grid) {
flex: 1 1 0%;
height: auto;
min-height: 0;
}
/* 名称列:图标 + 文字水平排列 */
@ -1156,8 +1176,8 @@ function refreshGrid() {
.name-link {
min-width: 0;
overflow: hidden;
color: hsl(var(--primary));
text-overflow: ellipsis;
color: hsl(var(--primary));
white-space: nowrap;
cursor: pointer;
}
@ -1169,8 +1189,8 @@ function refreshGrid() {
.name-text {
min-width: 0;
overflow: hidden;
color: inherit;
text-overflow: ellipsis;
color: inherit;
white-space: nowrap;
}

View File

@ -58,6 +58,8 @@ export function useSchema(): VbenFormSchema[] {
{
component: 'Select',
componentProps: {
// antd Select 默认不撑满容器(不像 Input),需显式 w-full 才能完整显示选项文本
class: 'w-full',
options: [
{ label: '普通路由', value: 'Route' },
{ label: '自定义表单', value: 'BuildFlowForm' },
@ -71,6 +73,7 @@ export function useSchema(): VbenFormSchema[] {
{
component: 'ApiSelect',
componentProps: {
class: 'w-full',
api: async () => {
const result = await getFormDefinitionListApi({
pageIndex: 1,
@ -93,6 +96,7 @@ export function useSchema(): VbenFormSchema[] {
{
component: 'Select',
componentProps: {
class: 'w-full',
options: [
{ label: '列表', value: 'List' },
{ label: '表单', value: 'Form' },
@ -121,28 +125,6 @@ export function useSchema(): VbenFormSchema[] {
fieldName: 'order',
label: '排序',
},
{
component: 'Input',
componentProps: { placeholder: 'Admin(选填)' },
fieldName: 'authority',
label: '允许角色',
},
{
component: 'Input',
componentProps: { placeholder: '/system/user/index(普通路由必填)' },
dependencies: {
disabled: (values) => values.renderType === 'BuildFlowForm',
triggerFields: ['renderType'],
},
fieldName: 'component',
label: '组件路径',
},
{
component: 'Input',
componentProps: { placeholder: '父菜单重定向(选填)' },
fieldName: 'redirect',
label: '重定向',
},
{
component: 'Switch',
fieldName: 'hideInMenu',
@ -183,7 +165,9 @@ export function useColumns(
{
field: 'path',
title: '路径',
width: 200,
// 用 minWidth 而非固定 width:作为弹性填充列,撑满操作列之外的剩余宽度,
// 避免宽屏下右侧出现空隙(与权限页 description 列同款做法)。
minWidth: 200,
},
{
field: 'icon',

View File

@ -1,9 +1,7 @@
import type { MenuApi } from '#/api';
interface MenuFormValues {
authority?: string;
code: string;
component?: string;
formDefinitionId?: string;
hideInMenu?: boolean;
icon?: string;
@ -11,7 +9,6 @@ interface MenuFormValues {
order?: number;
parentId?: null | number;
path: string;
redirect?: string;
renderType?: 'BuildFlowForm' | 'Route';
runtimeViewMode?: 'Detail' | 'Form' | 'List';
title: string;
@ -27,11 +24,9 @@ function buildMenuFormPayload(data: MenuFormValues): MenuApi.CreateMenuParams {
const isBuildFlowForm = data.renderType === 'BuildFlowForm';
return {
authority: normalizeOptional(data.authority),
code: data.code,
component: isBuildFlowForm
? buildFlowRuntimeComponent
: normalizeOptional(data.component),
// 表单已移除「组件路径」输入:BuildFlowForm 仍自动指向运行时组件;Route 不再带 component
component: isBuildFlowForm ? buildFlowRuntimeComponent : undefined,
formDefinitionId: isBuildFlowForm ? data.formDefinitionId : undefined,
hideInMenu: !!data.hideInMenu,
icon: normalizeOptional(data.icon),
@ -41,9 +36,10 @@ function buildMenuFormPayload(data: MenuFormValues): MenuApi.CreateMenuParams {
order: data.order ?? 0,
parentId: data.parentId || null,
path: data.path,
redirect: normalizeOptional(data.redirect),
renderType: data.renderType ?? 'Route',
runtimeViewMode: isBuildFlowForm ? (data.runtimeViewMode ?? 'List') : undefined,
runtimeViewMode: isBuildFlowForm
? (data.runtimeViewMode ?? 'List')
: undefined,
title: data.title,
};
}

View File

@ -119,7 +119,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
zoom: true,
},
treeConfig: {
expandAll: true,
// 不设 expandAll:默认折叠,只显示顶级菜单,点行首展开图标才加载显示子级
parentField: 'parentId',
rowField: 'id',
transform: true,

View File

@ -70,10 +70,7 @@ const [Modal, modalApi] = useVbenModal({
path: data.path,
icon: data.icon ?? '',
order: data.order,
authority: data.authority ?? '',
component: data.component ?? '',
formDefinitionId: data.formDefinitionId,
redirect: data.redirect ?? '',
renderType: data.renderType ?? 'Route',
runtimeViewMode: data.runtimeViewMode ?? 'List',
hideInMenu: data.hideInMenu,
@ -95,7 +92,8 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="editRow ? '编辑菜单' : '新增菜单'" width="640px">
<!-- Vben Modal 没有 width prop,宽度靠 class 覆盖默认的 w-130(520px) -->
<Modal :title="editRow ? '编辑菜单' : '新增菜单'" class="w-[820px]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -118,7 +118,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
// 不设 expandAll:默认折叠,只显示分组,点行首展开图标才显示该组权限
},
} as VxeTableGridOptions<PermissionRow>,
});

View File

@ -103,7 +103,7 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="getTitle" :width="960" fullscreen-button>
<Modal :title="getTitle" class="w-[960px]" fullscreen-button>
<ASpin :spinning="loading" class="block py-2">
<p class="mb-3 text-sm text-muted-foreground">
勾选该角色拥有的权限(全量替换)。右上角可全屏展开。

View File

@ -61,7 +61,7 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="`分配角色 — ${targetUsername}`" width="480px">
<Modal :title="`分配角色 — ${targetUsername}`" class="w-[480px]">
<ASpin :spinning="loading" class="block py-2">
<p class="mb-3 text-sm text-muted-foreground">
勾选要授予的角色(全量替换)。

View File

@ -66,10 +66,16 @@ export function buildNativeNodeAttrs(shape: string, data: Record<string, any>) {
const deleteVisible = data.__selected && canDelete ? 'visible' : 'hidden';
const commonSelectedFrame = {
// 选中框:四周相对节点对称外扩 7px 的蓝色虚线光晕。
// refX/refY:-7 把左上角放到节点外 7px;refWidth/refHeight:'100%' 仅等于节点宽高,
// 必须再补 refWidth2/refHeight2:14 才能让框 = 节点 + 14,否则框会整体往左上偏移
// (左/上外凸 7px、右/下内缩 7px)。
refX: -7,
refY: -7,
refWidth: '100%',
refHeight: '100%',
refWidth2: 14,
refHeight2: 14,
fill: 'transparent',
stroke: '#1677FF',
strokeDasharray: '7 4',
@ -291,7 +297,9 @@ export function applyWorkflowNodePresentation(node: Node) {
export function setWorkflowNodeSelected(node: Node, selected: boolean) {
if (!getNodeConfigByShape(node.shape)) return;
const data = node.getData() ?? {};
node.setAttrs(buildNativeNodeAttrs(node.shape, { ...data, __selected: selected }));
node.setAttrs(
buildNativeNodeAttrs(node.shape, { ...data, __selected: selected }),
);
}
/** 加载旧 definitionJson 后批量补齐 native rect 展示配置。 */

View File

@ -9,6 +9,9 @@ import { $t } from '@vben/locales';
import {
Button,
Dialog,
DialogContent,
DialogTitle,
Input,
Pagination,
PaginationContent,
@ -20,7 +23,6 @@ import {
PaginationPrevious,
VbenIcon,
VbenIconButton,
VbenPopover,
} from '@vben-core/shadcn-ui';
import { isFunction } from '@vben-core/shared/utils';
@ -162,139 +164,148 @@ const getBindAttrs = computed(() => {
defineExpose({ toggleOpenState, open, close });
</script>
<template>
<VbenPopover
v-model:open="visible"
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
content-class="p-0 pt-3 w-full"
trigger-class="w-full"
>
<template #trigger>
<template v-if="props.type === 'input'">
<component
v-if="props.inputComponent"
:is="inputComponent"
:[modelValueProp]="currentSelect"
:placeholder="$t('ui.iconPicker.placeholder')"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
aria-expanded="visible"
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
v-bind="getBindAttrs"
>
<template #[iconSlot]>
<VbenIcon
:icon="currentSelect || Grip"
class="size-4"
aria-hidden="true"
/>
</template>
</component>
<div class="relative w-full" v-else>
<Input
v-bind="$attrs"
v-model="currentSelect"
:placeholder="$t('ui.iconPicker.placeholder')"
class="h-8 w-full pr-8"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
aria-expanded="visible"
/>
<VbenIcon
:icon="currentSelect || Grip"
class="absolute top-1 right-1 size-6"
aria-hidden="true"
/>
</div>
<!-- 触发器:点击打开居中弹窗选择图标 -->
<template v-if="props.type === 'input'">
<component
v-if="props.inputComponent"
:is="inputComponent"
:[modelValueProp]="currentSelect"
:placeholder="$t('ui.iconPicker.placeholder')"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
v-bind="getBindAttrs"
@click="open"
>
<template #[iconSlot]>
<VbenIcon
:icon="currentSelect || Grip"
class="size-4"
aria-hidden="true"
/>
</template>
</component>
<div v-else class="relative w-full cursor-pointer" @click="open">
<Input
v-bind="$attrs"
:model-value="currentSelect"
readonly
:placeholder="$t('ui.iconPicker.placeholder')"
class="h-8 w-full cursor-pointer pr-8"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
/>
<VbenIcon
:icon="currentSelect || Grip"
v-else
class="size-4"
v-bind="$attrs"
/>
</template>
<div class="mb-2 flex w-full">
<component
v-if="inputComponent"
:is="inputComponent"
v-bind="searchInputProps"
/>
<Input
v-else
class="mx-2 h-8 w-full"
:placeholder="$t('ui.iconPicker.search')"
v-model="keyword"
class="absolute top-1 right-1 size-6"
aria-hidden="true"
/>
</div>
</template>
<VbenIcon
v-else
:icon="currentSelect || Grip"
class="size-4 cursor-pointer"
v-bind="$attrs"
@click="open"
/>
<template v-if="paginationList.length > 0">
<div class="grid max-h-90 w-full grid-cols-6 justify-items-center">
<VbenIconButton
v-for="(item, index) in paginationList"
:key="index"
:tooltip="item"
tooltip-side="top"
@click="handleClick(item)"
>
<VbenIcon
:class="{
'text-primary transition-all': currentSelect === item,
}"
:icon="item"
/>
</VbenIconButton>
<!-- 居中弹窗(与新增/编辑弹窗同款 Dialog) -->
<Dialog v-model:open="visible">
<DialogContent
append-to="body"
class="left-1/2 top-1/2 w-[600px] max-w-[90vw] -translate-x-1/2 -translate-y-1/2"
>
<DialogTitle class="sr-only">
{{ $t('ui.iconPicker.placeholder') }}
</DialogTitle>
<div class="mb-3 flex w-full">
<component
v-if="inputComponent"
:is="inputComponent"
v-bind="searchInputProps"
/>
<Input
v-else
class="h-8 w-full"
:placeholder="$t('ui.iconPicker.search')"
v-model="keyword"
/>
</div>
<div
v-if="total >= pageSize"
class="flex-center flex justify-end overflow-hidden border-t py-2 pr-3"
>
<Pagination
:items-per-page="36"
:sibling-count="1"
:total="total"
show-edges
size="small"
@update:page="handlePageChange"
<template v-if="paginationList.length > 0">
<div
class="grid max-h-[55vh] w-full grid-cols-8 justify-items-center overflow-y-auto"
>
<PaginationContent
v-slot="{ items }"
class="flex w-full items-center gap-1"
<VbenIconButton
v-for="(item, index) in paginationList"
:key="index"
:tooltip="item"
tooltip-side="top"
@click="handleClick(item)"
>
<PaginationFirst class="size-5" />
<PaginationPrevious class="size-5" />
<template v-for="(item, index) in items">
<PaginationItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button
:variant="item.value === currentPage ? 'default' : 'outline'"
class="size-5 p-0 text-sm"
<VbenIcon
:class="{
'text-primary transition-all': currentSelect === item,
}"
:icon="item"
/>
</VbenIconButton>
</div>
<div
v-if="total >= pageSize"
class="flex-center mt-2 flex justify-end overflow-hidden border-t py-2 pr-3"
>
<Pagination
:items-per-page="36"
:sibling-count="1"
:total="total"
show-edges
size="small"
@update:page="handlePageChange"
>
<PaginationContent
v-slot="{ items }"
class="flex w-full items-center gap-1"
>
<PaginationFirst class="size-5" />
<PaginationPrevious class="size-5" />
<template v-for="(item, index) in items">
<PaginationItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis
v-else
:key="item.type"
:index="index"
class="size-5"
/>
</template>
<PaginationNext class="size-5" />
<PaginationLast class="size-5" />
</PaginationContent>
</Pagination>
</div>
</template>
<Button
:variant="
item.value === currentPage ? 'default' : 'outline'
"
class="size-5 p-0 text-sm"
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis
v-else
:key="item.type"
:index="index"
class="size-5"
/>
</template>
<PaginationNext class="size-5" />
<PaginationLast class="size-5" />
</PaginationContent>
</Pagination>
</div>
</template>
<template v-else>
<div class="flex-col-center min-h-37.5 w-full text-muted-foreground">
<EmptyIcon class="size-10" />
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
</div>
</template>
</VbenPopover>
<template v-else>
<div class="flex-col-center min-h-37.5 w-full text-muted-foreground">
<EmptyIcon class="size-10" />
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
</div>
</template>
</DialogContent>
</Dialog>
</template>