feat: 统一管理端弹窗与内容区交互样式
- 收敛管理端公共 Modal 链路,新增表单弹窗与普通内容弹窗包装\n- 迁移 Bot、知识库、插件、工作流、资源、MCP、数据中枢与系统管理页面级弹窗\n- 统一内容区工具栏、列表容器、导航与顶部按钮的视觉密度和交互节奏
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EasyFlowFormModal } from './form-modal.vue';
|
||||
@@ -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';
|
||||
|
||||
@@ -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('确认');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EasyFlowPanelModal } from './panel-modal.vue';
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user