Files
admin-ui/src/views/home/components/ChatList.vue
T
2910410219andClaude cb6792cb29 首页工作流执行:表单默认收起可展开、节点执行过程可视化(仿豆包)、失败支持重新执行/重新编辑、错误信息去重
- 提交后表单卡片默认收起(仅显示工作流标题),头部加明确展开/收起按钮
- node_start/node_complete 事件驱动节点步骤流,执行过程区仿豆包竖向展示
- 失败卡片拆分「重新执行」(复用已填值直接重跑)与「重新编辑」两按钮
- 失败消息只显示「 执行失败」,错误详情由卡片 footer 展示,避免双处重复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 17:07:40 +08:00

795 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
<!-- 顶部有更多历史时上滑加载更早记录 -->
<div v-if="hasMore || loadingMore" class="load-more-bar">
<span v-if="loadingMore" class="load-more-spinner"></span>
{{ loadingMore ? '加载中…' : '继续上滑加载更早的记录' }}
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="message-row"
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
>
<div class="bubble-wrap">
<!-- 工作流表单卡片不套气泡独立卡片渲染 -->
<WorkflowFormCard
v-if="msg.type === 'form'"
:detail="msg.form"
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
@retry="emit('form-retry', msg, $event)"
@re-edit="emit('form-re-edit', msg)"
@cancel="emit('form-cancel', msg)"
/>
<template v-else>
<div class="bubble">
<template v-if="msg.isUser">{{ msg.content }}</template>
<template v-else>
<!-- 初始加载动画 -->
<template v-if="msg.loading && !msg.thinking && !msg.content">
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
</template>
<!-- 思考区DeepSeek 风格折叠块 -->
<div v-if="msg.thinking" class="thinking-block">
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
<span class="thinking-icon">🧠</span>
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }"></span>
</button>
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
<div class="thinking-text">{{ msg.thinking }}</div>
</div>
</div>
<!-- 回答区Markdown 渲染 -->
<div
v-if="msg.content"
class="answer-block"
v-html="renderAnswer(msg.content)"
@click="handleAnswerClick"
></div>
<!-- 失败重试工作流结果不提供文本重试需重新提交表单卡片 -->
<button v-if="isErrorMsg(msg) && msg.recordType !== 'workflow'" type="button" class="retry-btn" @click="emit('retry', msg)">
<span class="retry-icon"></span> 重新生成
</button>
</template>
</div>
<!-- 工作流执行产出文件卡片挂在结果消息上预览/下载/删除 -->
<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)"
/>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button v-if="msg.recordType !== 'workflow'" class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<el-icon><RefreshRight /></el-icon>
</button>
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
<el-icon><Delete /></el-icon>
</button>
</div>
<!-- 用户消息操作栏hover 显示重新生成 + 删除 -->
<div v-if="msg.isUser" class="msg-actions">
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<el-icon><RefreshRight /></el-icon>
</button>
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
<el-icon><Delete /></el-icon>
</button>
</div>
</template>
<div class="time">{{ msg.time }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
import type { WorkflowOutput } from '../utils/flowDsl';
import type { WorkflowNodeProgress } from '../utils/wsMessage';
import WorkflowFormCard from './WorkflowFormCard.vue';
import WorkflowOutputCard from './WorkflowOutputCard.vue';
interface ChatMessage {
id: string;
content: string;
time: string;
isUser: boolean;
loading?: boolean;
thinking?: string;
thinkingSeconds?: number;
retryQuestion?: string;
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
recordId?: string;
recordType?: string;
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard
type?: 'form';
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(node_start/node_complete 事件驱动的节点步骤列表,渲染执行过程区)
formProgress?: WorkflowNodeProgress;
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
interface Emits {
(e: 'retry', msg: ChatMessage): void;
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'form-retry', msg: ChatMessage, payload: any): void;
(e: 'form-re-edit', msg: ChatMessage): void;
(e: 'form-cancel', msg: ChatMessage): void;
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'load-more'): void;
}
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);
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
const thinkingExpanded = reactive<Record<string, boolean>>({});
const isThinkingExpanded = (msg: ChatMessage): boolean =>
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
const toggleThinking = (msg: ChatMessage) => {
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
};
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
const mdCache = new Map<string, string>();
const renderAnswer = (content: string): string => {
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
return mdCache.get(content)!;
};
// 复制文本(剪贴板 API + 兼容回退)
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
ElMessage.success('已复制');
};
// 代码块复制(事件委托:点击渲染出的 .code-copy-btn
const handleAnswerClick = (e: MouseEvent) => {
const el = e.target as HTMLElement;
const btn = el.closest('.code-copy-btn');
if (!btn) return;
const code = btn.closest('.code-wrapper')?.querySelector('code');
if (code) copyText(code.textContent || '');
};
// 思考区折叠标题:完成(有答案)显示"已深度思考(用时 X 秒)",流式中显示"思考中…"
const thinkingTitle = (msg: ChatMessage): string => {
if (!msg.content) return '思考中…';
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
};
// 距底部小于该阈值(px)视为"正在查看最新":新消息到达自动滚底;用户上滑阅读历史(超过阈值)则不强制拉回
const NEAR_BOTTOM_THRESHOLD = 120;
// 是否处于"查看最新"状态:初始为真(挂载即定位最新),由用户真实滚动(isTrusted)更新
let isAtBottom = true;
// 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 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
// 首屏不满视口时自动续载更早记录,直到填满视口或没有更多(保证"上滑加载"入口始终可见)
watch(
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
() => {
if (props.loadingMore) return;
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
// 仅当用户贴近底部(isAtBottom)时才滚底;上滑阅读历史期间新消息不打断阅读位置
if (isAtBottom) el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
if (canAutoLoad()) {
autoLoading = true;
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环;用户可主动上滑继续加载更早记录
autoLoading = false;
}
}
});
},
{ immediate: true }
);
// 顶部加载更多:开始加载时记录容器高度,完成后把滚动位置补回新增高度,保持视口内消息不跳动。
// 该 watch 注册在 messages watch 之后,其 nextTick 回调后执行,可覆盖同批的"滚到底"。
const prevScrollHeight = ref(0);
watch(
() => props.loadingMore,
(loading, prev) => {
if (loading && !prev) {
const el = chatListRef.value;
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
} else if (!loading && prev) {
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
if (prevScrollHeight.value) {
// 用户主动上滑加载更早:滚动位置补回新增高度,保持视口内消息不跳动
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
prevScrollHeight.value = 0;
} else if (autoLoading) {
// 自动续载:加载期间滚底 watch 被 loadingMore 拦截(同批 loadingMore 仍为 true 先 flush),
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
if (canAutoLoad()) {
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环
autoLoading = false;
}
} else {
autoLoading = false;
}
}
});
}
}
);
// 滚动到顶部时触发加载更早记录(防重入由父组件 sessionLoadingMore 保证)
const handleScroll = (e: Event) => {
// 程序设置 scrollTop 触发的 scroll 事件(isTrusted=false)不参与交互判断:
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
if (!e.isTrusted) return;
const el = chatListRef.value;
if (!el) return;
// 感知用户阅读位置:贴近底部视为"正在查看最新"(新消息自动滚底),上滑阅读历史则不再强制拉回
isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
if (!props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
autoLoading = false;
autoLoadCount = 0;
emit('load-more');
}
};
onMounted(() => {
const el = chatListRef.value;
// 挂载即定位到最新消息。immediate watch 的 nextTick 在 Transition 期间 DOM 未插入时执行(el 为 null)会失效,
// 这里在 DOM 已插入、内容已渲染(sh 已确定)后滚底;requestAnimationFrame 确保过渡与布局完成
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight;
});
});
</script>
<style scoped lang="scss">
.chat-list {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 8px 0 16px;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 顶部:加载更早记录提示条 */
.load-more-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
font-size: 11px;
color: #cbd5e1;
padding: 4px 0 10px;
}
.load-more-spinner {
width: 12px;
height: 12px;
border: 2px solid #e2e8f0;
border-top-color: #94a3b8;
border-radius: 50%;
animation: load-more-rotate 0.7s linear infinite;
}
@keyframes load-more-rotate {
to { transform: rotate(360deg); }
}
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 100%;
&.is-user {
justify-content: flex-end;
}
}
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
.message-row + .message-row {
margin-top: 16px;
}
.message-row:not(.is-user) + .message-row:not(.is-user) {
margin-top: 24px;
}
.bubble-wrap {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 100%;
align-items: flex-start;
.message-row.is-user & {
align-items: flex-end;
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
width: 100%;
}
}
.bubble {
font-size: 15px;
line-height: 1.7;
padding: 0;
border-radius: 0;
word-break: break-word;
color: #1f2328;
background: transparent;
border: none;
box-shadow: none;
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
.message-row:not(.is-user) & {
width: 100%;
max-width: 880px;
}
.message-row.is-user & {
width: fit-content;
max-width: 72%;
padding: 10px 16px;
border-radius: 18px 18px 6px 18px;
background: #e8ecff;
color: #1f2328;
box-shadow: none;
}
.message-row.is-error & {
color: #dc2626;
}
}
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
.message-row.is-form {
justify-content: flex-start;
.bubble-wrap {
align-items: flex-start;
width: 100%;
.time { padding-left: 2px; }
}
}
/* AI 消息操作栏(hover 显示) */
.msg-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
.message-row:hover & {
opacity: 1;
}
/* 用户消息在右侧,操作栏右对齐 */
.message-row.is-user & {
align-self: flex-end;
}
}
.msg-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #eef0f3;
color: #4b5563;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 14px;
}
}
/* 失败重试 */
.retry-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding: 7px 14px;
border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2;
color: #dc2626;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.retry-icon {
font-size: 14px;
line-height: 1;
}
}
/* 思考区:DeepSeek 风格折叠块 */
.thinking-block {
margin: 0 0 12px;
background: #f5f6f7;
border: 1px solid #eef0f3;
border-radius: 12px;
overflow: hidden;
max-width: 880px;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #8b8b98;
cursor: pointer;
text-align: left;
user-select: none;
transition: color 0.15s;
&:hover {
color: #6b7280;
}
}
.thinking-icon {
font-size: 14px;
line-height: 1;
}
.thinking-title {
font-size: 13px;
}
.thinking-arrow {
margin-left: auto;
display: inline-flex;
font-size: 11px;
line-height: 1;
opacity: 0.7;
transition: transform 0.2s;
&.collapsed {
transform: rotate(-90deg);
}
}
.thinking-content {
padding: 0 14px 14px;
animation: thinking-fade-in 0.2s ease;
}
.thinking-text {
font-size: 13px;
line-height: 1.7;
color: #9a9aab;
font-style: italic;
white-space: pre-wrap;
word-break: break-word;
border-top: 1px solid #eef0f3;
padding-top: 10px;
}
@keyframes thinking-fade-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
.answer-block {
white-space: normal;
line-height: 1.7;
font-size: 15px;
word-break: break-word;
max-width: 880px;
& > :first-child {
margin-top: 0;
}
& > :last-child {
margin-bottom: 0;
}
:deep(p) {
margin: 0 0 12px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
font-weight: 600;
line-height: 1.4;
margin: 20px 0 10px;
color: #1f2328;
}
:deep(h1) {
font-size: 20px;
border-bottom: 1px solid #eaecef;
padding-bottom: 8px;
}
:deep(h2) {
font-size: 18px;
}
:deep(h3) {
font-size: 16px;
}
:deep(h4),
:deep(h5),
:deep(h6) {
font-size: 15px;
}
:deep(ul),
:deep(ol) {
margin: 0 0 12px;
padding-left: 22px;
}
:deep(li) {
margin: 3px 0;
}
:deep(li > ul),
:deep(li > ol) {
margin-bottom: 0;
}
:deep(blockquote) {
margin: 0 0 12px;
padding: 6px 14px;
border-left: 4px solid #e0e0e0;
color: #6a737d;
background: #fafafa;
border-radius: 0 8px 8px 0;
}
:deep(code) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
background: #f2f3f5;
color: #c7254e;
}
:deep(.code-wrapper) {
position: relative;
margin: 0 0 14px;
&:hover .code-copy-btn {
opacity: 1;
}
}
:deep(.code-copy-btn) {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: #8b949e;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.16);
color: #c9d1d9;
}
}
:deep(pre) {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: #0d1117;
overflow-x: auto;
line-height: 1.6;
code {
display: block;
padding: 0;
background: transparent;
color: inherit;
font-size: 13px;
}
}
:deep(table) {
width: 100%;
margin: 0 0 14px;
border-collapse: collapse;
font-size: 14px;
th,
td {
border: 1px solid #e2e8f0;
padding: 6px 12px;
text-align: left;
}
th {
background: #f5f7fa;
font-weight: 600;
}
}
:deep(a) {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(hr) {
border: none;
border-top: 1px solid #eaecef;
margin: 16px 0;
}
:deep(img) {
max-width: 100%;
border-radius: 6px;
}
}
/* 执行中占位 */
.loading-text {
color: #94a3b8;
font-size: 13px;
}
.loading-dots {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
i {
width: 4px;
height: 4px;
border-radius: 50%;
background: #94a3b8;
animation: dot-blink 1.2s infinite ease-in-out both;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
}
@keyframes dot-blink {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
.time {
font-size: 11px;
color: #8f9aae;
padding: 4px 2px;
.message-row.is-user & {
text-align: right;
color: rgba(148, 163, 184, 0.9);
}
.message-row:not(.is-user) & {
padding-left: 2px;
}
}
</style>