feat: 统一管理端弹窗与内容区交互样式

- 收敛管理端公共 Modal 链路,新增表单弹窗与普通内容弹窗包装\n- 迁移 Bot、知识库、插件、工作流、资源、MCP、数据中枢与系统管理页面级弹窗\n- 统一内容区工具栏、列表容器、导航与顶部按钮的视觉密度和交互节奏
This commit is contained in:
2026-03-06 19:58:26 +08:00
parent 76c2954a70
commit b191d1aaed
99 changed files with 3148 additions and 1623 deletions

View File

@@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import { EasyFlowFormModal } from '..';
describe('easyFlowFormModal', () => {
it('renders title, preview and body content when opened', async () => {
const wrapper = mount(EasyFlowFormModal, {
global: {
stubs: {
teleport: true,
},
},
props: {
cancelText: '取消',
confirmText: '保存',
description: '描述信息',
open: true,
showPreview: true,
title: '测试弹窗',
},
slots: {
default: '<div>表单内容</div>',
preview: '<div>预览内容</div>',
},
});
await nextTick();
await nextTick();
expect(wrapper.text()).toContain('测试弹窗');
expect(wrapper.text()).toContain('描述信息');
expect(wrapper.text()).toContain('预览内容');
expect(wrapper.text()).toContain('表单内容');
});
it('emits confirm and cancel actions', async () => {
const wrapper = mount(EasyFlowFormModal, {
global: {
stubs: {
teleport: true,
},
},
props: {
cancelText: '取消',
confirmText: '保存',
description: '描述信息',
open: true,
title: '测试弹窗',
},
slots: {
default: '<div>表单内容</div>',
},
});
await nextTick();
await nextTick();
const buttons = wrapper.findAll('button');
const cancelButton = buttons.find((button) => button.text() === '取消');
const confirmButton = buttons.find((button) => button.text() === '保存');
await cancelButton?.trigger('click');
await confirmButton?.trigger('click');
expect(wrapper.emitted('cancel')).toHaveLength(1);
expect(wrapper.emitted('confirm')).toHaveLength(1);
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]);
});
});

View File

