- 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>
124 lines
3.3 KiB
Vue
124 lines
3.3 KiB
Vue
<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>
|