Track AI form editor confirmation panels

This commit is contained in:
2026-06-09 23:47:20 +08:00
parent ce5a34e3b0
commit 07d1f7402b
14 changed files with 2693 additions and 3 deletions
-3
View File
@@ -269,6 +269,3 @@ web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel
!web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue
!web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue
web/apps/web-ele/src/views/wiki/
# keep only lightweight form-editor barrel
web/apps/web-ele/src/components/form-editor/*
!web/apps/web-ele/src/components/form-editor/index.ts
@@ -0,0 +1,109 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput } from 'element-plus';
export interface AppDesignData {
type: 'app_design';
title: string;
data: {
content: string;
title: string;
};
nodeId?: string;
}
const props = defineProps<{
design?: AppDesignData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
const isFullscreen = ref(false);
const editContent = ref('');
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const panelTitle = computed(
() => props.design?.data?.title || props.design?.title || '应用设计方案',
);
watch(
() => props.design,
(design) => {
editContent.value = design?.data?.content || '';
},
{ immediate: true },
);
function handleConfirm() {
emit('confirm', {
content: editContent.value,
title: panelTitle.value,
});
}
function handleClose() {
dialogVisible.value = false;
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<div
class="border-border bg-muted/50 flex shrink-0 items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">{{ panelTitle }}</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="isFullscreen = !isFullscreen"
/>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
<div class="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
AI 已生成应用设计方案确认前可以直接调整正文确认后将继续后续编排节点
</div>
<ElInput
v-model="editContent"
class="min-h-0 flex-1"
resize="none"
type="textarea"
placeholder="请输入应用设计方案"
/>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
}
</style>
@@ -0,0 +1,536 @@
<script lang="ts" setup>
/**
* 应用设置面板
* 用于配置应用设置,包括应用配置、Logo配置、内置主题和布局
*/
import type { BuiltinThemeType, LayoutType, ThemeModeType } from '@vben/types';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import {
Block,
BuiltinTheme,
Layout,
Theme,
} from '@vben/layouts/preferences-blocks';
import { $t } from '@vben/locales';
import {
ElButton,
ElDivider,
ElInput,
ElMessage,
ElOption,
ElScrollbar,
ElSelect,
ElSwitch,
} from 'element-plus';
import { uploadFile } from '#/api/core/file';
import { getFileUrlPublic } from '#/composables/useFileUrl';
// 应用设置数据接口
export interface AppSettingsData {
type: 'app_settings';
title: string;
data: {
app?: {
defaultHomePath?: string;
dynamicTitle?: boolean;
enableCheckUpdates?: boolean;
enablePreferences?: boolean;
layout?: string;
locale?: string;
name?: string;
watermark?: boolean;
watermarkContent?: string;
};
breadcrumb?: Record<string, any>;
copyright?: Record<string, any>;
footer?: Record<string, any>;
header?: Record<string, any>;
logo?: {
enable?: boolean;
fit?: string;
source?: string;
};
navigation?: Record<string, any>;
shortcutKeys?: Record<string, any>;
sidebar?: Record<string, any>;
tabbar?: Record<string, any>;
theme?: {
builtinType?: string;
colorPrimary?: string;
mode?: string;
radius?: string;
semiDarkHeader?: boolean;
semiDarkSidebar?: boolean;
};
transition?: Record<string, any>;
widget?: Record<string, any>;
};
nodeId?: string;
layoutOptions?: Array<{ label: string; value: string }>;
themeOptions?: Array<{ label: string; value: string }>;
}
interface Props {
visible: boolean;
settings?: AppSettingsData;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 应用配置
const appName = ref('');
const appDefaultHomePath = ref('/analytics');
const appEnablePreferences = ref(true);
const appLayout = ref<LayoutType>('sidebar-nav');
// Logo配置
const logoEnable = ref(true);
const logoSource = ref('');
const logoFit = ref('contain');
const logoDisplayUrl = ref('');
// 主题配置
const themeMode = ref<ThemeModeType>('light');
const themeBuiltinType = ref<BuiltinThemeType>('default');
const themeColorPrimary = ref('hsl(212 100% 45%)');
const themeSemiDarkSidebar = ref(false);
const themeSemiDarkHeader = ref(false);
// 适应方式选项
const fitOptions = computed(() => [
{ label: $t('ai-platform.appSettings.fitOptions.contain'), value: 'contain' },
{ label: $t('ai-platform.appSettings.fitOptions.cover'), value: 'cover' },
{ label: $t('ai-platform.appSettings.fitOptions.fill'), value: 'fill' },
{ label: $t('ai-platform.appSettings.fitOptions.none'), value: 'none' },
{
label: $t('ai-platform.appSettings.fitOptions.scale-down'),
value: 'scale-down',
},
]);
// 面板标题
const panelTitle = computed(() => {
return props.settings?.title || $t('ai-platform.appSettings.title');
});
// 监听设置数据变化
watch(
() => props.settings,
(newSettings) => {
if (newSettings?.data) {
const data = newSettings.data;
// 应用配置
appName.value = data.app?.name || '';
appDefaultHomePath.value = data.app?.defaultHomePath || '/analytics';
appEnablePreferences.value = data.app?.enablePreferences ?? true;
appLayout.value = (data.app?.layout || 'sidebar-nav') as LayoutType;
// Logo配置
logoEnable.value = data.logo?.enable ?? true;
logoSource.value = data.logo?.source || '';
logoFit.value = data.logo?.fit || 'contain';
loadLogoDisplayUrl(logoSource.value);
// 主题配置
themeMode.value = (data.theme?.mode || 'light') as ThemeModeType;
themeBuiltinType.value = (data.theme?.builtinType ||
'default') as BuiltinThemeType;
themeColorPrimary.value = data.theme?.colorPrimary || 'hsl(212 100% 45%)';
themeSemiDarkSidebar.value = data.theme?.semiDarkSidebar ?? false;
themeSemiDarkHeader.value = data.theme?.semiDarkHeader ?? false;
}
},
{ immediate: true },
);
// 监听logoSource变化,加载显示URL
watch(logoSource, (newSource) => {
loadLogoDisplayUrl(newSource);
});
// 判断是否为文件ID
function isFileId(value: string): boolean {
return (
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value) ||
/^\d+$/.test(value) ||
/^[\w-]{10,30}$/.test(value)
);
}
// 加载Logo显示URL
function loadLogoDisplayUrl(source: string) {
if (!source) {
logoDisplayUrl.value = '';
return;
}
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('/') ||
source.startsWith('data:')
) {
logoDisplayUrl.value = source;
return;
}
if (isFileId(source)) {
logoDisplayUrl.value = getFileUrlPublic(source);
return;
}
logoDisplayUrl.value = source;
}
// 文件上传
const uploadInputRef = ref<HTMLInputElement>();
function openFileSelector() {
uploadInputRef.value?.click();
}
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
const response = await uploadFile(file, {
isPublic: true,
source: 'avatar',
});
if (response?.id) {
logoSource.value = getFileUrlPublic(String(response.id));
}
} catch {
ElMessage.error($t('ui-config.loadError'));
}
target.value = '';
}
function clearLogo() {
logoSource.value = '';
}
// 构建设置数据
function buildSettingsData(): Record<string, any> {
return {
app: {
name: appName.value,
defaultHomePath: appDefaultHomePath.value,
enablePreferences: appEnablePreferences.value,
layout: appLayout.value,
},
logo: {
enable: logoEnable.value,
source: logoSource.value,
fit: logoFit.value,
},
theme: {
mode: themeMode.value,
builtinType: themeBuiltinType.value,
colorPrimary: themeColorPrimary.value,
semiDarkSidebar: themeSemiDarkSidebar.value,
semiDarkHeader: themeSemiDarkHeader.value,
},
// 保留原有的其他配置
sidebar: props.settings?.data?.sidebar,
header: props.settings?.data?.header,
footer: props.settings?.data?.footer,
copyright: props.settings?.data?.copyright,
navigation: props.settings?.data?.navigation,
tabbar: props.settings?.data?.tabbar,
breadcrumb: props.settings?.data?.breadcrumb,
transition: props.settings?.data?.transition,
widget: props.settings?.data?.widget,
shortcutKeys: props.settings?.data?.shortcutKeys,
};
}
// 确认并继续
function handleConfirm() {
emit('confirm', buildSettingsData());
}
// 关闭
function handleClose() {
emit('update:visible', false);
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="app-settings-panel border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ panelTitle }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容区域 -->
<ElScrollbar class="flex-1">
<div class="grid grid-cols-2 gap-6 p-6">
<!-- 左列应用配置 + Logo配置 -->
<div class="space-y-6">
<!-- 应用配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.appConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.appName')">
<ElInput
v-model="appName"
:placeholder="
$t('ai-platform.appSettings.appNamePlaceholder')
"
clearable
/>
</Block>
<Block :title="$t('ai-platform.appSettings.defaultHomePath')">
<ElInput
v-model="appDefaultHomePath"
:placeholder="
$t('ai-platform.appSettings.defaultHomePathPlaceholder')
"
clearable
/>
</Block>
<Block :title="$t('ai-platform.appSettings.enablePreferences')">
<ElSwitch v-model="appEnablePreferences" />
</Block>
</div>
</div>
<ElDivider />
<!-- Logo配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.logoConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.logoEnable')">
<ElSwitch v-model="logoEnable" />
</Block>
<template v-if="logoEnable">
<Block :title="$t('ai-platform.appSettings.logoSource')">
<div class="flex flex-col gap-4">
<!-- Logo 预览和上传 -->
<div class="logo-preview-container flex">
<div class="flex items-center gap-4">
<div>
<div
v-if="logoDisplayUrl"
class="logo-preview"
@click="openFileSelector"
>
<img
:src="logoDisplayUrl"
alt="Logo"
class="logo-image"
/>
<div class="logo-overlay">
<span class="text-sm text-white">{{
$t('common.replace')
}}</span>
</div>
</div>
<div
v-else
class="logo-placeholder"
@click="openFileSelector"
>
<span class="text-muted-foreground text-sm">{{
$t(
'ai-platform.appSettings.logoSourcePlaceholder',
)
}}</span>
</div>
</div>
<ElButton
v-if="logoDisplayUrl"
size="small"
@click="clearLogo"
>
{{ $t('common.clear') }}
</ElButton>
</div>
</div>
<input
ref="uploadInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileInputChange"
/>
<ElInput
v-model="logoSource"
:placeholder="
$t('ai-platform.appSettings.logoSourcePlaceholder')
"
clearable
/>
</div>
</Block>
<Block :title="$t('ai-platform.appSettings.logoFit')">
<ElSelect v-model="logoFit" class="w-full">
<ElOption
v-for="item in fitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</Block>
</template>
</div>
</div>
</div>
<!-- 右列布局配置 + 主题配置 -->
<div class="space-y-6">
<!-- 布局配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.layoutConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.layout')">
<Layout v-model="appLayout" />
</Block>
</div>
</div>
<ElDivider />
<!-- 主题配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.themeConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.themeMode')">
<Theme
v-model="themeMode"
v-model:theme-semi-dark-sidebar="themeSemiDarkSidebar"
v-model:theme-semi-dark-header="themeSemiDarkHeader"
/>
</Block>
<Block :title="$t('ai-platform.appSettings.builtinTheme')">
<BuiltinTheme
v-model="themeBuiltinType"
v-model:theme-color-primary="themeColorPrimary"
:is-dark="themeMode === 'dark'"
/>
</Block>
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
<style scoped>
.app-settings-panel {
min-width: 600px;
}
.logo-preview-container {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.logo-preview {
position: relative;
width: 100px;
height: 100px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.logo-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.logo-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgb(0 0 0 / 50%);
opacity: 0;
transition: opacity 0.2s;
}
.logo-preview:hover .logo-overlay {
opacity: 1;
}
.logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.logo-placeholder:hover {
border-color: hsl(var(--primary));
}
</style>
@@ -0,0 +1,117 @@
<script lang="ts" setup>
/**
* 表单基础信息编辑组件
* 可用于:表单管理器、工作流设计预览编辑
*/
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
} from 'element-plus';
export interface BasicFormData {
name: string;
code: string;
form_type: 'normal' | 'workflow';
sort: number;
description: string;
}
interface Props {
modelValue: BasicFormData;
disabled?: boolean;
showTitle?: boolean;
labelWidth?: string;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
showTitle: true,
labelWidth: '100px',
});
const emit = defineEmits<{
'update:modelValue': [value: BasicFormData];
}>();
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
// 更新单个字段
function updateField<K extends keyof BasicFormData>(key: K, value: BasicFormData[K]) {
emit('update:modelValue', { ...props.modelValue, [key]: value });
}
</script>
<template>
<div class="basic-info-editor">
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
{{ $t('form-manager.editor.steps.basic') }}
</h3>
<ElForm
:model="formData"
:label-width="labelWidth"
label-position="right"
:disabled="disabled"
>
<ElFormItem :label="$t('form-manager.name')" required>
<ElInput
:model-value="formData.name"
:placeholder="$t('form-manager.placeholder.name')"
clearable
@update:model-value="updateField('name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.code')" required>
<ElInput
:model-value="formData.code"
:placeholder="$t('form-manager.placeholder.code')"
clearable
@update:model-value="updateField('code', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.type')" required>
<ElSelect
:model-value="formData.form_type"
:placeholder="$t('form-manager.placeholder.type')"
class="w-full"
@update:model-value="updateField('form_type', $event)"
>
<ElOption :label="$t('form-manager.typeMap.normal')" value="normal" />
<ElOption :label="$t('form-manager.typeMap.workflow')" value="workflow" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInput
:model-value="formData.sort"
type="number"
placeholder="0"
@update:model-value="updateField('sort', Number($event))"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.description')">
<ElInput
:model-value="formData.description"
type="textarea"
:rows="4"
:placeholder="$t('form-manager.editor.placeholder.remark')"
@update:model-value="updateField('description', $event)"
/>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped>
.basic-info-editor {
width: 100%;
}
</style>
@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* 仪表盘基础信息确认面板
* 复用 PageBasicInfoEditor 公共组件
*/
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
export interface DashboardBasicInfoData {
type: string;
title: string;
data: {
category: string;
code: string;
description: string;
name: string;
sort: number;
};
nodeId: string;
}
const props = defineProps<{
basicInfo?: DashboardBasicInfoData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const form = ref<PageBasicInfo>({
name: '',
code: '',
category: 'dashboard',
description: '',
sort: 0,
});
watch(
() => props.basicInfo,
(info) => {
if (info?.data) {
form.value = {
name: info.data.name || '',
code: info.data.code || '',
category: info.data.category || 'dashboard',
description: info.data.description || '',
sort: info.data.sort || 0,
};
}
},
{ immediate: true },
);
const handleConfirm = () => {
emit('confirm', { ...form.value });
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ basicInfo?.title || $t('ai-platform.dashboard.basicInfo.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-y-auto p-6">
<PageBasicInfoEditor v-model="form" />
</div>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage } from 'element-plus';
export interface DashboardDesignData {
type: string;
title: string;
data: {
dashboard_code?: string;
design_suggestion?: string;
design_title?: string;
page_config?: Record<string, any>;
};
nodeId: string;
}
const props = defineProps<{
design?: DashboardDesignData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
const isFullscreen = ref(false);
const configText = ref('{}');
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
watch(
() => props.design,
(design) => {
configText.value = JSON.stringify(design?.data?.page_config || {}, null, 2);
},
{ immediate: true },
);
function handleConfirm() {
try {
emit('confirm', {
design_title:
props.design?.data?.design_title ||
$t('ai-platform.dashboard.design.title'),
page_config: JSON.parse(configText.value || '{}'),
});
} catch {
ElMessage.error('页面配置不是有效 JSON');
}
}
function handleClose() {
dialogVisible.value = false;
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<div
class="border-border bg-muted/50 flex shrink-0 items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground flex items-center gap-3 font-medium">
<div class="bg-primary flex h-8 w-8 items-center justify-center rounded">
<span class="text-sm font-bold text-white">D</span>
</div>
<span>{{ design?.title || $t('ai-platform.dashboard.design.title') }}</span>
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<div class="bg-border mx-1 h-5 w-px"></div>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="isFullscreen = !isFullscreen"
/>
</div>
</div>
<div class="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-4 overflow-hidden p-4">
<div class="space-y-4 overflow-auto rounded-md border bg-muted/20 p-4">
<div>
<div class="text-sm font-medium">仪表盘编码</div>
<div class="mt-1 text-sm text-muted-foreground">
{{ design?.data?.dashboard_code || '-' }}
</div>
</div>
<div>
<div class="text-sm font-medium">设计标题</div>
<div class="mt-1 text-sm text-muted-foreground">
{{ design?.data?.design_title || '-' }}
</div>
</div>
<div>
<div class="text-sm font-medium">设计建议</div>
<div class="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{{ design?.data?.design_suggestion || '暂无设计建议' }}
</div>
</div>
</div>
<ElInput
v-model="configText"
class="min-h-0"
resize="none"
type="textarea"
placeholder="页面配置 JSON"
/>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
/**
* 仪表盘发布确认面板
* 复用 PagePublishInfoEditor 公共组件
*/
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
export interface DashboardPublishData {
type: string;
title: string;
data: {
application_id?: string;
dashboard_code: string;
dashboard_id: string;
menu_icon: string;
menu_name: string;
menu_order: number;
menu_parent_id?: string;
};
nodeId: string;
}
const props = defineProps<{
publishData?: DashboardPublishData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const form = ref<PagePublishInfo>({
menu_name: '',
menu_parent_id: undefined,
menu_icon: 'lucide:layout-dashboard',
menu_order: 0,
});
watch(
() => props.publishData,
(data) => {
if (data?.data) {
form.value = {
menu_name: data.data.menu_name || '',
menu_parent_id: data.data.menu_parent_id || undefined,
menu_icon: data.data.menu_icon || 'lucide:layout-dashboard',
menu_order: data.data.menu_order || 0,
};
}
},
{ immediate: true },
);
const handleConfirm = () => {
const confirmData = {
dashboard_id: props.publishData?.data.dashboard_id,
dashboard_code: props.publishData?.data.dashboard_code,
menu_name: form.value.menu_name,
menu_parent_id: form.value.menu_parent_id || '',
menu_icon: form.value.menu_icon,
menu_order: form.value.menu_order,
application_id: props.publishData?.data.application_id || '',
};
console.log('[DashboardPublishConfirmPanel] 确认数据:', confirmData);
emit('confirm', confirmData);
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ publishData?.title || $t('ai-platform.dashboard.publish.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-y-auto p-6">
<PagePublishInfoEditor
v-model="form"
:page-code="publishData?.data.dashboard_code || ''"
:show-route-info="true"
route-prefix="/page-render/"
/>
</div>
</div>
</template>
@@ -0,0 +1,288 @@
<script lang="ts" setup>
/**
* 设计编辑面板
* 用于工作流中每步设计完成后的编辑
* 使用 FormEditorContent 公共组件,根据当前步骤显示对应内容
*/
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { Maximize, Minimize } from '@vben/icons';
import { ElButton } from 'element-plus';
import FormEditorContent from './FormEditorContent.vue';
// 设计类型
export type DesignType = 'form_basic_info' | 'database_design' | 'form_ui_design' | 'list_config' | 'form_publish' | 'dashboard_basic_info' | 'dashboard_design' | 'dashboard_publish';
// 设计数据
export interface DesignData {
type: DesignType;
title: string;
data: Record<string, any>;
nodeId: string;
form_fields?: any[]; // 表单字段列表(用于列表设计)
table_configs?: any[]; // 数据表配置(用于表单设计)
}
interface Props {
visible: boolean;
design?: DesignData;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:visible': [value: boolean];
'save': [data: Record<string, any>];
'confirm': [data: Record<string, any>];
'close': [];
}>();
// 编辑器组件引用
const editorRef = ref<InstanceType<typeof FormEditorContent>>();
// 根据设计类型映射到步骤索引
const stepMap: Record<DesignType, number> = {
'form_basic_info': 0,
'database_design': 1,
'form_ui_design': 2,
'list_config': 3,
'form_publish': 4,
'dashboard_basic_info': 0,
'dashboard_design': 0,
'dashboard_publish': 0,
};
// 当前步骤
const currentStep = ref(0);
// 面板标题
const panelTitle = computed(() => {
if (!props.design) return '';
return props.design.title;
});
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 监听设计数据变化,设置当前步骤和初始化数据
watch(
() => props.design,
(newDesign) => {
if (!newDesign) return;
// 根据设计类型设置当前步骤
currentStep.value = stepMap[newDesign.type] ?? 0;
// 初始化编辑器数据
if (editorRef.value) {
initEditorData(newDesign);
}
},
{ immediate: true }
);
// 监听编辑器引用变化,初始化数据
watch(
() => editorRef.value,
(editor) => {
if (editor && props.design) {
initEditorData(props.design);
}
}
);
// 初始化编辑器数据
function initEditorData(design: DesignData) {
if (!editorRef.value) return;
switch (design.type) {
case 'form_basic_info': {
const data = design.data || {};
editorRef.value.setData({
basicForm: {
name: data.name || '',
code: data.code || '',
form_type: data.form_type || 'normal',
sort: data.sort || 0,
description: data.description || '',
},
});
break;
}
case 'database_design': {
const tableData = design.data?.table || design.data || {};
// 优先从 meta 中读取 schema,兼容旧数据从顶层读取
const metaData = tableData.meta || {};
editorRef.value.setData({
tableConfigs: [{
id: 'main-table',
type: 'main',
tableName: tableData.tableName || '',
alias: tableData.alias || tableData.tableName || '',
fields: tableData.fields || [],
meta: {
schema: metaData.schema || tableData.schema || '',
schemaRaw: metaData.schemaRaw || tableData.schemaRaw || '',
database: metaData.database || tableData.database || '',
},
}],
});
break;
}
case 'form_ui_design': {
const formConfig = design.data || {};
// 从 design 中获取 table_configs(后端在 waiting_config 中传递)
const tableConfigs = design.table_configs || design.data?.table_configs || [];
editorRef.value.setData({
tableConfigs: tableConfigs,
formConfig: {
items: formConfig.items || [],
labelWidth: formConfig.labelWidth || 120,
labelPosition: formConfig.labelPosition || 'right',
size: formConfig.size || 'default',
formPadding: formConfig.formPadding || 20,
formMargin: formConfig.formMargin || 0,
itemSpacing: formConfig.itemSpacing || 18,
formWidth: formConfig.formWidth || '100%',
formMaxWidth: formConfig.formMaxWidth || 0,
formBackground: formConfig.formBackground || false,
formBorder: formConfig.formBorder || false,
formBorderRadius: formConfig.formBorderRadius || 4,
formShadow: formConfig.formShadow || false,
disabled: formConfig.disabled || false,
tableConfigs: tableConfigs,
},
});
break;
}
case 'list_config': {
// 从 design 中获取 form_fields(后端在 waiting_config 中传递)
const formFields = design.form_fields || design.data?.form_fields || [];
editorRef.value.setData({
listConfig: design.data || {},
formFields: formFields,
});
break;
}
case 'form_publish': {
const publishData = design.data || {};
editorRef.value.setData({
publishData: {
menu_name: publishData.menu_name || '',
menu_parent_id: publishData.menu_parent_id,
menu_icon: publishData.menu_icon || 'lucide:file-text',
menu_order: publishData.menu_order ?? 1,
},
});
break;
}
}
}
// 获取当前编辑的数据
function getCurrentData(): Record<string, any> {
if (!editorRef.value) return {};
const data = editorRef.value.getData();
const designType = props.design?.type;
switch (designType) {
case 'form_basic_info':
return { ...data.basicForm };
case 'database_design':
return {
type: data.tableConfigs[0]?.type || 'main', // 添加 type 字段,默认为 main
table: data.tableConfigs[0] ? {
tableName: data.tableConfigs[0].tableName,
alias: data.tableConfigs[0].alias,
fields: data.tableConfigs[0].fields,
meta: {
schema: data.tableConfigs[0].meta?.schema || '',
schemaRaw: data.tableConfigs[0].meta?.schemaRaw || '',
database: data.tableConfigs[0].meta?.database || '',
},
} : {},
dbConfig: props.design?.data?.dbConfig || 'default', // 保留原有的 dbConfig
};
case 'form_ui_design':
return {
items: data.formConfig.items,
labelWidth: data.formConfig.labelWidth,
labelPosition: data.formConfig.labelPosition,
};
case 'list_config':
return { ...data.listConfig };
case 'form_publish':
return { ...data.publishData };
default:
return {};
}
}
// 确认并继续
function handleConfirm() {
const data = getCurrentData();
emit('confirm', data);
}
// 关闭
function handleClose() {
emit('update:visible', false);
emit('close');
}
</script>
<template>
<div
v-if="visible"
:class="[
'border-border bg-card flex flex-col rounded-lg',
isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full'
]"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ panelTitle }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">{{ $t('common.cancel') }}</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">{{ $t('common.confirmAndContinue') }}</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容区域使用 FormEditorContent 公共组件 -->
<div class="flex-1 overflow-hidden">
<FormEditorContent
ref="editorRef"
:step="currentStep"
:show-steps="true"
:show-actions="false"
:workflow-mode="true"
@update:step="currentStep = $event"
/>
</div>
</div>
</template>
<style scoped>
.design-editor-panel {
/* box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); */
}
</style>
@@ -0,0 +1,329 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElSelect,
ElSteps,
ElStep,
} from 'element-plus';
import PublishInfoEditor, { type FormPublishInput } from './PublishInfoEditor.vue';
export interface BasicFormData {
code: string;
description: string;
form_type: 'normal' | 'workflow';
name: string;
sort: number;
}
export interface TableConfig {
alias?: string;
fields?: any[];
id?: string;
meta?: Record<string, any>;
tableName?: string;
type?: string;
}
export interface FormEditorData {
basicForm: BasicFormData;
formConfig: {
disabled: boolean;
formBackground: boolean;
formBorder: boolean;
formBorderRadius: number;
formMargin: number;
formMaxWidth: number;
formPadding: number;
formShadow: boolean;
formWidth: string;
items: any[];
itemSpacing: number;
labelPosition: 'left' | 'right' | 'top';
labelWidth: number;
lifecycleHooks?: any[];
size: 'default' | 'large' | 'small';
tableConfigs: TableConfig[];
};
formFields: any[];
listConfig: Record<string, any>;
publishData?: FormPublishInput;
tableConfigs: TableConfig[];
}
const props = withDefaults(
defineProps<{
initialBasicForm?: BasicFormData;
initialListDesign?: Record<string, any>;
initialTableConfigs?: TableConfig[];
readonly?: boolean;
showActions?: boolean;
showSteps?: boolean;
step?: number;
workflowMode?: boolean;
}>(),
{
initialBasicForm: undefined,
initialListDesign: undefined,
initialTableConfigs: undefined,
readonly: false,
showActions: true,
showSteps: true,
step: undefined,
workflowMode: false,
},
);
const emit = defineEmits<{
cancel: [];
save: [data: FormEditorData];
'step-change': [step: number];
'update:step': [step: number];
}>();
const internalStep = ref(0);
const currentStep = computed({
get: () => (props.step === undefined ? internalStep.value : props.step),
set: (val) => {
if (props.step === undefined) internalStep.value = val;
else emit('update:step', val);
emit('step-change', val);
},
});
const basicForm = ref<BasicFormData>({
code: '',
description: '',
form_type: 'normal',
name: '',
sort: 0,
});
const tableConfigs = ref<TableConfig[]>([]);
const formConfigText = ref('{}');
const listConfigText = ref('{}');
const formFields = ref<any[]>([]);
const publishData = ref<FormPublishInput>({
menu_icon: 'lucide:file-text',
menu_name: '',
menu_order: 0,
menu_parent_id: undefined,
});
const steps = computed(() => {
const base = [
{ title: $t('form-manager.editor.steps.basic') || '基础信息' },
{ title: $t('form-manager.editor.steps.database') || '数据表' },
{ title: $t('form-manager.editor.steps.form') || '表单设计' },
{ title: $t('form-manager.editor.steps.list') || '列表设计' },
];
if (props.workflowMode) {
base.push({ title: $t('form-manager.editor.steps.publish') || '发布配置' });
}
return base;
});
const canGoPrev = computed(() => currentStep.value > 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
watch(
() => props.initialBasicForm,
(val) => {
if (val) basicForm.value = { ...val };
},
{ immediate: true },
);
watch(
() => props.initialTableConfigs,
(val) => {
if (val) tableConfigs.value = [...val];
},
{ immediate: true },
);
watch(
() => props.initialListDesign,
(val) => {
if (val) listConfigText.value = JSON.stringify(val, null, 2);
},
{ immediate: true },
);
watch(
() => basicForm.value.name,
(name) => {
if (props.workflowMode && name && !publishData.value.menu_name) {
publishData.value.menu_name = name;
}
},
);
function parseJson(text: string, fallback: any) {
if (!text.trim()) return fallback;
return JSON.parse(text);
}
function buildDefaultFormConfig(items: any[] = []) {
return {
disabled: false,
formBackground: false,
formBorder: false,
formBorderRadius: 4,
formMargin: 0,
formMaxWidth: 0,
formPadding: 20,
formShadow: false,
formWidth: '100%',
items,
itemSpacing: 18,
labelPosition: 'right' as const,
labelWidth: 120,
size: 'default' as const,
tableConfigs: tableConfigs.value,
};
}
function getData(): FormEditorData {
const formConfig = {
...buildDefaultFormConfig(),
...parseJson(formConfigText.value, {}),
};
const listConfig = parseJson(listConfigText.value, {});
return {
basicForm: { ...basicForm.value },
formConfig,
formFields: [...formFields.value],
listConfig,
publishData: props.workflowMode ? { ...publishData.value } : undefined,
tableConfigs: [...tableConfigs.value],
};
}
function setData(data: Partial<FormEditorData>) {
if (data.basicForm) basicForm.value = { ...data.basicForm };
if (data.tableConfigs) tableConfigs.value = [...data.tableConfigs];
if (data.formConfig) {
formConfigText.value = JSON.stringify(data.formConfig, null, 2);
formFields.value = data.formFields || data.formConfig.items || [];
}
if (data.listConfig) listConfigText.value = JSON.stringify(data.listConfig, null, 2);
if (data.formFields) formFields.value = [...data.formFields];
if (data.publishData) publishData.value = { ...publishData.value, ...data.publishData };
}
function handlePrev() {
if (canGoPrev.value) currentStep.value -= 1;
}
function handleNext() {
if (!isLastStep.value) currentStep.value += 1;
}
function handleSave() {
try {
emit('save', getData());
} catch {
ElMessage.error('JSON 配置格式不正确');
}
}
defineExpose({ getData, setData });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="showSteps" class="border-border border-b px-6 py-4">
<ElSteps :active="currentStep" align-center finish-status="success">
<ElStep v-for="item in steps" :key="item.title" :title="item.title" />
</ElSteps>
</div>
<div class="min-h-0 flex-1 overflow-auto p-6">
<ElForm v-if="currentStep === 0" :model="basicForm" label-width="100px">
<ElFormItem label="名称" required>
<ElInput v-model="basicForm.name" :disabled="readonly" />
</ElFormItem>
<ElFormItem label="编码" required>
<ElInput v-model="basicForm.code" :disabled="readonly" />
</ElFormItem>
<ElFormItem label="类型">
<ElSelect v-model="basicForm.form_type" :disabled="readonly" class="w-full">
<ElOption label="普通表单" value="normal" />
<ElOption label="流程表单" value="workflow" />
</ElSelect>
</ElFormItem>
<ElFormItem label="排序">
<ElInputNumber v-model="basicForm.sort" :disabled="readonly" class="w-full" />
</ElFormItem>
<ElFormItem label="描述">
<ElInput v-model="basicForm.description" :disabled="readonly" type="textarea" />
</ElFormItem>
</ElForm>
<div v-else-if="currentStep === 1" class="space-y-3">
<div class="text-sm text-muted-foreground">
轻量版保留 AI 生成的数据表结构确认不加载在线开发表设计器
</div>
<ElInput
:model-value="JSON.stringify(tableConfigs, null, 2)"
readonly
resize="none"
type="textarea"
/>
</div>
<div v-else-if="currentStep === 2" class="h-full min-h-[420px]">
<ElInput
v-model="formConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="表单配置 JSON"
/>
</div>
<div v-else-if="currentStep === 3" class="h-full min-h-[420px]">
<ElInput
v-model="listConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="列表配置 JSON"
/>
</div>
<PublishInfoEditor
v-else
v-model="publishData"
:form-code="basicForm.code"
/>
</div>
<div v-if="showActions" class="border-border flex justify-end gap-2 border-t px-6 py-4">
<ElButton @click="emit('cancel')">{{ $t('common.cancel') }}</ElButton>
<ElButton :disabled="!canGoPrev" @click="handlePrev">上一步</ElButton>
<ElButton v-if="!isLastStep" type="primary" @click="handleNext">下一步</ElButton>
<ElButton v-else type="primary" @click="handleSave">{{ $t('common.save') }}</ElButton>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -0,0 +1,140 @@
<script lang="ts" setup>
/**
* 页面基础信息编辑器
* 供 page-manager 和工作流节点共用
*/
import { watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
export interface PageBasicInfo {
name: string;
code: string;
category: string;
description: string;
sort: number;
}
interface Props {
modelValue: PageBasicInfo;
isEditMode?: boolean;
showTitle?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isEditMode: false,
showTitle: false,
});
const emit = defineEmits<{
'update:modelValue': [value: PageBasicInfo];
}>();
// 更新表单数据
function updateFormData(key: keyof PageBasicInfo, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 分类选项
const categoryOptions = [
{ label: $t('page-manager.categoryMap.dashboard'), value: 'dashboard' },
{ label: $t('page-manager.categoryMap.portal'), value: 'portal' },
{ label: $t('page-manager.categoryMap.databoard'), value: 'databoard' },
{ label: $t('page-manager.categoryMap.other'), value: 'other' },
];
// 监听 modelValue 变化
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<div>
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
{{ $t('page-manager.editor.steps.basic') }}
</h3>
<ElForm :model="modelValue" label-width="100px" label-position="right">
<ElFormItem :label="$t('page-manager.name')" required>
<ElInput
:model-value="modelValue.name"
:placeholder="$t('page-manager.placeholder.name')"
clearable
@update:model-value="updateFormData('name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.code')" required>
<ElInput
:model-value="modelValue.code"
:placeholder="$t('page-manager.placeholder.code')"
clearable
:disabled="isEditMode"
@update:model-value="updateFormData('code', $event)"
/>
<template #error>
<div
v-if="
modelValue.code &&
!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(modelValue.code)
"
class="el-form-item__error"
>
{{ $t('page-manager.codeFormatError') }}
</div>
</template>
</ElFormItem>
<ElFormItem :label="$t('page-manager.category')">
<ElSelect
:model-value="modelValue.category"
:placeholder="$t('page-manager.placeholder.category')"
class="w-full"
clearable
@update:model-value="updateFormData('category', $event)"
>
<ElOption
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.sort"
:min="0"
:max="9999"
class="w-full"
@update:model-value="updateFormData('sort', $event ?? 0)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.description')">
<ElInput
:model-value="modelValue.description"
type="textarea"
:rows="4"
:placeholder="$t('page-manager.placeholder.description')"
@update:model-value="updateFormData('description', $event)"
/>
</ElFormItem>
</ElForm>
</div>
</template>
@@ -0,0 +1,210 @@
<script lang="ts" setup>
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage, ElStep, ElSteps } from 'element-plus';
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
export interface PageEditorData {
basicForm: PageBasicInfo;
pageConfig: Record<string, any>;
publishData?: PagePublishInfo;
}
const props = withDefaults(
defineProps<{
initialBasicForm?: PageBasicInfo;
initialPageConfig?: Record<string, any>;
initialPublishData?: PagePublishInfo;
isEditMode?: boolean;
readonly?: boolean;
showActions?: boolean;
showSteps?: boolean;
step?: number;
workflowMode?: boolean;
}>(),
{
initialBasicForm: undefined,
initialPageConfig: undefined,
initialPublishData: undefined,
isEditMode: false,
readonly: false,
showActions: true,
showSteps: true,
step: undefined,
workflowMode: false,
},
);
const emit = defineEmits<{
cancel: [];
save: [data: PageEditorData];
'step-change': [step: number];
'update:step': [step: number];
}>();
const internalStep = ref(0);
const currentStep = computed({
get: () => (props.step === undefined ? internalStep.value : props.step),
set: (val) => {
if (props.step === undefined) internalStep.value = val;
else emit('update:step', val);
emit('step-change', val);
},
});
const basicForm = ref<PageBasicInfo>({
category: 'dashboard',
code: '',
description: '',
name: '',
sort: 0,
});
const pageConfigText = ref('{}');
const publishData = ref<PagePublishInfo>({
menu_icon: 'lucide:layout-dashboard',
menu_name: '',
menu_order: 0,
menu_parent_id: undefined,
});
const steps = computed(() => {
const base = [
{ title: $t('page-manager.editor.steps.basic') || '基础信息' },
{ title: $t('page-manager.editor.steps.design') || '页面设计' },
];
if (props.workflowMode) {
base.push({ title: $t('page-manager.editor.steps.publish') || '发布配置' });
}
return base;
});
const canGoPrev = computed(() => currentStep.value > 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
watch(
() => props.initialBasicForm,
(val) => {
if (val) basicForm.value = { ...val };
},
{ immediate: true },
);
watch(
() => props.initialPageConfig,
(val) => {
if (val) pageConfigText.value = JSON.stringify(val, null, 2);
},
{ immediate: true },
);
watch(
() => props.initialPublishData,
(val) => {
if (val) publishData.value = { ...val };
},
{ immediate: true },
);
watch(
() => basicForm.value.name,
(name) => {
if (props.workflowMode && name && !publishData.value.menu_name) {
publishData.value.menu_name = name;
}
},
);
function getData(): PageEditorData {
const pageConfig = pageConfigText.value.trim()
? JSON.parse(pageConfigText.value)
: {};
return {
basicForm: { ...basicForm.value },
pageConfig,
publishData: props.workflowMode ? { ...publishData.value } : undefined,
};
}
function setData(data: Partial<PageEditorData>) {
if (data.basicForm) basicForm.value = { ...data.basicForm };
if (data.pageConfig) pageConfigText.value = JSON.stringify(data.pageConfig, null, 2);
if (data.publishData) publishData.value = { ...publishData.value, ...data.publishData };
}
function handlePrev() {
if (canGoPrev.value) currentStep.value -= 1;
}
function handleNext() {
if (!isLastStep.value) currentStep.value += 1;
}
function handleSave() {
try {
emit('save', getData());
} catch {
ElMessage.error('页面配置不是有效 JSON');
}
}
defineExpose({ getData, setData });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="showSteps" class="border-border border-b px-6 py-4">
<ElSteps :active="currentStep" align-center finish-status="success">
<ElStep v-for="item in steps" :key="item.title" :title="item.title" />
</ElSteps>
</div>
<div class="min-h-0 flex-1 overflow-auto p-6">
<PageBasicInfoEditor
v-if="currentStep === 0"
v-model="basicForm"
:is-edit-mode="isEditMode"
/>
<div v-else-if="currentStep === 1" class="h-full min-h-[460px]">
<div class="mb-3 text-sm text-muted-foreground">
轻量版保留 AI 生成页面配置确认不加载大屏可视化设计器
</div>
<ElInput
v-model="pageConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="页面配置 JSON"
/>
</div>
<PagePublishInfoEditor
v-else
v-model="publishData"
:page-code="basicForm.code"
/>
</div>
<div v-if="showActions" class="border-border flex justify-end gap-2 border-t px-6 py-4">
<ElButton @click="emit('cancel')">{{ $t('common.cancel') }}</ElButton>
<ElButton :disabled="!canGoPrev" @click="handlePrev">上一步</ElButton>
<ElButton v-if="!isLastStep" type="primary" @click="handleNext">下一步</ElButton>
<ElButton v-else type="primary" @click="handleSave">{{ $t('common.save') }}</ElButton>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts" setup>
/**
* 页面发布信息编辑器
* 供 page-manager 和工作流节点共用
*/
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
import { useAppContextStore } from '#/store/app-context';
export interface PagePublishInfo {
menu_name: string;
menu_parent_id?: string;
menu_icon: string;
menu_order: number;
}
interface Props {
modelValue: PagePublishInfo;
pageCode?: string;
showRouteInfo?: boolean;
routePrefix?: string;
}
const props = withDefaults(defineProps<Props>(), {
pageCode: '',
showRouteInfo: true,
routePrefix: '/page/',
});
const emit = defineEmits<{
'update:modelValue': [value: PagePublishInfo];
}>();
const appContextStore = useAppContextStore();
// 更新表单数据
function updateFormData(key: keyof PagePublishInfo, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 菜单选择回调
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
if (menu && !Array.isArray(menu)) {
updateFormData('menu_parent_id', menu.id);
} else {
updateFormData('menu_parent_id', undefined);
}
}
// 监听 modelValue 变化
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<ElForm :model="modelValue" label-width="100px">
<!-- 菜单配置 -->
<div class="mb-4 font-medium">
{{ $t('page-manager.publishDialog.menuConfig') }}
</div>
<ElFormItem :label="$t('page-manager.publishDialog.menuName')" required>
<ElInput
:model-value="modelValue.menu_name"
:placeholder="$t('page-manager.placeholder.name')"
@update:model-value="updateFormData('menu_name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.publishDialog.parentMenu')">
<ZqMenuSelector
:model-value="modelValue.menu_parent_id || null"
mode="dialog"
:placeholder="$t('page-manager.publishDialog.parentMenuPlaceholder')"
:application-id="appContextStore.currentApp?.id"
@change="handleMenuChange"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.publishDialog.menuIcon')">
<ZqIconPicker
:model-value="modelValue.menu_icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
@update:model-value="updateFormData('menu_icon', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.menu_order"
:min="0"
:max="9999"
class="w-full"
@update:model-value="updateFormData('menu_order', $event ?? 0)"
/>
</ElFormItem>
<!-- 路由信息 -->
<template v-if="showRouteInfo && pageCode">
<div class="mb-4 mt-6 font-medium">
{{ $t('page-manager.publishDialog.routeInfo') }}
</div>
<ElFormItem :label="$t('page-manager.publishDialog.accessPath')">
<ElInput
:model-value="`${routePrefix}${pageCode}`"
disabled
class="text-muted-foreground"
/>
</ElFormItem>
</template>
</ElForm>
</template>
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
import { useAppContextStore } from '#/store/app-context';
interface Props {
modelValue: FormPublishInput;
formCode?: string;
showRouteInfo?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
formCode: '',
showRouteInfo: true,
});
const emit = defineEmits<{
'update:modelValue': [value: FormPublishInput];
}>();
const appContextStore = useAppContextStore();
// 更新表单数据
function updateFormData(key: keyof FormPublishInput, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 菜单选择回调
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
if (menu && !Array.isArray(menu)) {
updateFormData('menu_parent_id', menu.id);
} else {
updateFormData('menu_parent_id', undefined);
}
}
// 监听 modelValue 变化,确保响应式
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<ElForm :model="modelValue" label-width="100px">
<!-- 菜单配置 -->
<div class="mb-4 font-medium">
{{ $t('form-manager.publishDialog.menuConfig') }}
</div>
<ElFormItem :label="$t('form-manager.publishDialog.menuName')" required>
<ElInput
:model-value="modelValue.menu_name"
:placeholder="$t('form-manager.placeholder.name')"
@update:model-value="updateFormData('menu_name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.parentMenu')">
<ZqMenuSelector
:model-value="modelValue.menu_parent_id || null"
mode="dialog"
:placeholder="$t('form-manager.publishDialog.parentMenuPlaceholder')"
:application-id="appContextStore.currentApp?.id"
@change="handleMenuChange"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.menuIcon')">
<ZqIconPicker
:model-value="modelValue.menu_icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
@update:model-value="updateFormData('menu_icon', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.menu_order"
:min="0"
:max="999"
class="w-full"
@update:model-value="updateFormData('menu_order', $event)"
/>
</ElFormItem>
<!-- 路由信息 -->
<template v-if="showRouteInfo && formCode">
<div class="mb-4 mt-6 font-medium">
{{ $t('form-manager.publishDialog.routeInfo') }}
</div>
<ElFormItem :label="$t('form-manager.publishDialog.accessPath')">
<ElInput
:model-value="`/form-render/${formCode}`"
disabled
class="text-muted-foreground"
/>
</ElFormItem>
</template>
</ElForm>
</template>
export interface FormPublishInput {
menu_icon: string;
menu_name: string;
menu_order: number;
menu_parent_id?: string;
}
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
AppWindow,
CircleCheck,
ExternalLink,
FileText,
LayoutDashboard,
Maximize,
Minimize,
} from '@vben/icons';
import { useI18n } from '@vben/locales';
import { ElButton, ElCard, ElEmpty } from 'element-plus';
export interface SystemSummaryData {
type: 'system_summary';
title: string;
data: {
app?: {
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
};
base_url?: string;
created_at: string;
dashboard?: {
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
};
forms: Array<{
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
}>;
statistics?: {
forms_count: number;
has_app: boolean;
has_dashboard: boolean;
total_modules: number;
};
title: string;
};
nodeId: string;
}
const props = defineProps<{
data?: SystemSummaryData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: any];
'update:visible': [value: boolean];
}>();
const { t } = useI18n();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const summaryData = computed(() => props.data?.data || ({} as any));
const statistics = computed(() => summaryData.value.statistics);
const app = computed(() => summaryData.value.app);
const forms = computed(() => summaryData.value.forms || []);
const dashboard = computed(() => summaryData.value.dashboard);
const handleConfirm = () => {
emit('confirm', summaryData.value);
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
const openLink = (link: string) => {
if (link) {
const baseUrl = summaryData.value.base_url || '';
const fullUrl = baseUrl ? `${baseUrl}${link}` : link;
window.open(fullUrl, '_blank');
}
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ data?.title || t('ai.systemSummary.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
<CircleCheck class="mr-1 size-4" />
{{ t('ai.systemSummary.complete') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-auto p-6">
<div
class="mb-6 flex items-center gap-3 border-b border-green-200 pb-4 dark:border-green-800"
>
<div
class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900"
>
<CircleCheck class="size-6 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 class="text-xl font-semibold text-green-700 dark:text-green-300">
{{ summaryData.title || t('ai.systemSummary.title') }}
</h2>
<p class="text-muted-foreground text-sm">
{{ t('ai.systemSummary.subtitle') }}
</p>
</div>
</div>
<div v-if="statistics" class="mb-6 grid grid-cols-3 gap-4">
<div
class="rounded-lg border bg-blue-50 p-4 text-center dark:bg-blue-950"
>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ statistics.has_app ? 1 : 0 }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.app') }}
</div>
</div>
<div
class="rounded-lg border bg-purple-50 p-4 text-center dark:bg-purple-950"
>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400">
{{ statistics.forms_count }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.formModules') }}
</div>
</div>
<div
class="rounded-lg border bg-orange-50 p-4 text-center dark:bg-orange-950"
>
<div class="text-3xl font-bold text-orange-600 dark:text-orange-400">
{{ statistics.has_dashboard ? 1 : 0 }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.dashboard') }}
</div>
</div>
</div>
<div v-if="app" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<AppWindow class="size-5 text-blue-500" />
{{ t('ai.systemSummary.appInfo') }}
</h3>
<ElCard
shadow="hover"
class="cursor-pointer"
@click="openLink(app.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"
>
<AppWindow class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="font-medium">{{ app.name }}</div>
<div class="text-muted-foreground text-sm">
{{ app.description || app.code }}
</div>
</div>
</div>
<ElButton
v-if="app.link"
type="primary"
link
@click.stop="openLink(app.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
<div v-if="forms.length > 0" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<FileText class="size-5 text-purple-500" />
{{ t('ai.systemSummary.formModulesTitle') }}
<span class="text-muted-foreground text-sm">({{ forms.length }})</span>
</h3>
<div class="space-y-3">
<ElCard
v-for="form in forms"
:key="form.id || form.code"
shadow="hover"
class="cursor-pointer"
@click="openLink(form.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900"
>
<FileText
class="size-5 text-purple-600 dark:text-purple-400"
/>
</div>
<div>
<div class="font-medium">{{ form.name }}</div>
<div class="text-muted-foreground text-sm">
{{ form.description || form.code }}
</div>
</div>
</div>
<ElButton
v-if="form.link"
type="primary"
link
@click.stop="openLink(form.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
</div>
<div v-if="dashboard" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<LayoutDashboard class="size-5 text-orange-500" />
{{ t('ai.systemSummary.dashboardTitle') }}
</h3>
<ElCard
shadow="hover"
class="cursor-pointer"
@click="openLink(dashboard.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900"
>
<LayoutDashboard
class="size-5 text-orange-600 dark:text-orange-400"
/>
</div>
<div>
<div class="font-medium">{{ dashboard.name }}</div>
<div class="text-muted-foreground text-sm">
{{ dashboard.description || dashboard.code }}
</div>
</div>
</div>
<ElButton
v-if="dashboard.link"
type="primary"
link
@click.stop="openLink(dashboard.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
<ElEmpty
v-if="!app && forms.length === 0 && !dashboard"
:description="t('ai.systemSummary.noData')"
/>
</div>
</div>
</template>