@@ -0,0 +1,177 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
import { cn } from '@easyflow-core/shared/utils';
type BeforeClose = () => boolean | Promise<boolean | void> | void;
interface Props {
beforeClose?: BeforeClose;
cancelText?: string;
centered?: boolean;
closable?: boolean;
closeOnClickModal?: boolean;
confirmLoading?: boolean;
confirmText?: string;
description?: string;
draggable?: boolean;
modelValue?: boolean;
open?: boolean;
showCancelButton?: boolean;
showConfirmButton?: boolean;
showFooter?: boolean;
showPreview?: boolean;
submitting?: boolean;
title: string;
width?: 'lg' | 'md' | 'xl' | number | string;
}
defineOptions({
name: 'EasyFlowFormModal',
});
const props = withDefaults(defineProps<Props>(), {
beforeClose: undefined,
cancelText: '',
centered: false,
closable: true,
closeOnClickModal: false,
confirmLoading: false,
confirmText: '',
description: '',
draggable: false,
modelValue: undefined,
open: undefined,
showCancelButton: true,
showConfirmButton: true,
showFooter: true,
showPreview: false,
submitting: false,
width: 'lg',
});
const emit = defineEmits<{
cancel: [];
confirm: [];
'update:modelValue': [boolean];
'update:open': [boolean];
}>();
const [Modal, modalApi] = useEasyFlowModal({
async onBeforeClose() {
const result = await props.beforeClose?.();
return result !== false;
},
onOpenChange(isOpen) {
emit('update:modelValue', isOpen);
emit('update:open', isOpen);
},
});
const widthClassMap: Record<string, string> = {
'482': 'sm:w-[482px]',
'482px': 'sm:w-[482px]',
'50%': 'sm:w-[min(50vw,720px)]',
'500px': 'sm:w-[500px]',
'520px': 'sm:w-[520px]',
'550px': 'sm:w-[550px]',
'600px': 'sm:w-[600px]',
'762': 'sm:w-[762px]',
'762px': 'sm:w-[762px]',
'800px': 'sm:w-[800px]',
'80%': 'sm:w-[min(80vw,1120px)]',
'min(920px, 92vw)': 'sm:w-[min(92vw,920px)]',
'min(980px, 92vw)': 'sm:w-[min(92vw,980px)]',
md: 'sm:w-[560px]',
lg: 'sm:w-[720px]',
xl: 'sm:w-[960px]',
};
const modalClass = computed(() => {
const widthKey = String(props.width);
return cn(
'w-[calc(100vw-24px)] max-w-[calc(100vw-24px)] sm:max-w-[min(calc(100vw-48px),960px)]',
widthClassMap[widthKey] || widthClassMap.lg,
);
});
watch(
() => props.open ?? props.modelValue ?? false,
(value) => {
modalApi.setState({ isOpen: value });
},
{ immediate: true },
);
function handleCancel() {
emit('cancel');
modalApi.close();
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<Modal
:bordered="false"
:class="modalClass"
:centered="centered"
:closable="closable"
:close-on-click-modal="closeOnClickModal"
:confirm-loading="confirmLoading"
content-class="p-0"
:description="description"
:draggable="draggable"
:footer="showFooter"
:fullscreen-button="false"
:show-cancel-button="false"
:show-confirm-button="false"
:submitting="submitting"
:title="title"
>
<div class="flex flex-col">
<section
v-if="showPreview && $slots.preview"
class="bg-[linear-gradient(135deg,hsl(var(--modal-preview-surface))/0.96),hsl(var(--modal-preview-surface-strong))/0.88)] border-b border-[hsl(var(--modal-divider))/0.9] px-5 py-4 sm:px-6"
>
<slot name="preview"></slot>
</section>
<div class="px-5 pb-5 pt-4 sm:px-6 sm:pb-6 sm:pt-5">
<slot></slot>
</div>
</div>
<template v-if="showFooter" #footer>
<div class="flex w-full items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<slot name="footer-extra"></slot>
</div>
<div class="flex items-center gap-3">
<EasyFlowButton
v-if="showCancelButton"
variant="ghost"
:disabled="submitting"
@click="handleCancel"
>
{{ cancelText || $t('button.cancel') }}
</EasyFlowButton>
<EasyFlowButton
v-if="showConfirmButton"
:disabled="submitting"
:loading="confirmLoading || submitting"
@click="handleConfirm"
>
{{ confirmText || $t('button.confirm') }}
</EasyFlowButton>
</div>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1 @@
export { default as EasyFlowFormModal } from './form-modal.vue';

View File

@@ -2,4 +2,6 @@ export * from './about';
export * from './authentication';
export * from './dashboard';
export * from './fallback';
export * from './form-modal';
export * from './panel-modal';
export * from './profile';

View File

@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import { EasyFlowPanelModal } from '..';
describe('easyFlowPanelModal', () => {
it('renders title, description and body content when opened', async () => {
const wrapper = mount(EasyFlowPanelModal, {
global: {
stubs: {
teleport: true,
},
},
props: {
cancelText: '取消',
confirmText: '确认',
description: '描述信息',
open: true,
title: '面板弹窗',
},
slots: {
default: '<div>弹窗内容</div>',
},
});
await nextTick();
await nextTick();
expect(wrapper.text()).toContain('面板弹窗');
expect(wrapper.text()).toContain('描述信息');
expect(wrapper.text()).toContain('弹窗内容');
});
it('supports default footer actions and hide footer', async () => {
const wrapper = mount(EasyFlowPanelModal, {
global: {
stubs: {
teleport: true,
},
},
props: {
cancelText: '取消',
confirmText: '确认',
description: '描述信息',
open: true,
title: '面板弹窗',
},
slots: {
default: '<div>弹窗内容</div>',
},
});
await nextTick();
await nextTick();
const buttons = wrapper.findAll('button');
const cancelButton = buttons.find((button) => button.text() === '取消');
const confirmButton = buttons.find((button) => button.text() === '确认');
await cancelButton?.trigger('click');
await confirmButton?.trigger('click');
expect(wrapper.emitted('cancel')).toHaveLength(1);
expect(wrapper.emitted('confirm')).toHaveLength(1);
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]);
await wrapper.setProps({ showFooter: false });
await nextTick();
expect(wrapper.text()).not.toContain('取消');
expect(wrapper.text()).not.toContain('确认');
});
});

View File

