视频贴片相关
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
/* ========== 全局重置 ========== */
|
||||
/* 去掉所有元素的自带边距和内边距,让布局从零开始 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* ========== 画布(视频输出尺寸) ========== */
|
||||
/* 这是最终视频的分辨率:宽1080px × 高1920px(竖屏 9:16,手机全屏) */
|
||||
/* 如果你想改输出尺寸,改这里的 width 和 height 就行 */
|
||||
body {
|
||||
width: 1080px; /* 画布宽度 ← 改成 720 就是 720×1280 */
|
||||
height: 1920px; /* 画布高度 ← 改成 1280 就是 720p */
|
||||
overflow: hidden; /* 超出画布的部分裁掉,不显示滚动条 */
|
||||
background: #000; /* 画布背景色(黑色),视频没铺满时露出的颜色 */
|
||||
}
|
||||
|
||||
/* ========== 所有元素的定位基础 ========== */
|
||||
/* 给所有带 clip 类的元素开启绝对定位,可以用 left/top 精确控制位置 */
|
||||
.clip {
|
||||
position: absolute; /* 绝对定位,相对于父容器(body)定位 */
|
||||
}
|
||||
|
||||
/* ========== 文字元素的通用样式 ========== */
|
||||
.text-element {
|
||||
font-family: Arial, Helvetica, sans-serif; /* 字体:首选Arial,不行就Helvetica,再不行用系统无衬线体 */
|
||||
text-align: center; /* 文字水平居中 */
|
||||
display: flex; /* 启用弹性盒子布局 */
|
||||
align-items: center; /* 垂直居中(配合flex使用) */
|
||||
justify-content: center; /* 水平居中(配合flex使用) */
|
||||
white-space: pre-wrap; /* 保留换行,文字超出宽度自动换行 */
|
||||
word-break: break-word; /* 长单词或URL撑破容器时强制换行 */
|
||||
}
|
||||
|
||||
/* ========== 入场动画定义 ========== */
|
||||
/* 想改动画时长,改后面的秒数(如 0.5s → 1s 就是慢一倍) */
|
||||
|
||||
/* 淡入:从完全透明 → 完全可见 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; } /* 开始:透明(完全看不见) */
|
||||
to { opacity: 1; } /* 结束:不透明(完全可见) */
|
||||
}
|
||||
|
||||
/* 上滑:从下方40px处边向上移动边显现 */
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(40px); } /* 开始:透明 + 在下方40px */
|
||||
to { opacity: 1; transform: translateY(0); } /* 结束:可见 + 回到原位 */
|
||||
}
|
||||
|
||||
/* 左滑:从右侧40px处边向左移动边显现(视觉上像从右边滑入) */
|
||||
@keyframes slideLeft {
|
||||
from { opacity: 0; transform: translateX(40px); } /* 开始:透明 + 在右边40px */
|
||||
to { opacity: 1; transform: translateX(0); } /* 结束:可见 + 回到原位 */
|
||||
}
|
||||
|
||||
/* 放大:从缩小到80%边放大边显现 */
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.8); } /* 开始:透明 + 缩到80%大小 */
|
||||
to { opacity: 1; transform: scale(1); } /* 结束:可见 + 100%正常大小 */
|
||||
}
|
||||
|
||||
/* 脉冲:一直循环缩放,像呼吸一样忽大忽小(一般用于强调/吸引注意) */
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); } /* 开始:正常大小 */
|
||||
50% { transform: scale(1.05); } /* 中间:放大到1.05倍(105%)*/
|
||||
100% { transform: scale(1); } /* 结束:回到正常大小 */
|
||||
}
|
||||
|
||||
/* ========== 动画类名 ========== */
|
||||
/* 给元素加上 class="anim-xxx" 就能用上面的动画,用法看下面的HTML元素 */
|
||||
|
||||
/* 淡入 0.5秒,先快后慢 */
|
||||
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
|
||||
|
||||
/* 上滑 0.6秒,先快后慢 */
|
||||
.anim-slideUp { animation: slideUp 0.6s ease-out; }
|
||||
|
||||
/* 左滑 0.6秒,先快后慢 */
|
||||
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
|
||||
|
||||
/* 放大入场 0.5秒,先快后慢 */
|
||||
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
|
||||
|
||||
/* 脉冲 1.5秒一个循环,一直重复(infinite) */
|
||||
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
/* ── 动画参数怎么改? ── */
|
||||
/* • 0.5s → 改成 1s 动画就慢一倍,改 0.2s 就快一倍 */
|
||||
/* • ease-out → 开始快结尾慢,换成 linear 就是匀速,换成 ease-in 就是开始慢结尾快 */
|
||||
/* • infinite → 一直重复,去掉就不重复只播一次 */
|
||||
</style>
|
||||
|
||||
<!-- GSAP:专业动画引擎,用于精确控制时间轴(当前脚本只用了初始化,没做复杂动画) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ====================================================================== -->
|
||||
<!-- 舞台容器:所有元素都放在这里面 -->
|
||||
<!-- data-composition-id 是内部标识,不要改 -->
|
||||
<!-- data-width / data-height 需要和上面的 body 宽高一致 -->
|
||||
<!-- ====================================================================== -->
|
||||
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- data-key 属性:这个元素的内容会被程序自动替换 -->
|
||||
<!-- 比如 data-key="title",程序就会用"标题"对应的文字替换掉这个元素的内容 -->
|
||||
<!-- 所以里面的"标题文字"只是占位,实际显示什么由数据决定 -->
|
||||
<!-- ================================================================== -->
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- 标题:红色大标题,靠上居中 -->
|
||||
<!-- ======================================================== -->
|
||||
<!--
|
||||
data-key="title" → 程序会用标题数据替换此元素内容
|
||||
data-start="0" → 第0秒开始显示(单位:秒)
|
||||
data-duration="58.000" → 持续显示58秒
|
||||
class="clip" → 绝对定位,可以用 left/top 控制位置
|
||||
class="text-element" → 文字居中、自动换行
|
||||
class="anim-slideUp" → 入场动画:从下方滑入,0.6秒
|
||||
-->
|
||||
<div data-key="title" data-start="0" data-duration="58.000" data-track-index="1"
|
||||
class="clip text-element anim-slideUp"
|
||||
style="
|
||||
left: 50%; /* 水平居中定位(配合 translateX 使用) */
|
||||
top: 70px; /* 距离顶部70px ← 改这个数字上下移动 */
|
||||
transform: translateX(-50%); /* 把元素向左拉回自身宽度的一半,实现真居中 */
|
||||
width: 972px; /* 文字区域宽度 ← 改窄一点文字就缩短 */
|
||||
max-width: 972px; /* 最大宽度,防止被撑破 */
|
||||
font-size: 48px; /* 字号 ← 改大/改小 */
|
||||
color: #D82828; /* 文字颜色(红色)← 改成 #FFFFFF 就是白色 */
|
||||
">标题文字</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- 产品图片:左上角位置,图片链接由程序自动替换 -->
|
||||
<!-- ======================================================== -->
|
||||
<!--
|
||||
这是 <img> 标签,src 属性会被程序替换成真实的图片 URL
|
||||
data-key="product_image" → 程序用产品图片的URL替换 src
|
||||
-->
|
||||
<img data-key="product_image" data-start="0" data-duration="58.000" data-track-index="2"
|
||||
class="clip anim-fadeIn"
|
||||
style="
|
||||
width: 200px; /* 图片宽度 ← 改这个调大小 */
|
||||
height: 200px; /* 图片高度 ← 改这个调大小 */
|
||||
left: 0px; /* 距离左边0px(贴左边缘)← 改大往右移 */
|
||||
top: 200px; /* 距离顶部200px ← 改大往下移 */
|
||||
"
|
||||
src="占位图片.jpg" />
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- 产品卖点描述:居中偏下位置 -->
|
||||
<!-- ======================================================== -->
|
||||
<div data-key="product_desc" data-start="0" data-duration="58.000" data-track-index="3"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%; /* 水平居中 */
|
||||
top: 830px; /* 距离顶部830px ← 改大就往下移 */
|
||||
transform: translateX(-50%); /* 水平居中补偿 */
|
||||
width: 972px; /* 文本宽度 */
|
||||
max-width: 972px; /* 最大宽度 */
|
||||
font-size: 34px; /* 字号 ← 改大/改小 */
|
||||
color: #333333; /* 文字颜色(深灰色)← 改成 #000 就是黑色 */
|
||||
">产品卖点描述</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- 底部免责声明:背景半透明黑底白字,圆角,贴底部 -->
|
||||
<!-- ======================================================== -->
|
||||
<div data-key="disclaimer" data-start="0" data-duration="58.000" data-track-index="5"
|
||||
class="clip text-element"
|
||||
style="
|
||||
left: 50%; /* 水平居中 */
|
||||
top: calc(100% - 45px); /* 距离底部45px(100%是父容器高度)← 改45调距离 */
|
||||
transform: translateX(-50%); /* 水平居中补偿 */
|
||||
width: 972px; /* 文本宽度 */
|
||||
max-width: 972px;
|
||||
font-size: 20px; /* 字号 */
|
||||
color: #FFFFFF; /* 文字颜色(白色) */
|
||||
">
|
||||
<!--
|
||||
内部 span:负责背景和间距
|
||||
background: rgba(0,0,0,0.70) → 黑色半透明背景,70%不透明度
|
||||
← 改最后一个数字(0~1)调透明度:0=全透,1=全黑
|
||||
padding: 12px 24px → 内边距:上下12px,左右24px
|
||||
← 改这个让背景范围变大/变小
|
||||
border-radius: 8px → 圆角大小
|
||||
← 改大更圆(如 20px),改 0 就是直角
|
||||
-->
|
||||
<span style="background:rgba(0,0,0,0.70); padding:12px 24px; border-radius:8px;">
|
||||
免责声明文字
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- GSAP 时间轴初始化脚本 -->
|
||||
<!-- 当前只初始化了一个空时间轴,给程序框架用,一般不需要改 -->
|
||||
<!-- ================================================================ -->
|
||||
<script>
|
||||
(function() {
|
||||
var tl = gsap.timeline({ paused: true }); // 建一个暂停的时间轴
|
||||
tl.to('#stage', { duration: 0, opacity: 1 }, 0); // 舞台立刻显示(第0秒)
|
||||
window.__timelines = window.__timelines || {}; // 存到全局,供程序调用
|
||||
window.__timelines['main'] = tl; // 时间轴名字叫 'main'
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,6 +39,7 @@ export interface NodeLibraryItem {
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
isSaveFile: boolean;
|
||||
patchLayout: boolean;
|
||||
formConfig: NodeLibraryFormItem[];
|
||||
modelConfig: NodeLibraryModelConfig[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="patch-template-editor">
|
||||
<el-divider content-position="left">贴片模板</el-divider>
|
||||
|
||||
<div v-for="(template, index) in modelValue" :key="index" class="template-card">
|
||||
<div class="template-card-header">
|
||||
<span class="template-card-title">模板 #{{ index + 1 }}</span>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeTemplate(index)" />
|
||||
</div>
|
||||
|
||||
<div class="template-card-body">
|
||||
<!-- 模板文件上传 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">模板文件</span>
|
||||
<div class="field-control">
|
||||
<el-upload
|
||||
:key="`upload-${index}`"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(file: any) => handleTemplateUpload(index, file)"
|
||||
>
|
||||
<el-button size="small" type="primary" :disabled="!!template.url">
|
||||
<el-icon><Upload /></el-icon> 上传模板
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="handleDownload">
|
||||
<el-icon><Download /></el-icon> 下载参考模板
|
||||
</el-button>
|
||||
<el-tag v-if="template.url" type="success" size="small" class="uploaded-tag">已上传</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 起始/结束位置 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">开始结束时间</span>
|
||||
<div class="field-control field-control-inline">
|
||||
<el-input-number v-model="template.start" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
|
||||
<span class="inline-separator">~</span>
|
||||
<el-input-number v-model="template.end" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">标题</span>
|
||||
<div class="field-control">
|
||||
<el-input v-model="template.data.title" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品图片(动态追加) -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">图片</span>
|
||||
<div class="field-control field-control-images">
|
||||
<div v-for="(img, imgIdx) in getProductImages(template)" :key="imgIdx" class="image-item">
|
||||
<span class="image-index">#{{ imgIdx + 1 }}</span>
|
||||
<div v-if="img.url" class="image-preview-wrapper">
|
||||
<img :src="img.url" class="image-preview" @click="previewImage(img.url)" />
|
||||
<el-button class="image-remove-btn" type="danger" :icon="Close" circle size="small" @click="removeProductImage(template, imgIdx)" />
|
||||
</div>
|
||||
<el-upload
|
||||
v-else
|
||||
:key="`img-${index}-${imgIdx}`"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:accept="'image/*'"
|
||||
:on-change="(file: any) => handleImageUpload(index, imgIdx, file)"
|
||||
>
|
||||
<el-button size="small" type="primary" class="image-upload-btn">
|
||||
<el-icon><Plus /></el-icon> 上传图片
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-button size="small" class="add-image-btn" @click="addProductImage(template)">
|
||||
<el-icon><Plus /></el-icon> 添加图片
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品描述 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">产品描述</span>
|
||||
<div class="field-control">
|
||||
<el-input
|
||||
v-model="template.data.product_desc"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选填,如果没有内容将会没有贴片信息"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品信息 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">产品信息</span>
|
||||
<div class="field-control">
|
||||
<el-input
|
||||
v-model="template.data.product_info"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选填,如果没有内容将会没有贴片信息"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">免责声明</span>
|
||||
<div class="field-control">
|
||||
<el-input v-model="template.data.disclaimer" type="textarea" :rows="2" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加模板按钮 -->
|
||||
<el-button type="primary" :icon="Plus" class="add-template-btn" @click="addTemplate"> 添加模板 </el-button>
|
||||
|
||||
<!-- 图片预览弹窗 -->
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Plus, Delete, Close, Upload, Download } from '@element-plus/icons-vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
export interface PatchTemplate {
|
||||
url: string;
|
||||
start: number;
|
||||
end: number;
|
||||
data: {
|
||||
title: string;
|
||||
product_desc: string;
|
||||
product_info: string;
|
||||
disclaimer: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取模板中所有产品图片(按 product_image_N 顺序,含已添加的空槽位) */
|
||||
function getProductImages(template: PatchTemplate): { key: string; url: string }[] {
|
||||
const images: { key: string; url: string }[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const key = `product_image_${i}`;
|
||||
if (key in template.data) {
|
||||
images.push({ key, url: template.data[key] || '' });
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** 获取下一个可用的 product_image_N key */
|
||||
function nextImageKey(template: PatchTemplate): string | null {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const key = `product_image_${i}`;
|
||||
if (!template.data[key]) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: PatchTemplate[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: PatchTemplate[]): void;
|
||||
}>();
|
||||
|
||||
// 图片预览
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
const previewImage = (url: string) => {
|
||||
previewUrl.value = url;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const updateTemplates = (newTemplates: PatchTemplate[]) => {
|
||||
emit('update:modelValue', [...newTemplates]);
|
||||
};
|
||||
|
||||
const addTemplate = () => {
|
||||
const newTemplate: PatchTemplate = {
|
||||
url: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
data: {
|
||||
title: '',
|
||||
product_image_1: '',
|
||||
product_desc: '',
|
||||
product_info: '',
|
||||
disclaimer: '',
|
||||
},
|
||||
};
|
||||
updateTemplates([...props.modelValue, newTemplate]);
|
||||
};
|
||||
|
||||
const removeTemplate = (index: number) => {
|
||||
const newList = props.modelValue.filter((_, i) => i !== index);
|
||||
updateTemplates(newList);
|
||||
};
|
||||
|
||||
const addProductImage = (template: PatchTemplate) => {
|
||||
const key = nextImageKey(template);
|
||||
if (!key) {
|
||||
ElMessage.warning('产品图片最多 20 张');
|
||||
return;
|
||||
}
|
||||
template.data[key] = '';
|
||||
updateTemplates([...props.modelValue]);
|
||||
};
|
||||
|
||||
const removeProductImage = (template: PatchTemplate, imgIdx: number) => {
|
||||
// 删除该图片,并把后面的图片 key 前移
|
||||
const images = getProductImages(template);
|
||||
if (imgIdx >= images.length) return;
|
||||
|
||||
const removedKey = images[imgIdx].key;
|
||||
delete template.data[removedKey];
|
||||
|
||||
// 将后面的图片往前挪
|
||||
for (let i = imgIdx + 1; i < images.length; i++) {
|
||||
const oldKey = images[i].key;
|
||||
const newKey = `product_image_${i}`;
|
||||
if (oldKey in template.data) {
|
||||
template.data[newKey] = template.data[oldKey];
|
||||
if (newKey !== oldKey) {
|
||||
delete template.data[oldKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTemplates([...props.modelValue]);
|
||||
};
|
||||
|
||||
const handleTemplateUpload = async (index: number, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
|
||||
|
||||
const newList = [...props.modelValue];
|
||||
newList[index].url = fileUrl;
|
||||
updateTemplates(newList);
|
||||
|
||||
ElMessage.success('模板上传成功');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '模板上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (templateIndex: number, imgIdx: number, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
|
||||
|
||||
const newList = [...props.modelValue];
|
||||
const images = getProductImages(newList[templateIndex]);
|
||||
if (imgIdx < images.length) {
|
||||
newList[templateIndex].data[images[imgIdx].key] = fileUrl;
|
||||
}
|
||||
updateTemplates(newList);
|
||||
|
||||
ElMessage.success('图片上传成功');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 下载参考模板文件
|
||||
const handleDownload = (_e?: Event) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/template_example.html';
|
||||
a.download = 'template_example.html';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.patch-template-editor {
|
||||
width: 100%;
|
||||
|
||||
.template-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.template-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.template-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
.template-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
|
||||
&.template-field-row-vertical {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
min-width: 70px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.field-control-inline {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.inline-number {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.inline-separator {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.uploaded-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-control-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.image-index {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.image-preview-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.image-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.image-remove-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-image-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-template-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -408,6 +408,12 @@
|
||||
</template>
|
||||
</template>
|
||||
<el-empty v-else description="暂无表单配置" :image-size="80" />
|
||||
|
||||
<!-- 贴片模板编辑器(工作流包含贴片节点时显示) -->
|
||||
<PatchTemplateEditor
|
||||
v-if="currentWorkflowHasPatchLayout"
|
||||
v-model="templates"
|
||||
/>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -825,6 +831,7 @@ import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
|
||||
import ModelSelector from '/@/components/model/ModelSelector.vue';
|
||||
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
|
||||
import PromptSelector from './component/PromptSelector.vue';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import type { SkillItem } from '/@/api/settings/skill';
|
||||
import {
|
||||
downloadToFile,
|
||||
@@ -894,6 +901,7 @@ const selectedSkill = ref<SkillItem | null>(null);
|
||||
const showPromptSelector = ref(false);
|
||||
const promptContent = ref('');
|
||||
const isSaveFileEnabled = ref(false);
|
||||
const templates = ref<any[]>([]);
|
||||
const saving = ref(false);
|
||||
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
|
||||
const saveDialogVisible = ref(false);
|
||||
@@ -1073,6 +1081,22 @@ const currentNodeisSaveFile = computed(() => {
|
||||
});
|
||||
return isSaveFile;
|
||||
});
|
||||
// 判断当前工作流是否包含支持贴片布局的节点
|
||||
const currentWorkflowHasPatchLayout = computed(() => {
|
||||
const nodes = currentWorkflowForCreation.value?.nodeInputParams || [];
|
||||
if (!nodes.length || !nodeLibraryGroups.value.length) return false;
|
||||
return nodes.some((node: any) => {
|
||||
if (!node.nodeCode) return false;
|
||||
for (const group of nodeLibraryGroups.value) {
|
||||
for (const item of group.items || []) {
|
||||
if (item.nodeCode === node.nodeCode && item.patchLayout) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
// 获取当前节点的模型类型
|
||||
const currentNodeModelType = computed(() => {
|
||||
const currentNodeCode = String(formState.nodeCode || '').trim();
|
||||
@@ -2193,6 +2217,7 @@ const sendMessage = async () => {
|
||||
flowName: currentWorkflowForCreation.value.flowName || currentWorkflowForCreation.value.flowTemplateName, // 工作流名称
|
||||
fileUrl: fileUrls, // 添加文件 URL 数组
|
||||
resultUrl: currentWorkflowForCreation.value.resultUrl || '', // 添加结果节点 URL
|
||||
templates: templates.value,
|
||||
};
|
||||
|
||||
// 5. 调用执行接口(不再使用 FormData,直接传 JSON)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<script></script>
|
||||
@@ -30,6 +30,14 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 贴片布局开关 -->
|
||||
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
|
||||
<el-switch
|
||||
:model-value="selectedNode.data?.patchLayout ?? false"
|
||||
@update:model-value="emit('update:patchLayout', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
|
||||
<el-divider content-position="left">节点参数</el-divider>
|
||||
@@ -104,6 +112,7 @@ interface NodeData {
|
||||
formConfig?: any[];
|
||||
modelConfig?: any;
|
||||
skillName?: string;
|
||||
patchLayout?: boolean;
|
||||
}
|
||||
|
||||
interface ParamRef {
|
||||
@@ -115,6 +124,7 @@ interface NodeConfig {
|
||||
formConfig: any[];
|
||||
modelConfig: any[];
|
||||
skillOption: boolean;
|
||||
patchLayout: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -135,6 +145,7 @@ const emit = defineEmits<{
|
||||
(e: 'removeField', nodeId: string, fieldName: string): void;
|
||||
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
|
||||
(e: 'addParamByValue', paramValue: string): void;
|
||||
(e: 'update:patchLayout', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const updateNodeLabel = (newLabel: string) => {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
@remove-field="handleRemoveField"
|
||||
@toggle-output="handleToggleOutput"
|
||||
@add-param-by-value="handleAddParamByValue"
|
||||
@update:patch-layout="handleTogglePatchLayout"
|
||||
/>
|
||||
|
||||
<!-- 中间:VueFlow 画布(节点库在画布内) -->
|
||||
@@ -119,6 +120,7 @@ interface NodeData {
|
||||
formConfig?: any[];
|
||||
modelConfig?: any;
|
||||
skillName?: string;
|
||||
patchLayout?: boolean;
|
||||
}
|
||||
|
||||
interface ParamRef {
|
||||
@@ -142,13 +144,14 @@ const filteredNodeLibraryGroups = computed(() => {
|
||||
|
||||
// 节点配置映射:nodeCode -> 节点配置
|
||||
const nodeConfigMap = computed(() => {
|
||||
const map = new Map<string, { formConfig: any[]; modelConfig: any[]; skillOption: boolean }>();
|
||||
const map = new Map<string, { formConfig: any[]; modelConfig: any[]; skillOption: boolean; patchLayout: boolean }>();
|
||||
nodeLibraryGroups.value.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
map.set(item.nodeCode, {
|
||||
formConfig: item.formConfig || [],
|
||||
modelConfig: item.modelConfig || [],
|
||||
skillOption: item.skillOption || false,
|
||||
patchLayout: item.patchLayout || false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -436,6 +439,27 @@ const handleRemoveSkill = () => {
|
||||
ElMessage.success('技能已移除');
|
||||
};
|
||||
|
||||
// 切换贴片布局
|
||||
const handleTogglePatchLayout = (value: boolean) => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
|
||||
const updatedNode: Node<NodeData> = {
|
||||
...selectedNode.value,
|
||||
data: {
|
||||
...selectedNode.value.data,
|
||||
patchLayout: value,
|
||||
},
|
||||
};
|
||||
|
||||
selectedNode.value = updatedNode;
|
||||
// 同步更新到 VueFlow 内部状态
|
||||
updateNode(updatedNode.id, updatedNode);
|
||||
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
|
||||
if (index >= 0) {
|
||||
nodes.value[index] = updatedNode;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除上级参数字段
|
||||
const handleRemoveField = (nodeId: string, fieldName: string) => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
@@ -662,7 +686,7 @@ const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
|
||||
id: `node-${++nodeId}`,
|
||||
type: 'default',
|
||||
position: { x: spawnX, y: spawnY },
|
||||
data: { label: nodeName, nodeCode, inputSource: null },
|
||||
data: { label: nodeName, nodeCode, inputSource: null, patchLayout: false },
|
||||
style: { background: '#fff', border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px 20px' },
|
||||
},
|
||||
]);
|
||||
@@ -761,6 +785,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
||||
formConfig: n.formConfig || null,
|
||||
modelConfig: n.modelConfig || null,
|
||||
skillName: n.skillName || null,
|
||||
patchLayout: n.patchLayout || false,
|
||||
},
|
||||
style: { background: '#fff', border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px 20px' },
|
||||
};
|
||||
@@ -824,6 +849,7 @@ const confirmSaveWorkflow = async () => {
|
||||
name: n.data?.label || '',
|
||||
type: n.type || 'default',
|
||||
skillName: n.data?.skillName || null,
|
||||
patchLayout: n.data?.patchLayout || false,
|
||||
config: {
|
||||
nodeCode: n.data?.nodeCode || 'unknown',
|
||||
x: n.position?.x || 0,
|
||||
|
||||
Reference in New Issue
Block a user