feat: 主页对话功能完善 — 会话列表、工作流执行、样式优化

- 新增会话列表 API(getSessionList)及类型定义
- 修复对话记录为空:兼容 API 返回 data.list 结构
- 删除接口改为 DELETE 方法,参数统一使用 id
- 实现工作流执行逻辑,与内容创作对齐(含表单参数、文件上传、flowContent)
- 动态表单样式优化:三段式布局防溢出、上传区支持拖拽、节点分组显示
- 对话记录时间格式化:当天显示 HH:mm,非当天显示日期
- 对话记录默认展示全部,移除分页展开
- 发送按钮支持选中工作流时空文本可点击
- 默认占位展示引导提示,替代空状态
- 添加对话记录删除确认弹窗
- 下载改用后端代理,避免跨域问题
This commit is contained in:
2026-07-07 11:31:40 +08:00
parent 6be970dcaf
commit 6a588015d0
6 changed files with 843 additions and 112 deletions
+42
View File
@@ -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;
}
}
+18 -13
View File
@@ -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(() => {
+558 -50
View File
@@ -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>
+20 -14
View File
@@ -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
View File
@@ -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('已删除会话');