feat: 更新数字人创作页面以支持工作流和输入功能

- 修改工作流项接口,新增流模板名称和用户流列表支持
- 引入新的输入区域,允许用户上传文件并选择创作技能
- 实现工作流列表的Tab切换,分为“我的工作流”和“模板工作流”
- 优化界面布局和交互,提升用户体验
This commit is contained in:
2026-05-08 22:00:00 +08:00
parent 8cc5f4be64
commit a285c9d982
2 changed files with 493 additions and 76 deletions

View File

@@ -144,7 +144,8 @@ export function saveWorkflow(data: { flowName: string; description: string; flow
export interface WorkflowItem {
id: string;
flowName: string;
flowName?: string;
flowTemplateName?: string;
description: string;
flowContent: any;
nodeInputParams?: any[];
@@ -154,7 +155,15 @@ export interface WorkflowListResponse {
code: number;
message: string;
data: {
listFlowUserRes: {
list: WorkflowItem[];
total: number;
};
listFlowTemplateRes: {
list: WorkflowItem[];
total: number;
};
isAdmin?: boolean;
};
}
@@ -198,3 +207,72 @@ export function deleteWorkflow(id: string, requestOptions?: RequestOptions) {
requestOptions,
});
}
// 执行工作流相关类型定义
export interface FlowNodeFormField {
default?: any;
field?: string;
label?: string;
options?: { label?: string; value?: string }[];
required?: boolean;
type?: string;
value?: string;
}
export interface FlowNodeInputSource {
field?: string[];
nodeId?: string;
quoteOutput?: boolean;
}
export interface FlowNodeModelItem {
modelApiKey?: string;
modelForm?: FlowNodeFormField[];
modelName?: string;
}
export interface FlowNode {
config?: { [key: string]: any };
formConfig?: FlowNodeFormField[];
id?: string;
inputSource?: FlowNodeInputSource[];
modelConfig?: FlowNodeModelItem;
name?: string;
nodeCode?: string;
outputResult?: FlowNodeFormField[];
skillName?: string;
}
export interface FlowEdge {
from?: string;
id?: string;
to?: string;
}
export interface FlowInfo {
edges?: FlowEdge[];
nodes?: FlowNode[];
startNodeId?: string;
version?: string;
}
export interface ExecuteFlowParams {
desc?: string;
fileUrl?: string[];
flowContent?: FlowInfo;
flowId?: number;
nodeInputParams?: FlowNode[];
sessionId?: string;
skillName?: string;
}
export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/execute',
method: 'post',
data,
headers: data instanceof FormData ? { 'Content-Type': 'multipart/form-data' } : undefined,
timeout: 0,
requestOptions,
});
}

View File