@@ -0,0 +1 @@
export { default as EasyFlowPanelModal } from './panel-modal.vue';

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import { computed, useAttrs, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
import { cn } from '@easyflow-core/shared/utils';
type BeforeClose = () => boolean | Promise<boolean | void> | void;
interface Props {
alignCenter?: boolean;
beforeClose?: BeforeClose;
cancelText?: string;
centered?: boolean;
closable?: boolean;
closeOnClickModal?: boolean;
confirmLoading?: boolean;
confirmText?: string;
description?: string;
destroyOnClose?: boolean;
draggable?: boolean;
modelValue?: boolean;
open?: boolean;
showCancelButton?: boolean;
showConfirmButton?: boolean;
showFooter?: boolean;
submitting?: boolean;
title: string;
width?: 'lg' | 'md' | 'xl' | number | string;
}
defineOptions({
name: 'EasyFlowPanelModal',
});
const props = withDefaults(defineProps<Props>(), {
alignCenter: false,
beforeClose: undefined,
cancelText: '',
centered: false,
closable: true,
closeOnClickModal: false,
confirmLoading: false,
confirmText: '',
description: '',
destroyOnClose: true,
draggable: false,
modelValue: undefined,
open: undefined,
showCancelButton: true,
showConfirmButton: true,
showFooter: true,
submitting: false,
width: 'lg',
});
const emit = defineEmits<{
cancel: [];
confirm: [];
'update:modelValue': [boolean];
'update:open': [boolean];
}>();
const attrs = useAttrs();
const [Modal, modalApi] = useEasyFlowModal({
async onBeforeClose() {
const result = await props.beforeClose?.();
return result !== false;
},
onOpenChange(isOpen) {
emit('update:modelValue', isOpen);
emit('update:open', isOpen);
},
});
const widthClassMap: Record<string, string> = {
'482': 'sm:w-[482px]',
'482px': 'sm:w-[482px]',
'50%': 'sm:w-[min(50vw,720px)]',
'500px': 'sm:w-[500px]',
'520px': 'sm:w-[520px]',
'550px': 'sm:w-[550px]',
'600px': 'sm:w-[600px]',
'762': 'sm:w-[762px]',
'762px': 'sm:w-[762px]',
'800px': 'sm:w-[800px]',
'80%': 'sm:w-[min(80vw,1120px)]',
'min(920px, 92vw)': 'sm:w-[min(92vw,920px)]',
'min(980px, 92vw)': 'sm:w-[min(92vw,980px)]',
md: 'sm:w-[560px]',
lg: 'sm:w-[720px]',
xl: 'sm:w-[960px]',
};
const modalClass = computed(() => {
const widthKey = String(props.width);
return cn(
'w-[calc(100vw-24px)] max-w-[calc(100vw-24px)] sm:max-w-[min(calc(100vw-48px),1120px)]',
widthClassMap[widthKey] || widthClassMap.lg,
attrs.class,
);
});
watch(
() => props.open ?? props.modelValue ?? false,
(value) => {
modalApi.setState({ isOpen: value });
},
{ immediate: true },
);
function handleCancel() {
emit('cancel');
modalApi.close();
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<Modal
:class="modalClass"
:centered="centered || alignCenter"
:closable="closable"
:close-on-click-modal="closeOnClickModal"
:confirm-loading="confirmLoading"
:description="description"
:destroy-on-close="destroyOnClose"
:draggable="draggable"
:footer="showFooter"
:fullscreen-button="false"
:show-cancel-button="false"
:show-confirm-button="false"
:submitting="submitting"
:title="title"
>
<slot></slot>
<template v-if="showFooter" #footer>
<slot name="footer">
<div class="flex w-full items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<slot name="footer-extra"></slot>
</div>
<div class="flex items-center gap-3">
<EasyFlowButton
v-if="showCancelButton"
variant="ghost"
:disabled="submitting"
@click="handleCancel"
>
{{ cancelText || $t('button.cancel') }}
</EasyFlowButton>
<EasyFlowButton
v-if="showConfirmButton"
:disabled="submitting"
:loading="confirmLoading || submitting"
@click="handleConfirm"
>
{{ confirmText || $t('button.confirm') }}
</EasyFlowButton>
</div>
</div>
</slot>
</template>
</Modal>
</template>