feat: 主页对话功能完善 — 会话列表、工作流执行、样式优化
- 新增会话列表 API(getSessionList)及类型定义 - 修复对话记录为空:兼容 API 返回 data.list 结构 - 删除接口改为 DELETE 方法,参数统一使用 id - 实现工作流执行逻辑,与内容创作对齐(含表单参数、文件上传、flowContent) - 动态表单样式优化:三段式布局防溢出、上传区支持拖拽、节点分组显示 - 对话记录时间格式化:当天显示 HH:mm,非当天显示日期 - 对话记录默认展示全部,移除分页展开 - 发送按钮支持选中工作流时空文本可点击 - 默认占位展示引导提示,替代空状态 - 添加对话记录删除确认弹窗 - 下载改用后端代理,避免跨域问题
This commit is contained in:
@@ -349,3 +349,45 @@ export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?:
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
/** 删除工作空间结果(单个文件) */
|
||||
export function deleteExecutionResult(data: { id: string | number }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/deleteResult',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除会话记录 */
|
||||
export function deleteExecutionSession(data: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/deleteSession',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: SessionListItem[];
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
flowName: string;
|
||||
sessionId: string;
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 获取会话记录列表 */
|
||||
export function getSessionList(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/sessionList',
|
||||
method: 'get',
|
||||
requestOptions,
|
||||
}) as Promise<SessionListResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -300,24 +300,25 @@ const handleDownload = (_e?: Event) => {
|
||||
width: 100%;
|
||||
|
||||
.template-card {
|
||||
background: #f8fafc;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.template-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.template-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon class="tag-close" @click="selectedWorkflowId = null"><Close /></el-icon>
|
||||
<el-icon class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
<button class="send-btn" :disabled="!message.trim()" @click="handleSend">
|
||||
<button class="send-btn" :disabled="!message.trim() && selectedWorkflowId === null" @click="handleSend">
|
||||
<el-icon><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -70,10 +70,11 @@ import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
(e: 'workflow-select', workflowId: string | null): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
}
|
||||
@@ -81,7 +82,7 @@ interface Workflow {
|
||||
const emit = defineEmits<Emits>();
|
||||
const message = ref('');
|
||||
const isFocused = ref(false);
|
||||
const selectedWorkflowId = ref<number | null>(null);
|
||||
const selectedWorkflowId = ref<string | null>(null);
|
||||
const commonWorkflows = ref<Workflow[]>([]);
|
||||
|
||||
const fetchWorkflows = async () => {
|
||||
@@ -89,7 +90,7 @@ const fetchWorkflows = async () => {
|
||||
const res = await getWorkflowList();
|
||||
const workflows = res.data?.listFlowUserRes?.list || [];
|
||||
commonWorkflows.value = workflows.map((w) => ({
|
||||
id: Number(w.id),
|
||||
id: String(w.id),
|
||||
name: w.flowName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
|
||||
}));
|
||||
@@ -100,22 +101,26 @@ const fetchWorkflows = async () => {
|
||||
|
||||
const handleSend = () => {
|
||||
const msg = message.value.trim();
|
||||
if (!msg) return;
|
||||
const workflowPrefix = selectedWorkflowId.value !== null
|
||||
? commonWorkflows.value.find((w) => w.id === selectedWorkflowId.value)?.prefix || ''
|
||||
: '';
|
||||
const finalMessage = (workflowPrefix + msg).trim();
|
||||
emit('send', finalMessage);
|
||||
if (!msg && selectedWorkflowId.value === null) return;
|
||||
emit('send', msg || '');
|
||||
message.value = '';
|
||||
selectedWorkflowId.value = null;
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
ElMessage.info('附件上传功能开发中...');
|
||||
};
|
||||
|
||||
const toggleWorkflow = (id: number) => {
|
||||
selectedWorkflowId.value = selectedWorkflowId.value === id ? null : id;
|
||||
const toggleWorkflow = (id: string) => {
|
||||
const newId = selectedWorkflowId.value === id ? null : id;
|
||||
selectedWorkflowId.value = newId;
|
||||
emit('workflow-select', newId);
|
||||
};
|
||||
|
||||
const clearWorkflow = () => {
|
||||
selectedWorkflowId.value = null;
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,88 +1,596 @@
|
||||
<template>
|
||||
<div class="main-content">
|
||||
<div v-if="activeMenu !== 'chat'" class="content-header">
|
||||
<h2 class="content-title">{{ currentTitle }}</h2>
|
||||
<div class="content-actions">
|
||||
<!-- 工作流动态表单 -->
|
||||
<div v-if="workflowDetail" class="content-body workflow-form-body">
|
||||
<div class="workflow-form-card">
|
||||
<div class="workflow-form-header">
|
||||
<div>
|
||||
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
|
||||
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
|
||||
</div>
|
||||
<el-button size="small" @click="$emit('close-workflow')">返回对话</el-button>
|
||||
</div>
|
||||
<div class="workflow-form-scroll">
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<template v-if="workflowDetail.nodeInputParams">
|
||||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
||||
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
:key="getFieldKey(node, field)"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
class="w100"
|
||||
:controls="true"
|
||||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||||
/>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
show-word-limit
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||||
<el-upload
|
||||
:key="`upload-${getFieldKey(node, field)}`"
|
||||
:auto-upload="false"
|
||||
:multiple="field.type === 'uploadMultiple'"
|
||||
:show-file-list="false"
|
||||
:accept="getFileAccept(field)"
|
||||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||||
:disabled="uploadingFields[getFieldKey(node, field)]"
|
||||
>
|
||||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="upload-rules">
|
||||
<span>{{ getFileRuleText(field) }}</span>
|
||||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||||
已上传 {{ getFieldFileCountText(node, field) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||||
<div
|
||||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||||
:key="fileIdx"
|
||||
class="uploaded-file-item"
|
||||
>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<el-button type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兜底文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- 贴片模板编辑器 -->
|
||||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||||
<PatchTemplateEditor v-model="templates" />
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="chat-container" v-if="activeMenu === 'chat'">
|
||||
<!-- 对话区域 -->
|
||||
<div v-if="activeHistoryId && activeMenu === 'chat'" class="content-body">
|
||||
<div class="chat-container">
|
||||
<ChatList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认占位 -->
|
||||
<div v-if="!workflowDetail && !(activeHistoryId && activeMenu === 'chat')" class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
<div class="placeholder-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="12" width="64" height="48" rx="8" fill="url(#grad1)" fill-opacity="0.18"/>
|
||||
<rect x="8" y="12" width="64" height="48" rx="8" stroke="url(#grad1)" stroke-width="1.2"/>
|
||||
<path d="M28 32h24M28 40h16M28 48h20" stroke="url(#grad1)" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M62 72c0-8.837-7.163-16-16-16s-16 7.163-16 16" stroke="url(#grad2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="46" cy="48" r="4" fill="url(#grad2)"/>
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0" y1="0" x2="80" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad2" x1="0" y1="0" x2="80" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8b5cf6"/>
|
||||
<stop offset="1" stop-color="#ec4899"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="placeholder-title">开始内容创作</h2>
|
||||
<p class="placeholder-desc">选择下方的工作流,填写参数后即可快速生成内容</p>
|
||||
<div class="placeholder-hints">
|
||||
<div class="hint-item">
|
||||
<span class="hint-dot"></span>
|
||||
<span>点击工作流胶囊快速切换</span>
|
||||
</div>
|
||||
<div class="hint-item">
|
||||
<span class="hint-dot"></span>
|
||||
<span>在「对话记录」中查看历史</span>
|
||||
</div>
|
||||
<div class="hint-item">
|
||||
<span class="hint-dot"></span>
|
||||
<span>在「工作空间」中管理作品</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { UploadFilled, Document } from '@element-plus/icons-vue';
|
||||
import ChatList from './ChatList.vue';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
workflowDetail: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeHistoryId: null,
|
||||
});
|
||||
defineEmits<{
|
||||
(e: 'close-workflow'): void;
|
||||
}>();
|
||||
|
||||
const menuTitles: Record<string, string> = {
|
||||
chat: '对话',
|
||||
models: '模型管理',
|
||||
creation: '内容创作',
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
if (field?.__isHttpBodyChild) {
|
||||
return `${node.id}_body_${field.bodyKey}`;
|
||||
}
|
||||
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
|
||||
};
|
||||
|
||||
const currentTitle = computed(() => menuTitles[props.activeMenu] || '首页');
|
||||
const getVisibleFields = (node: any): any[] => {
|
||||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
||||
const result: any[] = [];
|
||||
fields.forEach((field: any) => {
|
||||
if (!field) return;
|
||||
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
|
||||
|
||||
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
|
||||
if (field.field !== 'body') return;
|
||||
const bodyVal = field.value;
|
||||
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
|
||||
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
result.push({
|
||||
__isHttpBodyChild: true,
|
||||
bodyKey,
|
||||
field: `body.${bodyKey}`,
|
||||
label: bodyItem.key || bodyKey,
|
||||
required: false,
|
||||
type: bodyItem.fieldType || 'input',
|
||||
fieldType: bodyItem.fieldType || 'string',
|
||||
fieldConstraint: bodyItem.fieldConstraint || {},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
result.push(field);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
|
||||
const getFileAccept = (field: any): string => {
|
||||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||||
};
|
||||
|
||||
const getFileRuleText = (field: any): string => {
|
||||
const rules: string[] = [];
|
||||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||||
return rules.join(' | ');
|
||||
};
|
||||
|
||||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||||
return fieldFiles[getFieldKey(node, field)] || [];
|
||||
};
|
||||
|
||||
const getFieldConstraint = (field: any): any => {
|
||||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||||
};
|
||||
|
||||
const getFieldFileCountText = (node: any, field: any): string => {
|
||||
const current = getFieldFileList(node, field).length;
|
||||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||||
if (!Number.isNaN(max) && max > 0) {
|
||||
return `${current} / ${max}`;
|
||||
}
|
||||
return `${current}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
const fc = getFieldConstraint(field);
|
||||
|
||||
const typeRules = String(fc.fileTypes || '')
|
||||
.split(',')
|
||||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||||
.filter(Boolean);
|
||||
if (typeRules.length > 0) {
|
||||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||||
if (!typeRules.includes(ext)) {
|
||||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileSize = Number(fc.maxFileSize);
|
||||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||||
const sizeMB = raw.size / 1024 / 1024;
|
||||
if (sizeMB > maxFileSize) {
|
||||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileCount = Number(fc.maxFileCount);
|
||||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||||
const currentCount = fieldFiles[key]?.length || 0;
|
||||
if (currentCount >= maxFileCount) {
|
||||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploadingFields[key] = true;
|
||||
|
||||
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;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
||||
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = fileUrl;
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
} finally {
|
||||
uploadingFields[key] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||||
const key = getFieldKey(node, field);
|
||||
if (!fieldFiles[key]) return;
|
||||
fieldFiles[key].splice(fileIdx, 1);
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = '';
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||||
}
|
||||
};
|
||||
|
||||
const currentWorkflowHasPatchLayout = computed(() => {
|
||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||||
return nodes.some((node: any) => node.patchLayout === true);
|
||||
});
|
||||
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some(
|
||||
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||||
for (const node of props.workflowDetail.nodeInputParams as any[]) {
|
||||
if (node.nodeCode === '__start__') continue;
|
||||
const fields = getVisibleFields(node);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
const key = getFieldKey(node, field);
|
||||
const value = formValues[key];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
ElMessage.warning(`请填写必填项:${field.label}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 初始化或重置表单
|
||||
watch(
|
||||
() => props.workflowDetail,
|
||||
(detail) => {
|
||||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||||
templates.value = [];
|
||||
|
||||
if (!detail?.nodeInputParams) return;
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||||
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||||
if (bodyItem.fieldType === 'number') {
|
||||
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
|
||||
? Number(bodyItem.value) : null;
|
||||
} else if (bodyItem.fieldType === 'fileUpload') {
|
||||
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
|
||||
: bodyItem.value ? [bodyItem.value] : [];
|
||||
} else {
|
||||
formValues[bodyFieldKey] = bodyItem.value || '';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||||
formValues[key] = field.default ?? null;
|
||||
} else if (field.type === 'switch') {
|
||||
formValues[key] = field.default ?? false;
|
||||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||||
formValues[key] = field.default ?? (field.type === 'upload' ? '' : []);
|
||||
} else {
|
||||
formValues[key] = field.default ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
if (!isFileField(field)) return;
|
||||
const key = getFieldKey(node, field);
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
fieldFiles[key] = urls.map((url: string) => ({
|
||||
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main-content {
|
||||
<style scoped lang="scss">.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(1200px 420px at 58% -120px, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0) 65%),
|
||||
radial-gradient(900px 320px at 20% 120%, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0) 68%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #f5f7fb 100%);
|
||||
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* ===== 工作流表单 ===== */
|
||||
.workflow-form-body {
|
||||
width: min(820px, 100%);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.workflow-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.workflow-form-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
}
|
||||
|
||||
.form-node-group {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
.form-node-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||||
.el-input-number { width: 100%; }
|
||||
.el-select { width: 100%; }
|
||||
.el-textarea__inner { font-size: 13px; }
|
||||
}
|
||||
|
||||
:deep(.el-divider) {
|
||||
border-top-color: #e2e8f0;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
|
||||
}
|
||||
|
||||
:deep(.upload-area) {
|
||||
width: 100%;
|
||||
.el-upload-dragger {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: auto; padding: 20px;
|
||||
border: 2px dashed #e2e8f0; border-radius: 10px;
|
||||
background: #fff; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: #93c5fd; background: #f8faff; }
|
||||
}
|
||||
.upload-trigger { display: flex; align-items: center; gap: 12px; }
|
||||
.upload-icon { font-size: 28px; color: #93c5fd; }
|
||||
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
|
||||
.upload-rule { font-size: 11px; color: #94a3b8; }
|
||||
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
|
||||
}
|
||||
|
||||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.uploaded-file-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; background: #fff;
|
||||
border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f1f5f9; }
|
||||
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
|
||||
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.patch-template-section {
|
||||
margin-top: 16px;
|
||||
:deep(.patch-template-editor) {
|
||||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||||
}
|
||||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: min(1060px, 82%);
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 130px;
|
||||
}
|
||||
</style>
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 40px 20px; max-width: 400px; }
|
||||
.placeholder-icon { margin-bottom: 24px; display: flex; justify-content: center; }
|
||||
.placeholder-title { font-size: 20px; font-weight: 700; color: #1e293b; margin: 0 0 8px; letter-spacing: -0.3px; }
|
||||
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 28px; line-height: 1.6; }
|
||||
.placeholder-hints { display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
||||
.hint-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #64748b; }
|
||||
.hint-dot { width: 6px; height: 6px; border-radius: 50%; background: #cbd5e1; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@
|
||||
<div v-show="activeTab === 'history'" class="panel">
|
||||
<div v-if="historyList.length" class="panel-list">
|
||||
<div
|
||||
v-for="item in visibleHistory"
|
||||
v-for="item in historyList"
|
||||
:key="item.id"
|
||||
class="history-item"
|
||||
:class="{ active: activeHistoryId === item.id }"
|
||||
@@ -44,10 +44,7 @@
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="historyShowCount < historyList.length" class="load-more-btn" @click="historyShowCount += HISTORY_PAGE">
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
展开更多 ({{ historyList.length - historyShowCount }} 条)
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div v-else class="panel-empty">暂无对话记录</div>
|
||||
</div>
|
||||
@@ -71,6 +68,7 @@
|
||||
<div v-if="fileNode.fileUrl" class="file-actions">
|
||||
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
|
||||
<button class="file-action-btn" @click.stop="handleDownloadNode(fileNode)">下载</button>
|
||||
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,14 +98,14 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
activeHistoryId: number;
|
||||
activeHistoryId: string | null;
|
||||
historyList: HistoryItem[];
|
||||
treeNodes: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
@@ -116,23 +114,22 @@ interface Props {
|
||||
interface Emits {
|
||||
(e: 'menu-change', key: string): void;
|
||||
(e: 'new-chat'): void;
|
||||
(e: 'select-history', id: number): void;
|
||||
(e: 'delete-history', id: number): void;
|
||||
(e: 'select-history', id: string): void;
|
||||
(e: 'delete-history', id: string): void;
|
||||
(e: 'preview-node', data: TreeNode): void;
|
||||
(e: 'download-node', data: TreeNode): void;
|
||||
(e: 'delete-node', data: TreeNode): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const HISTORY_PAGE = 10;
|
||||
const WORKSPACE_PAGE = 5;
|
||||
|
||||
const activeTab = ref<'history' | 'workspace'>('history');
|
||||
const historyShowCount = ref(HISTORY_PAGE);
|
||||
const workspaceShowCount = ref(WORKSPACE_PAGE);
|
||||
|
||||
const visibleHistory = computed(() => props.historyList.slice(0, historyShowCount.value));
|
||||
const visibleHistory = computed(() => props.historyList);
|
||||
const visibleWorkspace = computed(() => props.treeNodes.slice(0, workspaceShowCount.value));
|
||||
|
||||
const getFileIcon = (fileType?: string) => {
|
||||
@@ -145,10 +142,11 @@ const getFileIcon = (fileType?: string) => {
|
||||
};
|
||||
|
||||
const handleNewChat = () => emit('new-chat');
|
||||
const handleSelectHistory = (id: number) => emit('select-history', id);
|
||||
const handleDeleteHistory = (id: number) => emit('delete-history', id);
|
||||
const handleSelectHistory = (id: string) => emit('select-history', id);
|
||||
const handleDeleteHistory = (id: string) => emit('delete-history', id);
|
||||
const handlePreviewNode = (data: TreeNode) => emit('preview-node', data);
|
||||
const handleDownloadNode = (data: TreeNode) => emit('download-node', data);
|
||||
const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -484,6 +482,14 @@ const handleDownloadNode = (data: TreeNode) => emit('download-node', data);
|
||||
}
|
||||
|
||||
.file-action-btn {
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
|
||||
+200
-31
@@ -15,8 +15,8 @@
|
||||
@delete-node="handleDeleteNode"
|
||||
/>
|
||||
<div class="main-wrapper">
|
||||
<MainContent :active-menu="activeMenu" />
|
||||
<InputBar @send="handleSend" />
|
||||
<MainContent ref="mainContentRef" :active-menu="activeMenu" :workflow-detail="selectedWorkflowDetail" :active-history-id="activeHistoryId" />
|
||||
<InputBar @send="handleSend" @workflow-select="handleWorkflowSelect" />
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
@@ -39,15 +39,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import MainContent from './components/MainContent.vue';
|
||||
import InputBar from './components/InputBar.vue';
|
||||
import type { ExecutionTreeItem } from '/@/api/settings/creation';
|
||||
import { getExecutionList } from '/@/api/settings/creation';
|
||||
import { getExecutionList, getWorkflowDetail, deleteExecutionResult, deleteExecutionSession, getSessionList, downloadToFile, executeFlow } from '/@/api/settings/creation';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
time: string;
|
||||
}
|
||||
@@ -64,7 +65,7 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
const activeMenu = ref('chat');
|
||||
const activeHistoryId = ref(1);
|
||||
const activeHistoryId = ref<string | null>(null);
|
||||
const treeLoading = ref(false);
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const imgAddressPrefix = ref('');
|
||||
@@ -72,12 +73,7 @@ const previewDialogVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
const previewMode = ref<'iframe' | 'image' | 'video' | 'audio'>('iframe');
|
||||
|
||||
const historyList = ref<HistoryItem[]>([
|
||||
{ id: 1, title: '首页风格优化方案', time: '刚刚' },
|
||||
{ id: 2, title: '模型配置逻辑检查', time: '今天 11:20' },
|
||||
{ id: 3, title: '技能管理模块梳理', time: '昨天' },
|
||||
{ id: 4, title: '快捷回复产品设计', time: '2 天前' },
|
||||
]);
|
||||
const historyList = ref<HistoryItem[]>([]);
|
||||
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||
|
||||
@@ -93,6 +89,7 @@ const buildAssetUrl = (p?: string) =>
|
||||
? joinUrl(joinUrl(apiBaseUrl, imgAddressPrefix.value), p)
|
||||
: joinUrl(apiBaseUrl, p);
|
||||
|
||||
|
||||
const buildTreeNodes = (tree: ExecutionTreeItem[]): TreeNode[] =>
|
||||
tree.map((d, di) => ({
|
||||
id: 'date-' + di,
|
||||
@@ -132,19 +129,53 @@ const previewNode = (data: TreeNode) => {
|
||||
previewDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const downloadNode = (data: TreeNode) => {
|
||||
const downloadNode = async (data: TreeNode) => {
|
||||
if (!data.fileUrl) return;
|
||||
const url = buildAssetUrl(data.fileUrl);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = data.label;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
try {
|
||||
// 优先通过后端代理下载,避免跨域问题
|
||||
const res = await downloadToFile({ fileURL: data.fileUrl });
|
||||
const blob = new Blob([res]);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = data.label;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
// 兜底:直接用 a 标签下载
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = data.label;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNode = (data: TreeNode) => {
|
||||
const removeNode = (nodes: TreeNode[], targetId: string): boolean => {
|
||||
const handleDeleteNode = async (data: TreeNode) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${data.label}」?此操作不可恢复。`, '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// 调后端接口删除
|
||||
if (data.fileUrl && data.workflowId) {
|
||||
try {
|
||||
await deleteExecutionResult({ id: data.workflowId });
|
||||
} catch {
|
||||
// 后端已提示错误,继续本地删除
|
||||
}
|
||||
}
|
||||
const removeNode = (nodes: TreeNode[], targetId: string): boolean => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].id === targetId) {
|
||||
nodes.splice(i, 1);
|
||||
@@ -160,36 +191,156 @@ const handleDeleteNode = (data: TreeNode) => {
|
||||
ElMessage.success('已删除');
|
||||
};
|
||||
|
||||
const getSessionData = (res: any): any[] => {
|
||||
if (!res?.data) return [];
|
||||
if (Array.isArray(res.data)) return res.data;
|
||||
if (Array.isArray((res.data as any)?.list)) return (res.data as any).list;
|
||||
return [];
|
||||
};
|
||||
|
||||
const getList = async () => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const res = await getExecutionList();
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.tree || []);
|
||||
const [execRes, sessionRes] = await Promise.all([
|
||||
getExecutionList(),
|
||||
getSessionList(),
|
||||
]);
|
||||
imgAddressPrefix.value = execRes.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(execRes.data?.tree || []);
|
||||
const d = new Date(); const todayStr = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
|
||||
historyList.value = getSessionData(sessionRes).map((s) => {
|
||||
const rawTime = s.createdAt || s.createDate || '';
|
||||
let displayTime = rawTime;
|
||||
if (rawTime) {
|
||||
const datePart = rawTime.substring(0, 10);
|
||||
if (datePart === todayStr) {
|
||||
displayTime = rawTime.substring(11, 16);
|
||||
} else {
|
||||
displayTime = datePart;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.flowName || '未命名会话',
|
||||
time: displayTime,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
treeNodes.value = [];
|
||||
historyList.value = [];
|
||||
imgAddressPrefix.value = '';
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedWorkflowDetail = ref<any>(null);
|
||||
const mainContentRef = ref<any>(null);
|
||||
|
||||
const getSessionId = () => {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
const handleWorkflowSelect = async (workflowId: string | null) => {
|
||||
if (workflowId === null) {
|
||||
selectedWorkflowDetail.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getWorkflowDetail(workflowId);
|
||||
selectedWorkflowDetail.value = res.data || null;
|
||||
} catch {
|
||||
selectedWorkflowDetail.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuChange = (menu: string) => {
|
||||
activeMenu.value = menu;
|
||||
};
|
||||
|
||||
const handleSend = (_message: string) => {
|
||||
// 预留发送逻辑
|
||||
};
|
||||
const handleSend = async (message: string) => {
|
||||
if (!selectedWorkflowDetail.value) return;
|
||||
const mc = mainContentRef.value;
|
||||
if (!mc) return;
|
||||
|
||||
const handleSelectHistory = (id: number) => {
|
||||
if (!mc.validateFormFields()) return;
|
||||
|
||||
try {
|
||||
// 1. 构建节点输入参数
|
||||
const nodeInputParams = selectedWorkflowDetail.value.nodeInputParams?.map((node: any) => {
|
||||
const nodeParam: any = { ...node };
|
||||
|
||||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||||
nodeParam.formConfig = node.formConfig.map((field: any) => {
|
||||
// HTTP body 处理
|
||||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||||
const bodyValue = { ...field.value };
|
||||
Object.entries(bodyValue).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||||
const userVal = mc.formValues[bodyFieldKey];
|
||||
bodyValue[bodyKey] = {
|
||||
...bodyItem,
|
||||
value: userVal !== undefined ? userVal : bodyItem.value,
|
||||
};
|
||||
});
|
||||
return { ...field, value: bodyValue };
|
||||
}
|
||||
|
||||
const fieldKey = `${node.id}_${field.field || field.label}`;
|
||||
return {
|
||||
...field,
|
||||
value: mc.formValues[fieldKey] !== undefined ? mc.formValues[fieldKey] : field.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return nodeParam;
|
||||
}) || [];
|
||||
|
||||
// 2. 构建 flowContent
|
||||
const updatedFlowContent = {
|
||||
...selectedWorkflowDetail.value.flowContent,
|
||||
nodes: nodeInputParams,
|
||||
};
|
||||
|
||||
// 3. 收集文件
|
||||
const fileUrls: string[] = [];
|
||||
Object.values(mc.fieldFiles).forEach((files: any) => {
|
||||
files.forEach((f: any) => {
|
||||
if (f.url && !fileUrls.includes(f.url)) fileUrls.push(f.url);
|
||||
});
|
||||
});
|
||||
|
||||
// 4. 构建请求参数
|
||||
const params = {
|
||||
flowId: selectedWorkflowDetail.value.id,
|
||||
flowContent: updatedFlowContent,
|
||||
nodeInputParams: nodeInputParams,
|
||||
sessionId: getSessionId(),
|
||||
desc: message,
|
||||
flowName: selectedWorkflowDetail.value.flowName || '',
|
||||
fileUrl: fileUrls,
|
||||
resultUrl: selectedWorkflowDetail.value.resultUrl || '',
|
||||
templates: mc.templates || [],
|
||||
};
|
||||
|
||||
// 5. 执行
|
||||
await executeFlow(params);
|
||||
ElMessage.success('执行完成');
|
||||
} catch {
|
||||
// 错误由全局拦截器处理
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHistory = (id: string) => {
|
||||
activeHistoryId.value = id;
|
||||
activeMenu.value = 'chat';
|
||||
ElMessage.success('已切换会话');
|
||||
};
|
||||
|
||||
const handleCreateHistory = () => {
|
||||
const id = Date.now();
|
||||
const id = 'chat-' + Date.now();
|
||||
historyList.value.unshift({
|
||||
id,
|
||||
title: '新会话 ' + (historyList.value.length + 1),
|
||||
@@ -200,11 +351,29 @@ const handleCreateHistory = () => {
|
||||
ElMessage.success('已新建会话');
|
||||
};
|
||||
|
||||
const handleDeleteHistory = (id: number) => {
|
||||
const handleDeleteHistory = async (id: string) => {
|
||||
// 会话 ID 以 'chat-' 开头的是本地新建的虚拟会话,不调后端
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该对话记录?此操作不可恢复。', '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isVirtual = id.startsWith('chat-');
|
||||
if (!isVirtual) {
|
||||
try {
|
||||
await deleteExecutionSession({ id });
|
||||
} catch {
|
||||
// 后端已提示错误,继续本地删除
|
||||
}
|
||||
}
|
||||
const idx = historyList.value.findIndex((item) => item.id === id);
|
||||
if (idx < 0) return;
|
||||
historyList.value.splice(idx, 1);
|
||||
if (activeHistoryId.value === id && historyList.value.length) {
|
||||
if (activeHistoryId.value === id && historyList.value.length > 0) {
|
||||
activeHistoryId.value = historyList.value[0].id;
|
||||
}
|
||||
ElMessage.success('已删除会话');
|
||||
|
||||
Reference in New Issue
Block a user