Files
Yi.Framework/Yi.Ai.Vue3/src/layouts/components/ChatAside/index.vue
2026-01-02 22:47:09 +08:00

804 lines
20 KiB
Vue

<script setup lang="ts">
import type { ConversationItem } from 'vue-element-plus-x/types/Conversations';
import type { ChatSessionVo } from '@/api/session/types';
import { ChatLineSquare, Expand, Fold, MoreFilled, Plus } from '@element-plus/icons-vue';
import { useRoute, useRouter } from 'vue-router';
import { get_session } from '@/api';
import { useDesignStore } from '@/stores';
import { useSessionStore } from '@/stores/modules/session';
const route = useRoute();
const router = useRouter();
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const sessionId = computed(() => route.params?.id);
const conversationsList = computed(() => sessionStore.sessionList);
const loadMoreLoading = computed(() => sessionStore.isLoadingMore);
const active = ref<string | undefined>();
const isCollapsed = computed(() => designStore.isCollapseConversationList);
// 判断是否为新建对话状态(没有选中任何会话)
const isNewChatState = computed(() => !sessionStore.currentSession);
onMounted(async () => {
await sessionStore.requestSessionList();
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
}
});
watch(
() => sessionStore.currentSession,
(newValue) => {
active.value = newValue ? `${newValue.id}` : undefined;
},
);
// 创建会话
function handleCreatChat() {
sessionStore.createSessionBtn();
}
// 切换会话
function handleChange(item: ConversationItem<ChatSessionVo>) {
sessionStore.setCurrentSession(item);
router.replace({
name: 'chatConversationWithId',
params: {
id: item.id,
},
});
}
// 处理组件触发的加载更多事件
async function handleLoadMore() {
if (!sessionStore.hasMore)
return;
await sessionStore.loadMoreSessions();
}
// 右键菜单
function handleMenuCommand(command: string, item: ConversationItem<ChatSessionVo>) {
switch (command) {
case 'delete':
ElMessageBox.confirm('删除后,聊天记录将不可恢复。', '确定删除对话?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
autofocus: false,
})
.then(() => {
sessionStore.deleteSessions([item.id!]);
nextTick(() => {
if (item.id === active.value) {
sessionStore.createSessionBtn();
}
});
})
.catch(() => {
// 取消删除
});
break;
case 'rename':
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
confirmButtonClass: 'el-button--primary',
cancelButtonClass: 'el-button--info',
roundButton: true,
inputValue: item.sessionTitle,
autofocus: false,
inputValidator: (value) => {
return !!value;
},
}).then(({ value }) => {
sessionStore
.updateSession({
id: item.id!,
sessionTitle: value,
sessionContent: item.sessionContent,
})
.then(() => {
ElMessage({
type: 'success',
message: '修改成功',
});
nextTick(() => {
if (sessionStore.currentSession?.id === item.id) {
sessionStore.setCurrentSession({
...item,
sessionTitle: value,
});
}
});
});
});
break;
default:
break;
}
}
// 折叠/展开侧边栏
function toggleSidebar() {
designStore.setIsCollapseConversationList(!designStore.isCollapseConversationList);
}
</script>
<template>
<div
class="aside-container"
:class="{ 'aside-collapsed': isCollapsed }"
>
<div class="aside-wrapper">
<!-- 头部 -->
<div class="aside-header">
<!-- 展开状态显示logo和标题 -->
<div
v-if="!isCollapsed"
class="header-content-expanded flex items-center gap-8px"
:class="{ 'is-disabled': isNewChatState, 'hover:cursor-pointer': !isNewChatState }"
@click="!isNewChatState && handleCreatChat()"
>
<span class="logo-text max-w-150px text-overflow">会话</span>
</div>
<!-- 折叠状态只显示logo -->
<div
v-else
class="header-content-collapsed flex items-center justify-center hover:cursor-pointer"
>
<el-icon size="20">
<ChatLineSquare />
</el-icon>
</div>
<!-- 折叠按钮 -->
<el-tooltip
:content="isCollapsed ? '展开侧边栏' : '折叠侧边栏'"
placement="bottom"
>
<el-button
class="collapse-btn"
type="text"
@click="toggleSidebar"
>
<el-icon v-if="isCollapsed">
<Expand />
</el-icon>
<el-icon v-else>
<Fold />
</el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 内容区域 -->
<div class="aside-body">
<!-- 创建会话按钮 -->
<div class="creat-chat-btn-wrapper">
<div
class="creat-chat-btn"
:class="{
'creat-chat-btn-collapsed': isCollapsed,
'is-disabled': isNewChatState,
}"
@click="!isNewChatState && handleCreatChat()"
>
<el-icon class="add-icon">
<Plus />
</el-icon>
<span v-if="!isCollapsed" class="creat-chat-text">
新对话
</span>
</div>
</div>
<!-- 会话列表 -->
<div class="aside-content">
<div v-if="conversationsList.length > 0" class="conversations-wrap">
<Conversations
v-model:active="active"
:items="conversationsList"
:label-max-width="isCollapsed ? 0 : 140"
:show-tooltip="!isCollapsed"
:tooltip-offset="60"
show-built-in-menu
groupable
row-key="id"
label-key="sessionTitle"
:tooltip-placement="isCollapsed ? 'right-start' : 'right'"
:load-more="handleLoadMore"
:load-more-loading="loadMoreLoading"
:items-style="{
marginLeft: '8px',
marginRight: '8px',
userSelect: 'none',
borderRadius: isCollapsed ? '12px' : '10px',
padding: isCollapsed ? '12px 8px' : '8px 12px',
justifyContent: isCollapsed ? 'center' : 'space-between',
width: isCollapsed ? '64px' : 'auto',
height: isCollapsed ? '64px' : 'auto',
minHeight: '48px',
flexDirection: isCollapsed ? 'column' : 'row',
position: 'relative',
overflow: 'hidden',
}"
:items-active-style="{
backgroundColor: '#fff',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
color: 'rgba(0, 0, 0, 0.85)',
}"
:items-hover-style="{
backgroundColor: 'rgba(0, 0, 0, 0.04)',
}"
@menu-command="handleMenuCommand"
@change="handleChange"
@contextmenu="handleContextMenu"
>
<!-- 自定义折叠状态下的会话项内容 -->
<template #default="{ item }">
<div class="conversation-item-content">
<div v-if="isCollapsed" class="collapsed-item">
<div
class="avatar-circle"
@click="handleChange(item)"
@contextmenu="(e) => handleContextMenu(e, item)"
>
{{ item.sessionTitle?.charAt(0) || 'A' }}
</div>
<div v-if="item.unreadCount" class="unread-indicator">
{{ item.unreadCount > 99 ? '99+' : item.unreadCount }}
</div>
<!-- 折叠状态下的更多操作按钮 -->
<div
class="collapsed-menu-trigger"
@click.stop="(e) => handleCollapsedMenuClick(e, item)"
@contextmenu.stop="(e) => handleContextMenu(e, item)"
>
<el-icon size="14">
<MoreFilled />
</el-icon>
</div>
</div>
<div v-else class="expanded-item">
<div class="conversation-info">
<div class="conversation-title">
{{ item.sessionTitle }}
</div>
<div v-if="item.sessionContent" class="conversation-preview">
{{ item.sessionContent.substring(0, 30) }}{{ item.sessionContent.length > 30 ? '...' : '' }}
</div>
</div>
</div>
</div>
</template>
</Conversations>
</div>
<el-empty
v-else
class="h-full flex-center"
:description="isCollapsed ? '' : '暂无对话记录'"
>
<template #description>
<span v-if="!isCollapsed">暂无对话记录</span>
</template>
</el-empty>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 基础样式
.aside-container {
height: 100%;
border-right: 0.5px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
flex-shrink: 0;
//background-color: var(--sidebar-background-color, #f9fafb);
// 展开状态 - 240px
&:not(.aside-collapsed) {
width: 240px;
.aside-wrapper {
width: 240px;
}
}
// 折叠状态 - 100px
&.aside-collapsed {
display: none;
.aside-wrapper {
width: 100px;
}
}
}
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// 头部样式
.aside-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px;
border-bottom: 1px solid var(--s-color-border-tertiary, rgb(0 0 0 / 8%));
//background-color: var(--sidebar-header-bg, #ffffff);
.header-content-expanded {
flex: 1;
transition: opacity 0.2s ease;
&.is-disabled {
opacity: 0.5;
cursor: not-allowed !important;
pointer-events: none;
}
}
.header-content-collapsed {
width: 36px;
height: 36px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.el-icon {
color: var(--el-text-color-secondary);
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: rgb(0 0 0 / 85%);
transform: skewX(-2deg);
}
.collapse-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
transition: transform 0.2s ease;
&:hover {
color: var(--el-text-color-primary);
background-color: var(--el-fill-color-light);
transform: scale(1.1);
}
.el-icon {
font-size: 18px;
}
}
}
// 内容区域
.aside-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 4px;
overflow: hidden;
.creat-chat-btn-wrapper {
padding: 12px 8px 4px;
.creat-chat-btn {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
color: #0057ff;
cursor: pointer;
user-select: none;
background-color: rgb(0 87 255 / 6%);
border: 1px solid rgb(0 102 255 / 15%);
border-radius: 12px;
transition: all 0.2s ease;
&:hover:not(.is-disabled) {
background-color: rgb(0 87 255 / 12%);
transform: translateY(-1px);
}
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
&.creat-chat-btn-collapsed {
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 auto;
}
.add-icon {
width: 24px;
height: 24px;
font-size: 16px;
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
margin-left: 6px;
transition: opacity 0.2s ease;
}
}
}
.aside-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
.conversations-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
&:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
}
.conversation-item-content {
width: 100%;
height: 100%;
.collapsed-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
.avatar-circle {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.unread-indicator {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background-color: #ff4d4f;
color: white;
border-radius: 8px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
font-weight: 600;
}
.collapsed-menu-trigger {
position: absolute;
bottom: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
z-index: 2;
.el-icon {
color: var(--el-text-color-secondary);
font-size: 12px;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
opacity: 1;
}
}
&:hover .collapsed-menu-trigger {
opacity: 0.7;
}
}
.expanded-item {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
.conversation-info {
flex: 1;
min-width: 0;
margin-right: 8px;
overflow: hidden;
.conversation-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
line-height: 1.4;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.conversation-preview {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
}
}
}
}
}
}
// 样式穿透 - 重点修复溢出问题
:deep() {
.conversations-list {
background-color: transparent !important;
width: 100% !important;
}
.conversation-group-title {
padding-left: 12px !important;
background-color: transparent !important;
transition: all 0.3s ease;
.title-text {
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s ease;
}
}
.conversation-item {
transition: all 0.3s ease;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
&-inner {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
}
&-content {
flex: 1 !important;
min-width: 0 !important;
max-width: calc(100% - 32px) !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
// 确保操作按钮区域在展开状态下正常显示
&-actions {
flex-shrink: 0 !important;
display: flex !important;
align-items: center !important;
opacity: 1 !important;
visibility: visible !important;
transition: all 0.3s ease;
.el-button {
transition: all 0.2s ease;
width: 24px !important;
height: 24px !important;
padding: 0 !important;
margin-left: 4px !important;
flex-shrink: 0 !important;
&:hover {
transform: scale(1.1);
}
.el-icon {
font-size: 16px !important;
}
}
}
}
// 折叠状态样式
.aside-collapsed {
.conversation-group-title {
display: none !important;
}
.conversation-item {
justify-content: center !important;
padding: 12px 8px !important;
height: 64px !important;
min-height: 64px !important;
&-label {
display: none !important;
}
&-actions {
// 折叠状态下隐藏默认操作按钮,使用自定义的
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
&-content {
max-width: 100% !important;
}
}
}
// 展开状态样式
&:not(.aside-collapsed) {
.conversation-item {
&-actions {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
}
}
}
// 自定义对话框样式
:deep(.collapsed-menu-dialog) {
.el-message-box {
width: 160px !important;
padding: 12px !important;
&__header {
padding: 0 0 8px 0 !important;
border-bottom: none !important;
}
&__title {
font-size: 14px !important;
font-weight: 600 !important;
}
&__content {
padding: 0 !important;
}
&__headerbtn {
top: 8px !important;
right: 8px !important;
.el-icon {
font-size: 14px !important;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.aside-container {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
&.aside-collapsed {
transform: translateX(-100%);
width: 100px !important;
}
&:not(.aside-collapsed) {
transform: translateX(0);
}
}
.aside-wrapper {
width: 280px !important;
.aside-collapsed & {
width: 100px !important;
}
}
// 移动端遮罩层
.aside-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease;
}
}
// 动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>