@@ -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 / > < / e l - i c o n
> < / el-button >
> 下载 < / e l - b u t t o n
>
< / 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" / > < / e l - s e l e c t
> < / 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" / > < / e l - s e l e c t
> < / 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" / > < / e l - s e l e c t
> < / 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" / > < / e l - s e l e c t
> < / 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 c lass = "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 {
createCreat ion ,
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/extens ion' ;
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 Preview State {
url : string ;
nodeT ype: 'html' | 'image' ;
interface Selected State {
id : string ;
t ype: 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 ,
messa ge: '请选择图片比例' ,
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' } } ,
] ,
ed ges : [
{ 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 ,
content Type: 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 } ` ,
node Type : '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 ( 100 vh - 100 px ) ;
display : grid ;
grid - template - columns : 292 px minmax ( 470 px , 1 fr ) minmax ( 500 px , 1.02 fr ) ;
grid - template - columns : 280 px minmax ( 0 , 1 fr ) ;
gap : 14 px ;
padding : 14 px ;
background : # f6f8fb ;
box - sizing : border - box ;
position : relative ;
}
. creation - page . is - submitting {
overflow : hidden ;
}
. creation - loading - mask {
position : absolute ;
inset : 14 px ;
z - index : 20 ;
display : flex ;
align - items : center ;
justify - content : center ;
background : rgba ( 246 , 248 , 251 , 0.78 ) ;
backdrop - filter : blur ( 8 px ) ;
border - radius : 14 px ;
}
. creation - loading - card {
width : min ( 420 px , calc ( 100 % - 40 px ) ) ;
padding : 36 px 28 px ;
border - radius : 20 px ;
background : rgba ( 255 , 255 , 255 , 0.96 ) ;
box - shadow : 0 18 px 48 px rgba ( 64 , 102 , 255 , 0.18 ) ;
display : flex ;
flex - direction : column ;
align - items : center ;
text - align : center ;
}
. loading - orbit {
position : relative ;
width : 108 px ;
height : 108 px ;
margin - bottom : 20 px ;
}
. loading - ring {
position : absolute ;
inset : 0 ;
border - radius : 50 % ;
border - style : solid ;
animation : orbit - rotate 1.8 s linear infinite ;
}
. ring - outer {
border - width : 4 px ;
border - color : # 5 b8cff transparent # 8 fb3ff transparent ;
}
. ring - inner {
inset : 15 px ;
border - width : 4 px ;
border - color : transparent # 7 c9dff transparent # d2deff ;
animation - direction : reverse ;
animation - duration : 1.2 s ;
}
. loading - core {
position : absolute ;
inset : 34 px ;
border - radius : 50 % ;
background : linear - gradient ( 135 deg , # 5 b8cff 0 % , # 7 a5cff 100 % ) ;
box - shadow : 0 0 0 10 px rgba ( 91 , 140 , 255 , 0.12 ) ;
animation : core - pulse 1.6 s ease - in - out infinite ;
}
. loading - title {
font - size : 22 px ;
font - weight : 700 ;
color : # 1 f2d3d ;
margin - bottom : 10 px ;
}
. loading - desc {
font - size : 14 px ;
line - height : 1.7 ;
color : # 5 f6b7a ;
}
@ keyframes orbit - rotate {
from {
transform : rotate ( 0 deg ) ;
}
to {
transform : rotate ( 360 deg ) ;
}
}
@ keyframes core - pulse {
0 % ,
100 % {
transform : scale ( 0.92 ) ;
box - shadow : 0 0 0 10 px rgba ( 91 , 140 , 255 , 0.12 ) ;
}
50 % {
transform : scale ( 1 ) ;
box - shadow : 0 0 0 18 px rgba ( 91 , 140 , 255 , 0.2 ) ;
}
}
. panel {
background : # fff ;
border - radius : 10 px ;
border - radius : 14 px ;
padding : 14 px ;
box - shadow : 0 2 px 10 px rgba ( 15 , 23 , 42 , 0.05 ) ;
box - shadow : 0 4 px 18 px rgba ( 15 , 23 , 42 , 0.05 ) ;
overflow : hidden ;
display : flex ;
flex - direction : column ;
@@ -459,15 +388,21 @@ onMounted(getList);
. title {
font - size : 18 px ;
font - weight : 700 ;
color : # 303133 ;
margin - bottom : 12 px ;
color : # 1 f2937 ;
margin - bottom : 10 px ;
}
. preview - title {
margin - bottom : 0 ;
. title - sm {
font - size : 15 px ;
font - weight : 700 ;
color : # 1 f2937 ;
}
. tree - wrap ,
. center ,
. preview - main {
. sub {
font - size : 13 px ;
line - height : 1.7 ;
color : # 64748 b ;
}
. tree - wrap {
flex : 1 ;
overflow : auto ;
}
. tree - node {
@@ -475,107 +410,113 @@ onMounted(getList);
align - items : center ;
justify - content : space - between ;
gap : 8 px ;
width : 100 % ;
}
. tree - node - main {
display : flex ;
align - items : center ;
gap : 6 px ;
min - width : 0 ;
flex : 1 ;
}
. tree - download {
flex - shrink : 0 ;
padding : 2 px ;
}
. ellipsis {
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. compact - form {
flex : 1 ;
. editor - shell {
display : flex ;
flex - direction : column ;
gap : 14 px ;
min - width : 0 ;
}
. form - grid {
. top {
flex - direction : row ;
justify - content : space - between ;
align - items : flex - start ;
}
. actions {
display : flex ;
gap : 10 px ;
flex - shrink : 0 ;
}
. main {
display : grid ;
grid - template - columns : repeat ( 2 , minmax( 0 , 1 fr ) ) ;
gap : 0 12 px ;
grid - template - columns : minmax ( 0 , 1 fr ) 340 px ;
gap : 14 px ;
flex : 1 ;
}
. span - 1 {
grid - column : span 1 ;
. meta {
display : flex ;
justify - content : space - between ;
margin - bottom : 10 px ;
}
. span - 2 {
grid - column : span 2 ;
. canvas - wrap {
flex : 1 ;
min - height : 560 px ;
border : 1 px solid # e8eef7 ;
border - radius : 14 px ;
overflow : hidden ;
background : linear - gradient ( 180 deg , # fcfdff 0 % , # f8fbff 100 % ) ;
}
. description - item {
margin - bottom : 8 px ;
. logicflow - canvas {
width : 100 % ;
height : 100 % ;
min - height : 560 px ;
}
. form - wrap {
display : flex ;
flex - direction : column ;
gap : 10 px ;
font - size : 13 px ;
color : # 475569 ;
}
. prop - form {
display : flex ;
flex - direction : column ;
gap : 4 px ;
}
. w100 {
width : 100 % ;
}
. submit - btn {
width : 100 % ;
height : 40 px ;
margin - top : auto ;
border - radius : 8 px ;
. json - box {
margin : 0 ;
padding : 12 px ;
border - radius : 12 px ;
background : # 0 f172a ;
color : # e2e8f0 ;
font - size : 12 px ;
line - height : 1.6 ;
overflow : auto ;
white - space : pre - wrap ;
word - break : break - word ;
}
. preview - main {
flex : 1 ;
min - heigh t: 0 ;
background : # f8fafc ;
border : 1 px solid # edf1f7 ;
border - radius : 10 px ;
padding : 10 px ;
: deep ( . lf - dndpanel ) {
top : 14 px ;
lef t: 14 px ;
}
. iframe {
width : 100 % ;
height : 100 % ;
min - height : 520 px ;
border : 1 px solid # ebeef5 ;
border - radius : 8 px ;
background : # fff ;
: deep ( . lf - control ) {
right : 14 px ;
top : 14 px ;
left : auto ;
}
. img - wrap {
height : 100 % ;
min - height : 520 px ;
border : 1 px solid # ebeef5 ;
border - radius : 8 px ;
padding : 10 px ;
display : flex ;
align - items : center ;
justify - content : center ;
background : # fff ;
: deep ( . lf - node - selected . lf - basic - shape ) {
stroke : # 2563 eb ! important ;
stroke - width : 1.8 ! important ;
}
. img {
width : 100 % ;
height : 100 % ;
min - height : 480 px ;
: deep ( . lf - edge - selected path ) {
stroke : # 2563 eb ! important ;
}
: deep ( . el - form - item ) {
margin - bottom : 12 px ;
}
: deep ( . el - form - item _ _label ) {
padding - bottom : 4 px ;
font - size : 13 px ;
color : # 606266 ;
}
: deep ( . el - input _ _wrapper ) ,
: deep ( . el - select _ _wrapper ) ,
: deep ( . el - textarea _ _inner ) ,
: deep ( . el - input - number ) {
border - radius : 8 px ;
}
: deep ( . el - select ) ,
: deep ( . el - input ) ,
: deep ( . el - input - number ) ,
: deep ( . el - textarea ) {
width : 100 % ;
}
@ media ( max - width : 1800 px ) {
@ media ( max - width : 1400 px ) {
. creation - page {
grid - template - columns : 28 0 px minmax ( 430 px , 1 fr ) minmax ( 460 px , 0.98 fr ) ;
grid - template - columns : 26 0 px minmax ( 0 , 1 fr ) ;
}
. main {
grid - template - columns : 1 fr ;
}
}
@ media ( max - width : 1100 px ) {
. creation - page {
grid - template - columns : 1 fr ;
}
. top {
flex - direction : column ;
}
. actions {
width : 100 % ;
flex - wrap : wrap ;
}
}
< / style >