会话机制:
- 建连不传本地会话号,由后端生成 UUID 并经首帧 ack 认领(claimSessionId 就地迁移全部状态)
- 删除执行完成后轮询会话列表替换本地条目的双路径逻辑,统一认领后刷新
- 清理 virtual_ 特判,收敛为 hasBackendSessionId 单一语义判断
产出展示:
- flow_complete 事件自带 resultFileUrls 直接构建产出卡片(buildOutputsFromUrls)
- 完成文案改为「✅ 执行完成」,产出以消息形式展示在对话里
- 产出媒体(图片/视频/音频)常驻内嵌预览,图片点击放大,text/file 保持文件行
- ChatList 自动续载增加次数上限,防止 hasMore 恒真时无限加载循环
264 lines
6.5 KiB
Vue
264 lines
6.5 KiB
Vue
<template>
|
||
<div class="workflow-output-card">
|
||
<template v-for="(out, i) in outputs" :key="i">
|
||
<!-- 图片:常驻内嵌缩略图,点击放大看大图(走父级预览弹窗) -->
|
||
<div v-if="out.type === 'image'" class="output-media is-image" @click="emit('preview', out)">
|
||
<img :src="resolveUrl ? resolveUrl(out.url) : out.url" :alt="out.name" loading="lazy" />
|
||
<span class="media-name">{{ out.name }}</span>
|
||
<span class="media-actions" @click.stop>
|
||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||
<el-icon><Download /></el-icon>
|
||
</button>
|
||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<!-- 视频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||
<div v-else-if="out.type === 'video'" class="output-media is-video">
|
||
<video :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></video>
|
||
<span class="media-actions" @click.stop>
|
||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||
<el-icon><Download /></el-icon>
|
||
</button>
|
||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<!-- 音频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||
<div v-else-if="out.type === 'audio'" class="output-media is-audio">
|
||
<audio :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></audio>
|
||
<span class="media-actions" @click.stop>
|
||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||
<el-icon><Download /></el-icon>
|
||
</button>
|
||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<!-- 文本 / 其他类型:文件行 + 预览/下载/删除 -->
|
||
<div v-else class="output-item">
|
||
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
|
||
<component :is="getIcon(out.type)" />
|
||
</el-icon>
|
||
<span class="output-name" :title="out.name">{{ out.name }}</span>
|
||
<span class="output-actions">
|
||
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
|
||
<el-icon><View /></el-icon>
|
||
</button>
|
||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||
<el-icon><Download /></el-icon>
|
||
</button>
|
||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
|
||
|
||
// 产出文件:url 文件地址(相对/绝对)、name 展示名、type 预览类型(image/video/audio/text/file)、backendId 执行记录 id
|
||
interface WorkflowOutput {
|
||
url: string;
|
||
name: string;
|
||
type: string;
|
||
backendId: string;
|
||
}
|
||
|
||
interface Props {
|
||
outputs: WorkflowOutput[];
|
||
// 相对路径 → 完整 URL 解析函数(由父级传入 buildAssetUrl),缺省时直接用原 url(绝对地址场景)
|
||
resolveUrl?: (url: string) => string;
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'preview', output: WorkflowOutput): void;
|
||
(e: 'download', output: WorkflowOutput): void;
|
||
(e: 'delete', output: WorkflowOutput): void;
|
||
}
|
||
|
||
defineProps<Props>();
|
||
const emit = defineEmits<Emits>();
|
||
|
||
// 按文件类型选图标(与工作空间侧边栏一致)
|
||
const getIcon = (type: string) => {
|
||
const t = String(type || '').toLowerCase();
|
||
if (t === 'image') return Picture;
|
||
if (t === 'video') return VideoPlay;
|
||
if (t === 'audio') return Headset;
|
||
return Document;
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.workflow-output-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
// 媒体产出(图片/视频/音频):常驻内嵌展示,hover 浮出下载/删除操作
|
||
.output-media {
|
||
position: relative;
|
||
overflow: hidden;
|
||
border-radius: 10px;
|
||
background: #0f172a;
|
||
|
||
img,
|
||
video {
|
||
display: block;
|
||
width: 100%;
|
||
max-height: 320px;
|
||
object-fit: contain;
|
||
background: #0f172a;
|
||
}
|
||
|
||
audio {
|
||
display: block;
|
||
width: 100%;
|
||
background: #0f172a;
|
||
padding: 10px;
|
||
}
|
||
|
||
.media-name {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 4px 8px;
|
||
font-size: 12px;
|
||
color: #e2e8f0;
|
||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.media-actions {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
display: flex;
|
||
gap: 2px;
|
||
padding: 2px;
|
||
border-radius: 8px;
|
||
background: rgba(15, 23, 42, 0.75);
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
&:hover {
|
||
.media-actions {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 非媒体产出:文件行 + 操作按钮
|
||
.output-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
background: #f8fafc;
|
||
border: 1px solid #f1f5f9;
|
||
border-radius: 10px;
|
||
transition: background 0.15s;
|
||
|
||
&:hover {
|
||
background: #eef2f7;
|
||
.output-actions {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
.output-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
|
||
&.is-image {
|
||
color: #2563eb;
|
||
}
|
||
&.is-video {
|
||
color: #7c3aed;
|
||
}
|
||
&.is-audio {
|
||
color: #d97706;
|
||
}
|
||
&.is-file,
|
||
&.is-text {
|
||
color: #64748b;
|
||
}
|
||
}
|
||
|
||
.output-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #334155;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.output-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.output-action {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
|
||
&:hover {
|
||
background: #e2e8f0;
|
||
color: #475569;
|
||
}
|
||
|
||
&.is-danger:hover {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.el-icon {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
// 媒体产出内的操作按钮:深色背景上使用浅色图标
|
||
.output-media {
|
||
.output-action {
|
||
color: #cbd5e1;
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.18);
|
||
color: #f8fafc;
|
||
}
|
||
|
||
&.is-danger:hover {
|
||
background: rgba(220, 38, 38, 0.35);
|
||
color: #fecaca;
|
||
}
|
||
}
|
||
}
|
||
</style>
|