首页工作流会话重构与产出内嵌:后端 UUID 认领 + 产出对话内直接预览

会话机制:
- 建连不传本地会话号,由后端生成 UUID 并经首帧 ack 认领(claimSessionId 就地迁移全部状态)
- 删除执行完成后轮询会话列表替换本地条目的双路径逻辑,统一认领后刷新
- 清理 virtual_ 特判,收敛为 hasBackendSessionId 单一语义判断

产出展示:
- flow_complete 事件自带 resultFileUrls 直接构建产出卡片(buildOutputsFromUrls)
- 完成文案改为「 执行完成」,产出以消息形式展示在对话里
- 产出媒体(图片/视频/音频)常驻内嵌预览,图片点击放大,text/file 保持文件行
- ChatList 自动续载增加次数上限,防止 hasMore 恒真时无限加载循环
This commit is contained in:
2026-08-17 17:59:24 +08:00
parent bb15c053e6
commit 13a2aeee9f
6 changed files with 349 additions and 110 deletions
+26 -3
View File
@@ -60,6 +60,7 @@
<WorkflowOutputCard
v-if="msg.outputs && msg.outputs.length"
:outputs="msg.outputs"
:resolve-url="props.resolveUrl"
@preview="emit('output-preview', msg, $event)"
@download="emit('output-download', msg, $event)"
@delete="emit('output-delete', msg, $event)"
@@ -139,11 +140,14 @@ interface Props {
messages: ChatMessage[];
hasMore?: boolean;
loadingMore?: boolean;
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
resolveUrl?: (url: string) => string;
}
const props = withDefaults(defineProps<Props>(), {
hasMore: false,
loadingMore: false,
resolveUrl: undefined,
});
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
@@ -201,6 +205,11 @@ const thinkingTitle = (msg: ChatMessage): string => {
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
let autoLoading = false;
// 自动续载累计次数上限:连续自动加载超过上限即停,防止异常数据(如 hasMore 恒 true 且视口不满)
// 导致 load-more 无限循环占满主线程。用户主动上滑加载不受此上限约束(见 handleScroll)。
const MAX_AUTO_LOAD = 5;
let autoLoadCount = 0;
const canAutoLoad = (): boolean => autoLoadCount < MAX_AUTO_LOAD;
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
@@ -214,8 +223,14 @@ watch(
if (!el) return;
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
autoLoading = true;
emit('load-more');
if (canAutoLoad()) {
autoLoading = true;
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环;用户可主动上滑继续加载更早记录
autoLoading = false;
}
}
});
},
@@ -244,7 +259,13 @@ watch(
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
emit('load-more');
if (canAutoLoad()) {
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环
autoLoading = false;
}
} else {
autoLoading = false;
}
@@ -262,7 +283,9 @@ const handleScroll = (e: Event) => {
const el = chatListRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
autoLoading = false;
autoLoadCount = 0;
emit('load-more');
}
};
+139 -24
View File
@@ -1,22 +1,62 @@
<template>
<div class="workflow-output-card">
<div v-for="(out, i) in outputs" :key="i" 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 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>
@@ -33,6 +73,8 @@ interface WorkflowOutput {
interface Props {
outputs: WorkflowOutput[];
// 相对路径 → 完整 URL 解析函数(由父级传入 buildAssetUrl),缺省时直接用原 url(绝对地址场景)
resolveUrl?: (url: string) => string;
}
interface Emits {
@@ -58,20 +100,76 @@ const getIcon = (type: string) => {
.workflow-output-card {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
margin-top: 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
padding: 8px;
}
// 媒体产出(图片/视频/音频):常驻内嵌展示,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: 6px 8px;
border-radius: 8px;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
transition: background 0.15s;
&:hover {
@@ -145,4 +243,21 @@ const getIcon = (type: string) => {
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>