ai-chat-ui/src/components/chat/MessageList.vue

547 lines
12 KiB
Vue

<template>
<div class="message-list-container">
<!-- 消息选择操作栏 -->
<Transition name="slide-down">
<div v-if="isMessageSelectMode" class="message-select-bar">
<div class="select-info">
<span class="select-count">已选择 {{ selectedMessageCount }} 条消息</span>
</div>
<div class="select-actions">
<button class="action-btn select-all" @click="handleSelectAll">
全选
</button>
<button class="action-btn cancel" @click="handleCancelSelect">
取消
</button>
<button class="action-btn confirm" :disabled="selectedMessageCount === 0" @click="handleConfirmShare">
确认分享
</button>
</div>
</div>
</Transition>
<div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 -->
<WelcomeScreen v-if="visibleMessages.length === 0" @select="$emit('select-suggestion', $event)" />
<!-- 消息列表 -->
<template v-else>
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble v-for="(message, index) in visibleMessages" :key="message.id" :message="message"
:show-timestamp="showTimestamp" :compact="compact" :is-New="index === visibleMessages.length - 1"
:is-message-select-mode="isMessageSelectMode" :is-selected="isMessageSelected(message.id)"
@retry="$emit('retry', message.id)" @regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)" @like="handleLike(message)" @dislike="handleDislike(message)"
@select-suggestion="$emit('select-suggestion', $event)" @preview-image="handlePreviewImage"
@play-video="handlePlayVideo" @download-file="handleDownloadFile"
@toggle-select="handleToggleMessageSelect(message.id)"
@enter-select-mode="handleEnterSelectMode(message.id)" />
</TransitionGroup>
<!-- 正在输入指示器 -->
<div v-if="isTyping" class="typing-indicator">
<div class="typing-avatar">
<Bot :size="20" />
</div>
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="typing-text">AI 正在思考...</span>
</div>
</div>
</template>
</div>
<!-- 回到底部按钮 -->
<Transition name="fade">
<button v-if="showScrollButton" class="scroll-bottom-btn" @click="handleScrollToBottom">
<ChevronDown :size="20" />
<span v-if="newMessageCount > 0" class="new-count">
{{ newMessageCount }}
</span>
</button>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, computed } from "vue";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo, Suggestion } from "@/types/chat";
import { MessageRole } from "@/types/chat";
const props = withDefaults(
defineProps<{
messages: Message[];
showTimestamp?: boolean;
compact?: boolean;
isTyping?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
isTyping: false,
},
);
// 过滤掉系统消息,不显示在前端
const visibleMessages = computed(() => {
return props.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
);
});
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
"select-suggestion": [suggestion: Suggestion];
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
// 消息选择模式状态
const isMessageSelectMode = computed(() => chatStore.isMessageSelectMode);
const selectedMessageCount = computed(() => chatStore.selectedMessageCount);
// 响应式状态
const containerRef = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
const lastScrollTop = ref(0);
// 高度通过 CSS flex 布局自适应,不需要手动设置
// 滚动处理
function handleScroll() {
const container = containerRef.value;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
if (scrollTop < lastScrollTop.value && !isAtBottom) {
isAutoScrolling.value = false;
showScrollButton.value = true;
}
if (isAtBottom) {
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
lastScrollTop.value = scrollTop;
}
// 滚动到底部
function scrollToBottom(smooth = true) {
const container = containerRef.value;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
// 按钮点击处理
function handleScrollToBottom() {
scrollToBottom(true);
}
// 消息操作
function handleCopy(message: Message) {
chatStore.setMessageCopied(message.id);
}
function handleLike(message: Message) {
const currentLiked = message.feedback?.liked;
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
}
function handleDislike(message: Message) {
const currentDisliked = message.feedback?.disliked;
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
}
function handlePreviewImage(image: Attachment, index: number) {
emit("preview-image", image, index);
}
function handlePlayVideo(video: VideoInfo) {
emit("play-video", video);
}
function handleDownloadFile(file: Attachment) {
emit("download-file", file);
}
// 消息选择模式方法
function handleEnterSelectMode(messageId: string) {
chatStore.enterMessageSelectMode(chatStore.currentConversationId || '', messageId);
}
function handleToggleMessageSelect(messageId: string) {
chatStore.toggleMessageSelection(messageId);
}
function handleSelectAll() {
chatStore.selectAllMessages();
}
function handleCancelSelect() {
chatStore.exitMessageSelectMode();
}
function handleConfirmShare() {
if (chatStore.selectedMessageCount > 0) {
settingsStore.openShareModal();
}
}
function isMessageSelected(messageId: string): boolean {
return chatStore.isMessageSelected(messageId);
}
// 监听消息变化
watch(
() => visibleMessages.value.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(false);
});
} else {
newMessageCount.value++;
}
}
},
);
// 监听最后一条消息的内容变化
watch(
() => visibleMessages.value[visibleMessages.value.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
const container = containerRef.value;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
},
);
// 监听 isTyping 变化
watch(
() => props.isTyping,
(typing) => {
if (typing && isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(true);
});
}
},
);
// 暴露方法
defineExpose({
scrollToBottom,
});
onMounted(() => {
// 只有当有消息时才滚动到底部,否则保持在顶部显示欢迎界面
if (visibleMessages.value.length > 0) {
scrollToBottom(false);
}
});
</script>
<style lang="scss" scoped>
.message-list-container {
flex: 1;
position: relative;
min-height: 0;
}
.message-list {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.messages-wrapper {
display: flex;
flex-direction: column;
padding: 20px 0;
min-height: 100%;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
animation: fadeIn 0.3s ease;
}
.typing-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border-radius: 12px;
color: white;
}
.typing-dots {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: #f3f4f6;
border-radius: 16px;
.dark & {
background: #2d2d3d;
}
span {
width: 8px;
height: 8px;
background: #9ca3af;
border-radius: 50%;
animation: typingBounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typing-text {
font-size: 14px;
color: #9ca3af;
}
.scroll-bottom-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: white;
color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
&:hover {
transform: translateX(-50%) scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.new-count {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #ef4444;
border-radius: 10px;
color: white;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
}
// 消息过渡动画
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(20px);
}
.message-leave-to {
opacity: 0;
transform: translateX(-20px);
}
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes typingBounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
// 消息选择操作栏
.message-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: white;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.select-info {
display: flex;
align-items: center;
gap: 12px;
}
.select-count {
font-size: 14px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.select-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.select-all,
&.cancel {
background: #f3f4f6;
color: #6b7280;
.dark & {
background: #2d2d3d;
color: #9ca3af;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
}
&.confirm {
background: #3b82f6;
color: white;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// 滑入动画
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style>