Files
PlantAI-rag-new/frontend/src/components/chat/MessageList.vue
T
jerryandClaude Fable 5 1e94de7464 PlantAI-rag-new 首次提交:文献知识工作台
- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:47:52 +08:00

124 lines
3.3 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.
<script setup>
// chat/MessageList.vue —— 消息流渲染(FR-CHAT-003/014)
// 自动滚动:新消息/流式更新滚到底;用户手动向上滚动后暂停,
// 显示「回到最新」按钮(FR-CHAT-014);点击或滚回底部恢复。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { T } from '../../i18n/labels.js';
import UserMessage from './UserMessage.vue';
import AssistantMessage from './AssistantMessage.vue';
const props = defineProps({
messages: { type: Array, required: true },
busy: { type: Boolean, default: false },
reveal: { type: Object, default: null },
scrollSeq: { type: Number, default: 0 }, // chat store 的滚动请求序号
});
const emit = defineEmits(['retry', 'jump-graph', 'jump-source']);
const el = ref(null);
const stickToBottom = ref(true);
const hasMessages = computed(() => props.messages.length > 0);
const onScroll = () => {
const node = el.value;
if (!node) return;
const dist = node.scrollHeight - node.scrollTop - node.clientHeight;
const nearBottom = dist < 60;
if (nearBottom) stickToBottom.value = true;
else stickToBottom.value = false;
};
const scrollToBottom = (smooth = false) => {
const node = el.value;
if (!node) return;
node.scrollTo({ top: node.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
};
const backToLatest = () => {
stickToBottom.value = true;
scrollToBottom(true);
};
watch(
() => [props.messages.length, props.scrollSeq, props.reveal],
() => {
if (stickToBottom.value) scrollToBottom();
},
{ flush: 'post' },
);
onMounted(() => scrollToBottom());
onBeforeUnmount(() => {});
</script>
<template>
<div ref="el" class="chat-scroll" @scroll="onScroll">
<div class="chat-inner">
<template v-for="(m, mi) in messages" :key="m.id ?? mi">
<UserMessage v-if="m.role === 'user'" :message="m" />
<AssistantMessage
v-else
:message="m"
:reveal="reveal"
@retry="emit('retry')"
@jump-graph="emit('jump-graph')"
@jump-source="(payload) => emit('jump-source', payload)"
/>
</template>
</div>
<transition name="back-fade">
<button v-if="hasMessages && !stickToBottom" class="back-latest" type="button" @click="backToLatest">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
{{ T.backToLatest }}
</button>
</transition>
</div>
</template>
<style scoped>
.chat-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
position: relative;
}
.chat-inner {
max-width: 760px;
margin: 0 auto;
padding: 28px 28px 12px;
}
.back-latest {
position: sticky;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--pa-r-pill);
background: var(--pa-raised);
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
z-index: 4;
}
.back-latest:hover {
color: var(--pa-ink);
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
}
.back-fade-enter-active,
.back-fade-leave-active {
transition: opacity 0.2s var(--pa-ease);
}
.back-fade-enter-from,
.back-fade-leave-to {
opacity: 0;
}
</style>