视频贴片相关

This commit is contained in:
2026-07-06 16:08:02 +08:00
parent 595858ae5e
commit 422c85a068
7 changed files with 717 additions and 2 deletions
+1
View File
@@ -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>
+25
View File
@@ -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) => {
+28 -2
View File
@@ -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,