chore: save work before switching to dev

This commit is contained in:
2026-04-24 09:36:56 +08:00
parent 9e45acf60b
commit 415ba67d01
14 changed files with 587 additions and 583 deletions

View File

@@ -1,20 +1,9 @@
<template>
<div class="creation-page" :class="{ 'is-submitting': submitLoading }">
<div v-if="submitLoading" class="creation-loading-mask">
<div class="creation-loading-card">
<div class="loading-orbit">
<span class="loading-ring ring-outer"></span>
<span class="loading-ring ring-inner"></span>
<span class="loading-core"></span>
</div>
<div class="loading-title">正在创作中</div>
<div class="loading-desc">内容生成较慢请稍候创作完成后会自动刷新结果</div>
</div>
</div>
<div class="creation-page">
<div class="panel left" v-loading="treeLoading">
<div class="title">工作空间</div>
<div class="tree-wrap">
<el-empty v-if="treeNodes.length === 0 && !treeLoading" description="暂无作品数据" />
<el-empty v-if="!treeLoading && treeNodes.length === 0" description="暂无作品数据" />
<el-tree
v-else
:data="treeNodes"
@@ -27,235 +16,185 @@
>
<template #default="{ data }">
<div class="tree-node">
<div class="tree-node-main">
<el-icon v-if="data.nodeType === 'date'"><ele-Calendar /></el-icon>
<el-icon v-else-if="data.nodeType === 'contentType'"><ele-Collection /></el-icon>
<el-icon v-else-if="data.nodeType === 'theme'"><ele-CollectionTag /></el-icon>
<el-icon v-else-if="data.nodeType === 'title'"><ele-FolderOpened /></el-icon>
<el-icon v-else-if="data.nodeType === 'html'"><ele-Document /></el-icon>
<el-icon v-else><ele-Picture /></el-icon>
<span class="ellipsis">{{ data.label }}</span>
</div>
<span class="ellipsis">{{ data.label }}</span>
<el-button
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
type="primary"
link
class="tree-download"
@click.stop="downloadNode(data)"
><el-icon><ele-Download /></el-icon
></el-button>
>下载</el-button
>
</div>
</template>
</el-tree>
</div>
</div>
<div class="panel center">
<div class="title">内容创建参数配置</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
<div class="form-grid">
<el-form-item label="1. 创作模式" prop="mode" class="span-1"
><el-select v-model="formData.mode"><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="2. 内容类型" prop="content_type" class="span-1"
><el-select v-model="formData.content_type"
><el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="3. 主题(系列名)" prop="theme" class="span-1"
><el-input v-model="formData.theme" placeholder="例如:春季通勤穿搭、小个子显高技巧"
/></el-form-item>
<el-form-item label="4. 标题(具体标题)" prop="title" class="span-1"
><el-input v-model="formData.title" placeholder="例如通勤穿搭技巧、5个显高穿搭法则"
/></el-form-item>
<el-form-item label="5. 内容风格" prop="style" class="span-1"
><el-select v-model="formData.style"><el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="6. 生成条数" prop="count" class="span-1"
><el-input-number v-model="formData.count" :min="1" :max="3" controls-position="right" class="w100"
/></el-form-item>
<el-form-item v-if="showImageConfig" label="7. 每条配图数量" prop="image_per_post" class="span-1"
><el-input-number v-model="formData.image_per_post" :min="1" :max="3" controls-position="right" class="w100"
/></el-form-item>
<el-form-item v-if="showImageConfig" label="8. 图片比例" prop="image_ratio" class="span-1"
><el-select v-model="formData.image_ratio"
><el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item :label="showImageConfig ? '9. 描述' : '7. 描述'" prop="description" class="span-2 description-item"
><el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入内容补充描述、重点要求或限制条件"
/></el-form-item>
<div class="editor-shell">
<div class="panel top">
<div>
<div class="title">作品创作工作流</div>
<div class="sub">右侧可编辑属性底部同时展示原生 JSON 和后端 DSL</div>
</div>
<el-button type="primary" class="submit-btn" :loading="submitLoading" @click="handleSubmit">告诉我你的选择我马上开始创作</el-button>
</el-form>
</div>
<div class="panel right" v-loading="previewLoading">
<div class="title preview-title">预览区域</div>
<div class="preview-main">
<el-empty v-if="!selectedPreview" description="请选择预览节点" />
<iframe v-else-if="selectedPreview.nodeType === 'html'" :src="selectedPreview.url" class="iframe" frameborder="0"></iframe>
<div v-else class="img-wrap">
<el-image :src="selectedPreview.url" :preview-src-list="[selectedPreview.url]" fit="contain" preview-teleported class="img" />
<div class="actions">
<el-button @click="resetFlow">重置示例</el-button>
<el-button @click="showDsl = !showDsl">{{ showDsl ? '收起 DSL' : '展开 DSL' }}</el-button>
<el-button type="primary" @click="syncDsl">同步 DSL</el-button>
</div>
</div>
<div class="main">
<div class="panel canvas-panel">
<div class="meta">
<span>工作流画布</span><span>节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}</span>
</div>
<div class="canvas-wrap"><div ref="logicFlowRef" class="logicflow-canvas"></div></div>
</div>
<div class="panel side">
<div class="title-sm">当前选中元素</div>
<el-empty v-if="!selectedElement" description="请先点击一个节点或连线" :image-size="84" />
<div v-else class="form-wrap">
<div>ID{{ selectedElement.id }}</div>
<div>分类{{ selectedElement.kind }}</div>
<div>业务类型{{ formState.nodeCode || '-' }}</div>
<el-form label-position="top" class="prop-form">
<el-form-item v-if="selectedElement.kind === 'node'" label="节点名称"><el-input v-model="formState.text" /></el-form-item>
<el-form-item v-if="selectedElement.kind === 'node'" label="业务类型"><el-input v-model="formState.nodeCode" /></el-form-item>
<el-form-item v-if="selectedElement.kind === 'edge' || formState.nodeCode === 'theme-input'" label="字段"
><el-input v-model="formState.field"
/></el-form-item>
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="模型"><el-input v-model="formState.model" /></el-form-item>
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="Temperature"
><el-input-number v-model="formState.temperature" :min="0" :max="2" :step="0.1" class="w100"
/></el-form-item>
<el-form-item v-if="formState.nodeCode === 'image-agent'" label="图片比例"><el-input v-model="formState.imageRatio" /></el-form-item>
<el-form-item v-if="formState.nodeCode === 'publish-output'" label="发布渠道"><el-input v-model="formState.channel" /></el-form-item>
<el-button type="primary" class="w100" @click="applySelected">应用到当前元素</el-button>
</el-form>
<pre class="json-box">{{ pretty(selectedElement.properties) }}</pre>
</div>
</div>
</div>
<div v-show="showDsl" class="panel dsl">
<div class="title-sm">LogicFlow 原生 JSON</div>
<pre class="json-box">{{ pretty(flowDsl) }}</pre>
<div class="title-sm">发给后端的业务 DSL JSON</div>
<pre class="json-box">{{ pretty(workflowDsl) }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import {
createCreation,
downloadToFile,
getCreationList,
type CreationListParams,
type CreationSubmitParams,
type CreationTreeItem,
} from '/@/api/digitalHuman/creation';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import LogicFlow from '@logicflow/core';
import { Control, DndPanel, SelectionSelect } from '@logicflow/extension';
import '@logicflow/core/dist/index.css';
import '@logicflow/extension/lib/style/index.css';
import { downloadToFile, getCreationList, type CreationListParams, type CreationTreeItem } from '/@/api/digitalHuman/creation';
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
type Item = Record<string, any>;
interface TreeNode {
id: string;
label: string;
nodeType: NodeType;
children?: TreeNode[];
createdDate?: string;
contentType?: string;
theme?: string;
creationTitle?: string;
fileUrl?: string;
}
interface PreviewState {
url: string;
nodeType: 'html' | 'image';
interface SelectedState {
id: string;
type: string;
kind: 'node' | 'edge';
properties: Item;
text?: string;
}
const formRef = ref<FormInstance>();
const treeLoading = ref(false);
const submitLoading = ref(false);
const previewLoading = ref(false);
const imgAddressPrefix = ref('');
const treeNodes = ref<TreeNode[]>([]);
const selectedPreview = ref<PreviewState | null>(null);
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const imgAddressPrefix = ref('');
const selectedElement = ref<SelectedState | null>(null);
const flowDsl = ref<{ nodes: Item[]; edges: Item[] }>({ nodes: [], edges: [] });
const logicFlowRef = ref<HTMLDivElement | null>(null);
const logicFlowInstance = ref<LogicFlow | null>(null);
const showDsl = ref(false);
const formState = reactive({ text: '', nodeCode: '', field: '', model: '', temperature: 0.7, imageRatio: '3:4', channel: '' });
const treeProps = { children: 'children', label: 'label' };
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
const formData = reactive<CreationSubmitParams>({
mode: '混合模式(文案 + 图片)',
content_type: '穿搭分享',
theme: '',
title: '',
description: '',
style: '生活分享 — 亲切自然,像朋友聊天',
count: 1,
image_per_post: 1,
image_ratio: '3:4 — 小红书',
});
const showImageConfig = computed(() => formData.mode === '混合模式(文案 + 图片)' || formData.mode === '纯图片模式');
const modeOptions = ['混合模式(文案 + 图片)', '纯文案模式', '纯图片模式'];
const contentTypeOptions = ['穿搭分享', '好物推荐', '美妆护肤', '探店分享', '旅行日常', '美食分享'];
const styleOptions = [
'生活分享 — 亲切自然,像朋友聊天',
'专业测评 — 深度分析,数据支撑',
'种草推荐 — 强调亮点,感染力强',
'干货教学 — 条理清晰,步骤明确',
const queryParams: CreationListParams = { keyword: '', pageNum: 1, pageSize: 10 };
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const nodePalette = [
{ type: 'rect', text: '主题输入', label: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
{ type: 'rect', text: '文案生成', label: '文案生成', properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 } },
{ type: 'rect', text: '图片生成', label: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
{ type: 'rect', text: '发布输出', label: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
];
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
watch(
() => formData.mode,
() => {
if (!showImageConfig.value) {
formData.image_per_post = 1;
formData.image_ratio = '3:4 — 小红书';
}
},
{ immediate: true }
);
const rules: FormRules = {
mode: [{ required: true, message: '请选择创作模式', trigger: 'change' }],
content_type: [{ required: true, message: '请选择内容类型', trigger: 'change' }],
theme: [{ required: true, message: '请输入主题', trigger: 'blur' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
style: [{ required: true, message: '请选择内容风格', trigger: 'change' }],
count: [{ required: true, message: '请输入生成条数', trigger: 'change' }],
image_per_post: [{
required: true,
message: '请输入配图数量',
trigger: 'change',
validator: (rule, value, callback) => {
void rule;
if (!showImageConfig.value) return callback();
if (!value) return callback(new Error('请输入配图数量'));
callback();
const defaultGraphData = {
nodes: [
{ id: 'theme-node', type: 'rect', x: 160, y: 170, text: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
{
id: 'copy-node',
type: 'rect',
x: 420,
y: 170,
text: '文案生成',
properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 },
},
}],
image_ratio: [{
required: true,
message: '请选择图片比例',
trigger: 'change',
validator: (rule, value, callback) => {
void rule;
if (!showImageConfig.value) return callback();
if (!value) return callback(new Error('请选择图片比例'));
callback();
},
}],
};
const joinUrl = (base: string, path: string) => `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
const buildAssetUrl = (path?: string) => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
const prefix = imgAddressPrefix.value || '';
if (/^https?:\/\//i.test(prefix)) return joinUrl(prefix, path);
if (prefix) return joinUrl(joinUrl(apiBaseUrl, prefix), path);
return joinUrl(apiBaseUrl, path);
{ id: 'image-node', type: 'rect', x: 680, y: 170, text: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
{ id: 'publish-node', type: 'rect', x: 940, y: 170, text: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
],
edges: [
{ id: 'edge-1', type: 'polyline', sourceNodeId: 'theme-node', targetNodeId: 'copy-node', text: '主题变量', properties: { field: 'theme' } },
{ id: 'edge-2', type: 'polyline', sourceNodeId: 'copy-node', targetNodeId: 'image-node', text: '文案结果', properties: { field: 'copywriting' } },
{ id: 'edge-3', type: 'polyline', sourceNodeId: 'image-node', targetNodeId: 'publish-node', text: '图文结果', properties: { field: 'assets' } },
],
};
const workflowDsl = computed(() => ({
version: '1.0.0',
startNodeId: flowDsl.value.nodes[0]?.id || '',
nodes: flowDsl.value.nodes.map((n) => ({
id: n.id,
nodeCode: n.properties?.nodeCode || 'unknown',
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
config: { ...n.properties },
})),
edges: flowDsl.value.edges.map((e) => ({ id: e.id, from: e.sourceNodeId, to: e.targetNodeId, mapping: { ...e.properties } })),
}));
const pretty = (v: unknown) => JSON.stringify(v, null, 2);
const joinUrl = (b: string, p: string) => `${b.replace(/\/$/, '')}${p.startsWith('/') ? p : `/${p}`}`;
const buildAssetUrl = (p?: string) =>
!p
? ''
: /^https?:\/\//i.test(p)
? p
: /^https?:\/\//i.test(imgAddressPrefix.value || '')
? joinUrl(imgAddressPrefix.value, p)
: imgAddressPrefix.value
? joinUrl(joinUrl(apiBaseUrl, imgAddressPrefix.value), p)
: joinUrl(apiBaseUrl, p);
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
tree.map((dateGroup, dIndex) => ({
id: `date-${dIndex}`,
label: dateGroup.createdDate,
nodeType: 'date' as const,
children: (dateGroup.contentTypes || []).map((contentTypeGroup, cIndex) => ({
id: `content-type-${dIndex}-${cIndex}`,
label: contentTypeGroup.contentType,
nodeType: 'contentType' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
children: (contentTypeGroup.themes || []).map((themeGroup, tIndex) => ({
id: `theme-${dIndex}-${cIndex}-${tIndex}`,
label: themeGroup.theme,
nodeType: 'theme' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
children: (themeGroup.titles || []).map((titleItem, i) => ({
id: `title-${dIndex}-${cIndex}-${tIndex}-${i}`,
label: titleItem.title || `作品${i + 1}`,
nodeType: 'title' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
tree.map((d, di) => ({
id: `date-${di}`,
label: d.createdDate,
nodeType: 'date',
children: (d.contentTypes || []).map((c, ci) => ({
id: `content-${di}-${ci}`,
label: c.contentType,
nodeType: 'contentType',
children: (c.themes || []).map((t, ti) => ({
id: `theme-${di}-${ci}-${ti}`,
label: t.theme,
nodeType: 'theme',
children: (t.titles || []).map((title, i) => ({
id: `title-${di}-${ci}-${ti}-${i}`,
label: title.title || `作品${i + 1}`,
nodeType: 'title',
children: [
...(titleItem.htmlFileUrl
? [
{
id: `html-${dIndex}-${cIndex}-${tIndex}-${i}`,
label: 'HTML',
nodeType: 'html' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
fileUrl: titleItem.htmlFileUrl,
},
]
...(title.htmlFileUrl
? [{ id: `html-${di}-${ci}-${ti}-${i}`, label: 'HTML', nodeType: 'html' as const, fileUrl: title.htmlFileUrl }]
: []),
...(titleItem.imageUrls || []).map((img, imgIndex) => ({
id: `img-${dIndex}-${cIndex}-${tIndex}-${i}-${imgIndex}`,
label: img.name || `图片 ${imgIndex + 1}`,
...(title.imageUrls || []).map((img, ii) => ({
id: `img-${di}-${ci}-${ti}-${i}-${ii}`,
label: img.name || `图片 ${ii + 1}`,
nodeType: 'image' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
fileUrl: img.url,
})),
],
@@ -263,195 +202,185 @@ const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
})),
})),
}));
const handleNodeClick = (data: TreeNode) => {
if (data.contentType) formData.content_type = data.contentType;
if (data.theme) formData.theme = data.theme;
if (data.nodeType === 'title') {
formData.title = data.creationTitle || data.label || formData.title;
return;
}
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
const url = buildAssetUrl(data.fileUrl);
if (!url) return ElMessage.warning('当前节点没有可预览地址');
selectedPreview.value = { url, nodeType: data.nodeType };
formData.title = data.creationTitle || formData.title;
};
const downloadNode = async (data: TreeNode) => {
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
if (!data.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
try {
const response = await downloadToFile({ fileURL: data.fileUrl });
const blob = response instanceof Blob ? response : response?.data;
if (!(blob instanceof Blob)) throw new Error('无效的下载数据');
const fileName = decodeURIComponent(data.fileUrl.split('/').pop() || `${data.label}.${data.nodeType === 'html' ? 'html' : 'png'}`);
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(objectUrl);
ElMessage.success('下载成功');
} catch {
ElMessage.error('下载失败');
}
};
const findFirstPreviewNode = (nodes: TreeNode[]): TreeNode | null => {
for (const node of nodes) {
if (node.nodeType === 'html' || node.nodeType === 'image') return node;
if (node.children?.length) {
const matched = findFirstPreviewNode(node.children);
if (matched) return matched;
}
}
return null;
};
const getList = async () => {
treeLoading.value = true;
try {
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined });
// 这里改成 page表示列表加载失败的文案由当前页面自己决定。
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined }, { errorMode: 'page' });
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
selectedPreview.value = null;
await nextTick();
const firstLeaf = findFirstPreviewNode(treeNodes.value);
if (firstLeaf) handleNodeClick(firstLeaf);
} catch {
treeNodes.value = [];
imgAddressPrefix.value = '';
selectedPreview.value = null;
// 既然这个请求声明由页面自己处理错误,这里保留页面可读性更强的业务文案。
ElMessage.error('获取作品创作列表失败');
} finally {
treeLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value || submitLoading.value) return;
const handleNodeClick = (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
const url = buildAssetUrl(d.fileUrl);
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
window.open(url, '_blank');
};
const downloadNode = async (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
if (!d.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
try {
await formRef.value.validate();
submitLoading.value = true;
selectedPreview.value = null;
await createCreation({
...formData,
count: Number(formData.count),
image_per_post: Number(formData.image_per_post),
description: formData.description?.trim() || undefined,
});
ElMessage.success('创作任务已提交');
await getList();
// 下载失败时希望展示更贴近页面语义的提示,因此改为 page 模式。
const r = await downloadToFile({ fileURL: d.fileUrl }, { errorMode: 'page' });
const blob = r instanceof Blob ? r : r?.data;
if (!(blob instanceof Blob)) throw new Error('invalid blob');
const name = decodeURIComponent(d.fileUrl.split('/').pop() || `${d.label}.${d.nodeType === 'html' ? 'html' : 'png'}`);
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = u;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(u);
ElMessage.success('下载成功');
} catch {
ElMessage.error('提交创作任务失败');
} finally {
submitLoading.value = false;
// 下载接口已经声明由页面自己处理错误,所以这里只会出现一条下载失败提示。
ElMessage.error('下载失败');
}
};
onMounted(getList);
const syncDsl = () => {
const lf = logicFlowInstance.value;
if (!lf) return;
const data = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
flowDsl.value = { nodes: data.nodes || [], edges: data.edges || [] };
};
watch(
selectedElement,
(e) => {
formState.text = String(e?.text || '');
formState.nodeCode = String(e?.properties?.nodeCode || '');
formState.field = String(e?.properties?.field || '');
formState.model = String(e?.properties?.model || '');
formState.temperature = Number(e?.properties?.temperature ?? 0.7);
formState.imageRatio = String(e?.properties?.imageRatio || '3:4');
formState.channel = String(e?.properties?.channel || '');
},
{ immediate: true }
);
const applySelected = () => {
const lf = logicFlowInstance.value,
cur = selectedElement.value;
if (!lf || !cur) return;
const p: Item = { ...cur.properties, nodeCode: formState.nodeCode };
formState.field ? (p.field = formState.field) : delete p.field;
formState.model ? (p.model = formState.model) : delete p.model;
formState.nodeCode === 'copywriting-agent' ? (p.temperature = formState.temperature) : delete p.temperature;
formState.imageRatio ? (p.imageRatio = formState.imageRatio) : delete p.imageRatio;
formState.channel ? (p.channel = formState.channel) : delete p.channel;
lf.setProperties(cur.id, p);
if (formState.text) lf.updateText(cur.id, formState.text);
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
const n = g.nodes.find((x) => x.id === cur.id),
e = g.edges.find((x) => x.id === cur.id);
selectedElement.value = n
? { id: n.id, type: n.type, kind: 'node', properties: n.properties || {}, text: typeof n.text === 'string' ? n.text : n.text?.value }
: e
? { id: e.id, type: e.type, kind: 'edge', properties: e.properties || {}, text: typeof e.text === 'string' ? e.text : e.text?.value }
: null;
syncDsl();
ElMessage.success('已更新当前元素配置');
};
const setupDndPanel = () => {
const lf = logicFlowInstance.value as LogicFlow & {
extension: { dndPanel?: { setPatternItems: (items: Array<Record<string, unknown>>) => void } };
};
lf.extension.dndPanel?.setPatternItems(nodePalette);
};
const bindEvents = () => {
const lf = logicFlowInstance.value;
if (!lf) return;
lf.on('node:click', ({ data }: { data: any }) => {
selectedElement.value = {
id: data.id,
type: data.type,
kind: 'node',
properties: data.properties || {},
text: typeof data.text === 'string' ? data.text : data.text?.value,
};
});
lf.on('edge:click', ({ data }: { data: any }) => {
selectedElement.value = {
id: data.id,
type: data.type,
kind: 'edge',
properties: data.properties || {},
text: typeof data.text === 'string' ? data.text : data.text?.value,
};
});
lf.on('blank:click', () => {
selectedElement.value = null;
});
['history:change', 'node:add', 'edge:add', 'node:delete', 'edge:delete'].forEach((n) => lf.on(n, syncDsl));
};
const initLogicFlow = () => {
if (!logicFlowRef.value) return;
LogicFlow.use(Control);
LogicFlow.use(DndPanel);
LogicFlow.use(SelectionSelect);
const lf = new LogicFlow({
container: logicFlowRef.value,
grid: { size: 16, visible: true, type: 'dot', config: { color: '#d7e0ef', thickness: 1 } },
background: { backgroundColor: '#fbfcfe' },
keyboard: { enabled: true },
adjustEdge: true,
edgeType: 'polyline',
style: {
rect: { width: 120, height: 54, radius: 10, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
polyline: { stroke: '#475569', strokeWidth: 1.4 },
edgeText: { fill: '#64748b', fontSize: 12, textWidth: 120, background: { fill: '#fff' } },
},
});
logicFlowInstance.value = lf;
lf.render(defaultGraphData);
lf.fitView(60, 80);
setupDndPanel();
bindEvents();
syncDsl();
};
const resetFlow = () => {
const lf = logicFlowInstance.value;
if (!lf) return;
lf.render(defaultGraphData);
lf.fitView(60, 80);
selectedElement.value = null;
syncDsl();
ElMessage.success('示例流程已重置');
};
onMounted(async () => {
await getList();
await nextTick();
initLogicFlow();
});
onBeforeUnmount(() => {
logicFlowInstance.value?.destroy();
logicFlowInstance.value = null;
});
</script>
<style scoped lang="scss">
.creation-page {
height: calc(100vh - 100px);
display: grid;
grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr);
grid-template-columns: 280px minmax(0, 1fr);
gap: 14px;
padding: 14px;
background: #f6f8fb;
box-sizing: border-box;
position: relative;
}
.creation-page.is-submitting {
overflow: hidden;
}
.creation-loading-mask {
position: absolute;
inset: 14px;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(246, 248, 251, 0.78);
backdrop-filter: blur(8px);
border-radius: 14px;
}
.creation-loading-card {
width: min(420px, calc(100% - 40px));
padding: 36px 28px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 48px rgba(64, 102, 255, 0.18);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.loading-orbit {
position: relative;
width: 108px;
height: 108px;
margin-bottom: 20px;
}
.loading-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border-style: solid;
animation: orbit-rotate 1.8s linear infinite;
}
.ring-outer {
border-width: 4px;
border-color: #5b8cff transparent #8fb3ff transparent;
}
.ring-inner {
inset: 15px;
border-width: 4px;
border-color: transparent #7c9dff transparent #d2deff;
animation-direction: reverse;
animation-duration: 1.2s;
}
.loading-core {
position: absolute;
inset: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #5b8cff 0%, #7a5cff 100%);
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
animation: core-pulse 1.6s ease-in-out infinite;
}
.loading-title {
font-size: 22px;
font-weight: 700;
color: #1f2d3d;
margin-bottom: 10px;
}
.loading-desc {
font-size: 14px;
line-height: 1.7;
color: #5f6b7a;
}
@keyframes orbit-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes core-pulse {
0%,
100% {
transform: scale(0.92);
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
}
50% {
transform: scale(1);
box-shadow: 0 0 0 18px rgba(91, 140, 255, 0.2);
}
}
.panel {
background: #fff;
border-radius: 10px;
border-radius: 14px;
padding: 14px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -459,15 +388,21 @@ onMounted(getList);
.title {
font-size: 18px;
font-weight: 700;
color: #303133;
margin-bottom: 12px;
color: #1f2937;
margin-bottom: 10px;
}
.preview-title {
margin-bottom: 0;
.title-sm {
font-size: 15px;
font-weight: 700;
color: #1f2937;
}
.tree-wrap,
.center,
.preview-main {
.sub {
font-size: 13px;
line-height: 1.7;
color: #64748b;
}
.tree-wrap {
flex: 1;
overflow: auto;
}
.tree-node {
@@ -475,107 +410,113 @@ onMounted(getList);
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.tree-node-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.tree-download {
flex-shrink: 0;
padding: 2px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact-form {
flex: 1;
.editor-shell {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.form-grid {
.top {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 14px;
flex: 1;
}
.span-1 {
grid-column: span 1;
.meta {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.span-2 {
grid-column: span 2;
.canvas-wrap {
flex: 1;
min-height: 560px;
border: 1px solid #e8eef7;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, #fcfdff 0%, #f8fbff 100%);
}
.description-item {
margin-bottom: 8px;
.logicflow-canvas {
width: 100%;
height: 100%;
min-height: 560px;
}
.form-wrap {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
color: #475569;
}
.prop-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.w100 {
width: 100%;
}
.submit-btn {
width: 100%;
height: 40px;
margin-top: auto;
border-radius: 8px;
.json-box {
margin: 0;
padding: 12px;
border-radius: 12px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.6;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.preview-main {
flex: 1;
min-height: 0;
background: #f8fafc;
border: 1px solid #edf1f7;
border-radius: 10px;
padding: 10px;
:deep(.lf-dndpanel) {
top: 14px;
left: 14px;
}
.iframe {
width: 100%;
height: 100%;
min-height: 520px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fff;
:deep(.lf-control) {
right: 14px;
top: 14px;
left: auto;
}
.img-wrap {
height: 100%;
min-height: 520px;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
:deep(.lf-node-selected .lf-basic-shape) {
stroke: #2563eb !important;
stroke-width: 1.8 !important;
}
.img {
width: 100%;
height: 100%;
min-height: 480px;
:deep(.lf-edge-selected path) {
stroke: #2563eb !important;
}
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
padding-bottom: 4px;
font-size: 13px;
color: #606266;
}
:deep(.el-input__wrapper),
:deep(.el-select__wrapper),
:deep(.el-textarea__inner),
:deep(.el-input-number) {
border-radius: 8px;
}
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number),
:deep(.el-textarea) {
width: 100%;
}
@media (max-width: 1800px) {
@media (max-width: 1400px) {
.creation-page {
grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr);
grid-template-columns: 260px minmax(0, 1fr);
}
.main {
grid-template-columns: 1fr;
}
}
@media (max-width: 1100px) {
.creation-page {
grid-template-columns: 1fr;
}
.top {
flex-direction: column;
}
.actions {
width: 100%;
flex-wrap: wrap;
}
}
</style>