@@ -296,8 +296,45 @@
<el-empty v-else description="暂无表单配置" :image-size="100" />
</el-form>
</div>
<div class="creation-actions">
<el-button type="primary" size="large" class="w100">开始创作</el-button>
<!-- AI 创作输入区域 -->
<div class="creation-input-area">
<!-- 已选文件列表 -->
<div v-if="selectedFiles.length > 0" class="selected-files-top">
<el-tag v-for="(file, index) in selectedFiles" :key="index" closable @close="removeFile(index)" type="info" size="small">
{{ file.name }}
</el-tag>
</div>
<!-- 输入框容器 -->
<div class="chat-input-container">
<!-- 左侧工具按钮 -->
<div class="input-tools-left">
<el-upload :auto-upload="false" :show-file-list="false" :on-change="handleFileSelect" multiple>
<el-button text :icon="Paperclip" class="tool-btn" />
</el-upload>
<el-button text :icon="MagicStick" @click="showCreationSkillSelector = true" class="tool-btn" />
</div>
<!-- 输入框 -->
<el-input v-model="userInput" placeholder="说点什么..." class="chat-input" @keydown.enter="sendMessage" />
<!-- 右侧发送按钮 -->
<el-button
type="primary"
:icon="Promotion"
:loading="isCreating"
:disabled="!userInput.trim()"
@click="sendMessage"
class="send-btn"
circle
/>
</div>
<!-- 已选技能标签 -->
<div v-if="selectedCreationSkill" class="selected-skill-bottom">
<el-tag type="success" closable @close="selectedCreationSkill = null" size="small"> 技能: {{ selectedCreationSkill.name }} </el-tag>
</div>
</div>
</div>
</div>
@@ -343,15 +380,18 @@
<!-- 右侧:工作流列表(竖状) -->
<div class="panel right-panel">
<el-tabs v-model="workflowTab" class="workflow-tabs">
<!-- Tab 1: 我的工作流 -->
<el-tab-pane label="我的工作流" name="user">
<div class="right-panel-header">
<div class="title-sm">我的工作流</div>
<el-button type="success" size="small" @click="createNewWorkflow">新建</el-button>
<el-button type="primary" link size="small" @click="refreshWorkflowList">刷新</el-button>
</div>
<div class="workflow-list-vertical" v-loading="workflowListLoading">
<el-empty v-if="!workflowListLoading && workflowList.length === 0" description="暂无工作流" :image-size="60" />
<el-empty v-if="!workflowListLoading && userWorkflowList.length === 0" description="暂无工作流" :image-size="60" />
<div v-else class="workflow-list-scroll">
<div
v-for="workflow in workflowList"
v-for="workflow in userWorkflowList"
:key="workflow.id"
class="workflow-item"
:class="{ active: currentEditingWorkflowId === workflow.id }"
@@ -369,15 +409,51 @@
</div>
</div>
<!-- 分页 -->
<div v-if="workflowPagination.total > 0" class="workflow-pagination">
<div v-if="userWorkflowPagination.total > 0" class="workflow-pagination">
<el-pagination
v-model:current-page="workflowPagination.pageNum"
:page-size="workflowPagination.pageSize"
:total="workflowPagination.total"
v-model:current-page="userWorkflowPagination.pageNum"
:page-size="userWorkflowPagination.pageSize"
:total="userWorkflowPagination.total"
layout="prev, pager, next"
@current-change="handlePageChange"
@current-change="handleUserPageChange"
/>
</div>
</el-tab-pane>
<!-- Tab 2: 模板工作流 -->
<el-tab-pane label="模板工作流" name="template">
<div class="right-panel-header">
<el-button v-if="isAdmin" type="success" size="small" @click="createNewWorkflow">新建</el-button>
<el-button type="primary" link size="small" @click="refreshWorkflowList">刷新</el-button>
</div>
<div class="workflow-list-vertical" v-loading="workflowListLoading">
<el-empty v-if="!workflowListLoading && templateWorkflowList.length === 0" description="暂无模板" :image-size="60" />
<div v-else class="workflow-list-scroll">
<div v-for="workflow in templateWorkflowList" :key="workflow.id" class="workflow-item" @click="useWorkflow(workflow)">
<div class="workflow-item-content">
<div class="workflow-item-name">{{ workflow.flowName || workflow.flowTemplateName }}</div>
<div class="workflow-item-desc">{{ workflow.description || '暂无描述' }}</div>
</div>
<div class="workflow-item-actions">
<el-button type="primary" link size="small" @click.stop="editWorkflow(workflow)">编辑</el-button>
<el-button type="success" link size="small" @click.stop="useWorkflow(workflow)">使用</el-button>
<el-button v-if="isAdmin" type="danger" link size="small" @click.stop="deleteWorkflowAction(workflow)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="templateWorkflowPagination.total > 0" class="workflow-pagination">
<el-pagination
v-model:current-page="templateWorkflowPagination.pageNum"
:page-size="templateWorkflowPagination.pageSize"
:total="templateWorkflowPagination.total"
layout="prev, pager, next"
@current-change="handleTemplatePageChange"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 保存工作流对话框 -->
@@ -403,13 +479,16 @@
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
<!-- 创作技能选择器 -->
<SkillSelector v-model="showCreationSkillSelector" :default-skill="selectedCreationSkill" @confirm="handleCreationSkillConfirm" />
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Document, Plus } from '@element-plus/icons-vue';
import { Document, Plus, Paperclip, MagicStick, Promotion } from '@element-plus/icons-vue';
import LogicFlow from '@logicflow/core';
import { Control, SelectionSelect } from '@logicflow/extension';
import '@logicflow/core/dist/index.css';
@@ -425,11 +504,13 @@ import {
updateWorkflow,
deleteWorkflow,
saveWorkflow,
executeFlow,
type CreationListParams,
type CreationTreeItem,
type NodeLibraryFormItem,
type NodeLibraryGroup,
type WorkflowItem,
type ExecuteFlowParams,
} from '/@/api/digitalHuman/creation';
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
@@ -467,17 +548,40 @@ const saveForm = reactive({
flowName: '',
description: '',
});
const workflowList = ref<WorkflowItem[]>([]);
const workflowListLoading = ref(false);
const currentEditingWorkflowId = ref<string | null>(null);
const isCreationMode = ref(false); // 是否处于创作模式
const currentWorkflowForCreation = ref<any>(null); // 当前用于创作的工作流数据
const creationFormValues = reactive<Record<string, any>>({}); // 创作表单的值
const workflowPagination = reactive({
const workflowTab = ref('user'); // 工作流 Tabuser 或 template
const userWorkflowList = ref<WorkflowItem[]>([]); // 用户工作流列表
const templateWorkflowList = ref<WorkflowItem[]>([]); // 模板工作流列表
const isAdmin = ref(false); // 是否为管理员
const userWorkflowPagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0,
});
const templateWorkflowPagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0,
});
// AI 创作输入相关状态
const userInput = ref('');
const selectedFiles = ref<File[]>([]);
const selectedCreationSkill = ref<SkillItem | null>(null);
const showCreationSkillSelector = ref(false);
const isCreating = ref(false);
// 会话ID管理存储在 sessionStorage 中)
const getSessionId = () => {
let sessionId = sessionStorage.getItem('ai_creation_session_id');
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('ai_creation_session_id', sessionId);
}
return sessionId;
};
// 格式化参数引用显示
const formatParamReference = (value: string) => {
// 从 ${nodeId.field} 提取节点名和字段名
@@ -715,28 +819,61 @@ const fetchWorkflowList = async () => {
workflowListLoading.value = true;
try {
const res = await getWorkflowList({ errorMode: 'page' });
const allWorkflows = res.data?.list || [];
workflowPagination.total = allWorkflows.length;
// 计算分页
const start = (workflowPagination.pageNum - 1) * workflowPagination.pageSize;
const end = start + workflowPagination.pageSize;
workflowList.value = allWorkflows.slice(start, end);
// 分别处理用户工作流和模板工作流
const userWorkflows = res.data?.listFlowUserRes?.list || [];
const templateWorkflows = res.data?.listFlowTemplateRes?.list || [];
// 获取管理员权限
isAdmin.value = res.data?.isAdmin || false;
// 用户工作流分页
userWorkflowPagination.total = userWorkflows.length;
const userStart = (userWorkflowPagination.pageNum - 1) * userWorkflowPagination.pageSize;
const userEnd = userStart + userWorkflowPagination.pageSize;
userWorkflowList.value = userWorkflows.slice(userStart, userEnd);
// 模板工作流分页
templateWorkflowPagination.total = templateWorkflows.length;
const templateStart = (templateWorkflowPagination.pageNum - 1) * templateWorkflowPagination.pageSize;
const templateEnd = templateStart + templateWorkflowPagination.pageSize;
templateWorkflowList.value = templateWorkflows.slice(templateStart, templateEnd);
} catch {
workflowList.value = [];
workflowPagination.total = 0;
userWorkflowList.value = [];
templateWorkflowList.value = [];
userWorkflowPagination.total = 0;
templateWorkflowPagination.total = 0;
isAdmin.value = false;
} finally {
workflowListLoading.value = false;
}
};
// 刷新工作流列表
const refreshWorkflowList = () => {
workflowPagination.pageNum = 1;
userWorkflowPagination.pageNum = 1;
templateWorkflowPagination.pageNum = 1;
fetchWorkflowList();
};
// 处理分页变化
const handlePageChange = (page: number) => {
workflowPagination.pageNum = page;
// 新建工作流
const createNewWorkflow = () => {
// 切换回画布编辑模式
isCreationMode.value = false;
currentWorkflowForCreation.value = null;
// 清空当前编辑状态
currentEditingWorkflowId.value = null;
// 重置画布
resetFlow();
};
// 处理用户工作流分页变化
const handleUserPageChange = (page: number) => {
userWorkflowPagination.pageNum = page;
fetchWorkflowList();
};
// 处理模板工作流分页变化
const handleTemplatePageChange = (page: number) => {
templateWorkflowPagination.pageNum = page;
fetchWorkflowList();
};
// 处理技能选择确认
@@ -754,7 +891,6 @@ const handleSkillConfirm = (skill: SkillItem) => {
selectedElement.value.properties.skillName = skill.name;
}
}
ElMessage.success('技能选择成功');
};
// 移除已选择的技能
const handleRemoveSkill = () => {
@@ -807,7 +943,7 @@ const useWorkflow = async (workflow: WorkflowItem) => {
});
}
ElMessage.success(`已进入创作模式${res.data.flowName}`);
ElMessage.success(`已进入创作模式`);
} else {
ElMessage.warning('该工作流没有内容');
}
@@ -831,9 +967,8 @@ const editWorkflow = async (workflow: WorkflowItem) => {
loadWorkflowFromDsl(res.data.flowContent);
// 预填充保存表单并记录当前编辑的工作流ID
currentEditingWorkflowId.value = res.data.id;
saveForm.flowName = res.data.flowName;
saveForm.flowName = res.data.flowName || res.data.flowTemplateName || '';
saveForm.description = res.data.description || '';
ElMessage.success(`正在编辑工作流:${res.data.flowName}`);
} else {
ElMessage.warning('该工作流没有内容');
}
@@ -857,25 +992,12 @@ const backToCanvas = async () => {
lf.zoom(1);
lf.translateCenter();
}
ElMessage.info('已返回画布编辑模式');
};
// 判断节点是否有可见字段
const hasVisibleFields = (node: any) => {
if (!node.config) return false;
const excludeKeys = ['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'];
return Object.keys(node.config).some((key) => !excludeKeys.includes(key));
};
// 根据字段类型返回CSS类名
const getFieldClass = (type: string) => {
if (type === 'textarea') return 'form-item-full';
if (type === 'number' || type === 'switch') return 'form-item-small';
return 'form-item-medium';
};
// 删除工作流
const deleteWorkflowAction = async (workflow: WorkflowItem) => {
try {
await ElMessageBox.confirm(`确定要删除工作流"${workflow.flowName}"吗?此操作不可恢复。`, '删除确认', {
const workflowName = workflow.flowName || workflow.flowTemplateName || '该工作流';
await ElMessageBox.confirm(`确定要删除工作流"${workflowName}"吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
@@ -899,6 +1021,136 @@ const deleteWorkflowAction = async (workflow: WorkflowItem) => {
}
}
};
// 处理文件选择
const handleFileSelect = (file: any) => {
selectedFiles.value.push(file.raw);
};
// 移除文件
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1);
};
// 处理创作技能选择
const handleCreationSkillConfirm = (skill: SkillItem) => {
selectedCreationSkill.value = skill;
};
// 发送消息/开始创作
const sendMessage = async () => {
if (!userInput.value.trim()) {
ElMessage.warning('请输入创作需求');
return;
}
if (!currentWorkflowForCreation.value) {
ElMessage.warning('请先选择一个工作流');
return;
}
isCreating.value = true;
try {
// 1. 构建节点输入参数
const nodeInputParams = currentWorkflowForCreation.value.nodeInputParams?.map((node: any) => {
const nodeParam: any = {
id: node.id,
nodeCode: node.nodeCode,
name: node.name,
};
// 添加表单配置和值
if (node.formConfig && Array.isArray(node.formConfig)) {
nodeParam.formConfig = node.formConfig.map((field: any) => {
const fieldKey = `${node.id}_${field.label}`;
return {
...field,
value: creationFormValues[fieldKey] || field.value || '',
};
});
}
// 添加其他配置
if (node.config) {
nodeParam.config = { ...node.config };
// 更新 config 中的值
Object.keys(node.config).forEach((key) => {
const fieldKey = `${node.id}_${key}`;
if (creationFormValues[fieldKey] !== undefined) {
nodeParam.config[key] = creationFormValues[fieldKey];
}
});
}
// 添加其他字段
if (node.inputSource) nodeParam.inputSource = node.inputSource;
if (node.modelConfig) nodeParam.modelConfig = node.modelConfig;
if (node.skillName) nodeParam.skillName = node.skillName;
return nodeParam;
}) || [];
// 2. 同步更新 flowContent.nodes使其与 nodeInputParams 一致
const updatedFlowContent = {
...currentWorkflowForCreation.value.flowContent,
nodes: nodeInputParams, // 使用更新后的节点参数
};
// 3. 构建请求参数
const params: ExecuteFlowParams = {
flowId: parseInt(currentWorkflowForCreation.value.id),
flowContent: updatedFlowContent,
nodeInputParams: nodeInputParams,
sessionId: getSessionId(),
desc: userInput.value,
skillName: selectedCreationSkill.value?.name,
};
// 4. 使用 FormData 传递文件流
const formData = new FormData();
// 添加文件
if (selectedFiles.value.length > 0) {
selectedFiles.value.forEach((file) => {
formData.append('files', file);
});
}
// 添加其他参数(转为 JSON 字符串)
formData.append('flowId', params.flowId!.toString());
formData.append('flowContent', JSON.stringify(params.flowContent));
formData.append('nodeInputParams', JSON.stringify(params.nodeInputParams));
formData.append('sessionId', params.sessionId!);
formData.append('desc', params.desc!);
if (params.skillName) {
formData.append('skillName', params.skillName);
}
// 5. 调用执行接口
await executeFlow(formData, { errorMode: 'page' });
ElMessage.success('创作完成!');
// 6. 清空输入
userInput.value = '';
selectedFiles.value = [];
selectedCreationSkill.value = null;
} catch (error) {
ElMessage.error('创作失败,请重试');
} finally {
isCreating.value = false;
}
};
// 判断节点是否有可见字段
const hasVisibleFields = (node: any) => {
if (!node.config) return false;
const excludeKeys = ['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'];
return Object.keys(node.config).some((key) => !excludeKeys.includes(key));
};
// 根据字段类型返回CSS类名
const getFieldClass = (type: string) => {
if (type === 'textarea') return 'form-item-full';
if (type === 'number' || type === 'switch') return 'form-item-small';
return 'form-item-medium';
};
const handleNodeClick = (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
const url = buildAssetUrl(d.fileUrl);
@@ -1536,7 +1788,7 @@ const applySelected = () => {
syncDsl();
ElMessage.success('已更新当前元素配置');
// 静默更新,不显示提示
} catch (error) {
ElMessage.error('应用配置失败,请查看控制台');
}
@@ -1734,7 +1986,6 @@ const resetFlow = () => {
nodeSpawnIndex.value = 0;
selectedElement.value = null;
syncDsl();
ElMessage.success('流程已重置');
};
// 从后端 DSL 恢复工作流
const loadWorkflowFromDsl = (dsl: any) => {
@@ -1781,7 +2032,6 @@ const loadWorkflowFromDsl = (dsl: any) => {
lf.zoom(1);
lf.translateCenter();
syncDsl();
ElMessage.success('工作流已加载');
} catch (error) {
ElMessage.error('工作流加载失败');
}
@@ -2317,7 +2567,7 @@ onBeforeUnmount(() => {
min-height: 100px;
}
.workflow-pagination {
margin-top: 20px;
margin-top: 12px;
display: flex;
justify-content: center;
padding: 12px 0;
@@ -2671,10 +2921,28 @@ onBeforeUnmount(() => {
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.workflow-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.workflow-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.workflow-tabs :deep(.el-tab-pane) {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.right-panel-header {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-shrink: 0;
}
@@ -2762,4 +3030,75 @@ onBeforeUnmount(() => {
font-size: 14px;
padding: 8px 16px;
}
/* AI 创作输入区域样式 */
.creation-input-area {
padding: 20px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.selected-files-top {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.chat-input-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 24px;
transition: all 0.2s ease;
}
.chat-input-container:focus-within {
border-color: #3b82f6;
background: #fff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-tools-left {
display: flex;
align-items: center;
gap: 4px;
}
.tool-btn {
width: 32px;
height: 32px;
padding: 0;
font-size: 18px;
color: #64748b;
transition: all 0.2s ease;
}
.tool-btn:hover {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.chat-input {
flex: 1;
}
.chat-input :deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
padding: 0;
}
.chat-input :deep(.el-input__inner) {
font-size: 14px;
color: #1e293b;
}
.send-btn {
width: 36px;
height: 36px;
padding: 0;
flex-shrink: 0;
}
.send-btn:disabled {
opacity: 0.5;
}
.selected-skill-bottom {
margin-top: 12px;
display: flex;
gap: 8px;
}
</style>