351 lines
7.8 KiB
Vue
351 lines
7.8 KiB
Vue
<template>
|
|
<section class="conversation-nav-section" :class="{ collapsed }">
|
|
<div v-if="showHistory && !collapsed" class="history-panel">
|
|
<div class="history-label" @click="listCollapsed = !listCollapsed">
|
|
<span>对话历史</span>
|
|
<ChevronDown :size="14" class="collapse-icon" :class="{ collapsed: listCollapsed }" />
|
|
</div>
|
|
<div v-show="!listCollapsed" class="conversation-list">
|
|
<template v-if="sortedChats.length > 0">
|
|
<div
|
|
v-for="chat in sortedChats"
|
|
:key="chat.id"
|
|
type="button"
|
|
class="conversation-item"
|
|
:class="{ active: currentChatId === chat.id }"
|
|
@click="$emit('select-chat', chat.id)"
|
|
@click.middle="$emit('delete-chat', chat.id)"
|
|
>
|
|
<span class="conversation-title">{{ chat.title || '新的对话' }}</span>
|
|
<span class="actions-mask"></span>
|
|
<span class="conversation-actions" @click.stop>
|
|
<a-dropdown :trigger="['click']">
|
|
<template #overlay>
|
|
<a-menu>
|
|
<a-menu-item
|
|
key="pin"
|
|
:icon="h(chat.is_pinned ? PinOff : Pin, { size: 14 })"
|
|
@click.stop="$emit('toggle-pin', chat.id)"
|
|
>
|
|
{{ chat.is_pinned ? '取消置顶' : '置顶' }}
|
|
</a-menu-item>
|
|
<a-menu-item
|
|
key="rename"
|
|
:icon="h(Pencil, { size: 14 })"
|
|
@click.stop="renameChat(chat.id)"
|
|
>
|
|
重命名
|
|
</a-menu-item>
|
|
<a-menu-item
|
|
key="delete"
|
|
:icon="h(Trash2, { size: 14 })"
|
|
@click.stop="$emit('delete-chat', chat.id)"
|
|
>
|
|
删除
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
<span class="action-btn-wrapper">
|
|
<a-button type="text" class="more-btn">
|
|
<MoreVertical :size="16" />
|
|
</a-button>
|
|
<Pin v-if="chat.is_pinned" :size="14" class="pinned-indicator" />
|
|
</span>
|
|
</a-dropdown>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<div v-else-if="!collapsed" class="empty-list">暂无对话历史</div>
|
|
<div v-if="hasMoreChats && !collapsed" class="load-more-wrapper">
|
|
<a-button
|
|
type="text"
|
|
class="load-more-btn"
|
|
:loading="isLoadingMore"
|
|
@click="$emit('load-more-chats')"
|
|
>
|
|
{{ isLoadingMore ? '加载中...' : '加载更多' }}
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, h, ref } from 'vue'
|
|
import { message, Modal } from 'ant-design-vue'
|
|
import { ChevronDown, MoreVertical, Pencil, Pin, PinOff, Trash2 } from 'lucide-vue-next'
|
|
import { parseToShanghai } from '@/utils/time'
|
|
|
|
const props = defineProps({
|
|
currentChatId: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
chatsList: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
hasMoreChats: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
isLoadingMore: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
collapsed: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
showHistory: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'select-chat',
|
|
'delete-chat',
|
|
'rename-chat',
|
|
'toggle-pin',
|
|
'load-more-chats'
|
|
])
|
|
|
|
const listCollapsed = ref(false)
|
|
|
|
const sortedChats = computed(() => {
|
|
return [...props.chatsList].sort((a, b) => {
|
|
if (a.is_pinned !== b.is_pinned) {
|
|
return a.is_pinned ? -1 : 1
|
|
}
|
|
const dateA = parseToShanghai(b.created_at)
|
|
const dateB = parseToShanghai(a.created_at)
|
|
if (!dateA || !dateB) return 0
|
|
return dateA.diff(dateB)
|
|
})
|
|
})
|
|
|
|
const renameChat = async (chatId) => {
|
|
const chat = props.chatsList.find((item) => item.id === chatId)
|
|
if (!chat) return
|
|
|
|
let newTitle = chat.title || ''
|
|
Modal.confirm({
|
|
title: '重命名对话',
|
|
content: h('div', { style: { marginTop: '12px' } }, [
|
|
h('input', {
|
|
value: newTitle,
|
|
style: {
|
|
width: '100%',
|
|
padding: '4px 8px',
|
|
border: '1px solid var(--gray-150)',
|
|
background: 'var(--gray-0)',
|
|
borderRadius: '4px'
|
|
},
|
|
onInput: (event) => {
|
|
newTitle = event.target.value
|
|
}
|
|
})
|
|
]),
|
|
okText: '确认',
|
|
cancelText: '取消',
|
|
onOk: () => {
|
|
if (!newTitle.trim()) {
|
|
message.warning('标题不能为空')
|
|
return Promise.reject()
|
|
}
|
|
emit('rename-chat', { chatId, title: newTitle })
|
|
}
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.conversation-nav-section {
|
|
display: flex;
|
|
min-height: 0;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.conversation-title {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.history-panel {
|
|
display: flex;
|
|
min-height: 0;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.history-label {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 4px 8px;
|
|
color: var(--gray-500);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
border-radius: 4px;
|
|
transition: background-color 0.2s ease;
|
|
|
|
&:hover {
|
|
background: var(--main-20);
|
|
}
|
|
|
|
&:active {
|
|
background: var(--main-30);
|
|
}
|
|
}
|
|
|
|
.collapse-icon {
|
|
transition: transform 0.2s ease;
|
|
|
|
&.collapsed {
|
|
transform: rotate(-90deg);
|
|
}
|
|
}
|
|
|
|
.conversation-list {
|
|
min-height: 0;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding-right: 2px;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.conversation-item {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
width: 100%;
|
|
height: 36px;
|
|
padding: 0 8px;
|
|
overflow: hidden;
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: var(--gray-700);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
text-align: left;
|
|
transition:
|
|
background-color 0.2s ease,
|
|
color 0.2s ease;
|
|
|
|
&:hover {
|
|
background: var(--main-20);
|
|
color: var(--main-color);
|
|
|
|
.actions-mask,
|
|
.conversation-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.more-btn {
|
|
display: inline-flex;
|
|
}
|
|
|
|
.pinned-indicator {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
background-color: color-mix(in srgb, var(--main-color) 6%, var(--gray-0));
|
|
color: var(--main-color);
|
|
font-weight: 600;
|
|
|
|
.actions-mask {
|
|
opacity: 1;
|
|
background: linear-gradient(
|
|
to right,
|
|
transparent,
|
|
color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px
|
|
);
|
|
}
|
|
}
|
|
|
|
&:has(.pinned-indicator) {
|
|
.actions-mask,
|
|
.conversation-actions {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
.actions-mask {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 56px;
|
|
background: linear-gradient(to right, transparent, var(--main-5) 20px);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.conversation-actions {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
opacity: 0;
|
|
transform: translateY(-50%);
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.action-btn-wrapper {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.more-btn {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 1;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
padding: 0;
|
|
color: var(--gray-600);
|
|
}
|
|
|
|
.pinned-indicator {
|
|
color: var(--gray-400);
|
|
}
|
|
|
|
.empty-list {
|
|
margin-top: 16px;
|
|
color: var(--gray-500);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
}
|
|
|
|
.load-more-wrapper {
|
|
padding: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.load-more-btn {
|
|
color: var(--main-color);
|
|
font-size: 12px;
|
|
}
|
|
</style>
|