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>
|