352 lines
8.3 KiB
Vue
352 lines
8.3 KiB
Vue
<template>
|
|
<div class="chat-list-page">
|
|
<div class="page-header">
|
|
<h2>我的对话</h2>
|
|
<el-button type="primary" @click="showNewChat = true">
|
|
<el-icon class="mr-1"><ChatDotSquare /></el-icon>
|
|
新对话
|
|
</el-button>
|
|
</div>
|
|
|
|
<!-- 对话列表 -->
|
|
<div v-if="loading" class="loading-state">
|
|
<el-skeleton :rows="5" animated />
|
|
</div>
|
|
|
|
<div v-else-if="chats.length === 0" class="empty-state">
|
|
<el-empty description="还没有对话">
|
|
<el-button type="primary" @click="showNewChat = true">开始第一次对话</el-button>
|
|
</el-empty>
|
|
</div>
|
|
|
|
<div v-else class="chat-items">
|
|
<div
|
|
v-for="chat in chats"
|
|
:key="chat.id"
|
|
class="chat-item"
|
|
@click="router.push(`/chat/${chat.id}`)"
|
|
>
|
|
<div class="chat-avatar">
|
|
<el-avatar :src="chat.characterAvatar" :size="48">
|
|
{{ chat.characterName?.charAt(0) || '?' }}
|
|
</el-avatar>
|
|
</div>
|
|
<div class="chat-info">
|
|
<div class="chat-title">
|
|
{{ chat.title }}
|
|
<el-tag v-if="chat.isPinned" type="warning" size="small" class="ml-1">置顶</el-tag>
|
|
</div>
|
|
<div class="chat-preview">
|
|
{{ chat.lastMessage?.content || '暂无消息' }}
|
|
</div>
|
|
</div>
|
|
<div class="chat-meta">
|
|
<div class="chat-time">{{ formatTime(chat.lastMessageAt || chat.createdAt) }}</div>
|
|
<div class="chat-count">{{ chat.messageCount }} 条消息</div>
|
|
<el-button
|
|
text
|
|
type="danger"
|
|
size="small"
|
|
class="delete-btn"
|
|
@click.stop="handleDelete(chat)"
|
|
>
|
|
<el-icon><Delete /></el-icon>
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新建对话对话框 -->
|
|
<el-dialog v-model="showNewChat" title="选择角色开始对话" width="500px">
|
|
<div class="character-search">
|
|
<el-input
|
|
v-model="searchKeyword"
|
|
placeholder="搜索角色..."
|
|
prefix-icon="Search"
|
|
clearable
|
|
@input="searchCharacters"
|
|
/>
|
|
</div>
|
|
<div class="character-list" v-loading="searchLoading">
|
|
<div
|
|
v-for="char in characterResults"
|
|
:key="char.id"
|
|
class="character-option"
|
|
@click="handleCreateChat(char)"
|
|
>
|
|
<el-avatar :src="char.avatar" :size="40">{{ char.name?.charAt(0) }}</el-avatar>
|
|
<div class="char-info">
|
|
<div class="char-name">{{ char.name }}</div>
|
|
<div class="char-desc">{{ (char.description || '').slice(0, 60) }}{{ (char.description || '').length > 60 ? '...' : '' }}</div>
|
|
</div>
|
|
</div>
|
|
<el-empty v-if="characterResults.length === 0 && !searchLoading" description="没有找到角色" />
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
|
import { ChatDotSquare, Delete } from '@element-plus/icons-vue'
|
|
import * as chatApi from '@/api/chat'
|
|
import * as characterApi from '@/api/character'
|
|
|
|
const router = useRouter()
|
|
|
|
const chats = ref<Chat[]>([])
|
|
const loading = ref(false)
|
|
const showNewChat = ref(false)
|
|
const searchKeyword = ref('')
|
|
const searchLoading = ref(false)
|
|
const characterResults = ref<any[]>([])
|
|
|
|
onMounted(async () => {
|
|
await fetchChats()
|
|
await searchCharacters()
|
|
})
|
|
|
|
async function fetchChats() {
|
|
loading.value = true
|
|
try {
|
|
const res = await chatApi.getChatList({ page: 1, pageSize: 50 }) as any
|
|
chats.value = res.data?.list || []
|
|
} catch {
|
|
// 错误已处理
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
async function searchCharacters() {
|
|
if (searchTimer) clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(async () => {
|
|
searchLoading.value = true
|
|
try {
|
|
// 同时获取「我的角色卡」和「公开角色卡」,并合并去重
|
|
const [myRes, publicRes] = await Promise.all([
|
|
characterApi.getMyCharacterList({
|
|
page: 1,
|
|
pageSize: 50,
|
|
keyword: searchKeyword.value,
|
|
}) as any,
|
|
characterApi.getPublicCharacterList({
|
|
page: 1,
|
|
pageSize: 50,
|
|
keyword: searchKeyword.value,
|
|
}) as any,
|
|
])
|
|
|
|
const mergedMap = new Map<number, any>()
|
|
|
|
const appendList = (list?: any[]) => {
|
|
if (!list) return
|
|
for (const item of list) {
|
|
if (!item || item.id == null) continue
|
|
// 以 id 为键去重;优先保留「我的角色卡」信息
|
|
if (!mergedMap.has(item.id)) {
|
|
mergedMap.set(item.id, item)
|
|
}
|
|
}
|
|
}
|
|
|
|
appendList(myRes?.data?.list)
|
|
appendList(publicRes?.data?.list)
|
|
|
|
characterResults.value = Array.from(mergedMap.values())
|
|
} catch {
|
|
characterResults.value = []
|
|
} finally {
|
|
searchLoading.value = false
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
async function handleCreateChat(character: any) {
|
|
try {
|
|
const res = await chatApi.createChat({ characterId: character.id }) as any
|
|
const chatId = res.data?.id
|
|
showNewChat.value = false
|
|
if (chatId) {
|
|
router.push(`/chat/${chatId}`)
|
|
}
|
|
} catch {
|
|
ElMessage.error('创建对话失败')
|
|
}
|
|
}
|
|
|
|
async function handleDelete(chat: Chat) {
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确定要删除与「${chat.characterName || chat.title}」的对话吗?所有消息都会被删除。`,
|
|
'删除对话',
|
|
{ type: 'warning' }
|
|
)
|
|
await chatApi.deleteChat(chat.id)
|
|
ElMessage.success('删除成功')
|
|
await fetchChats()
|
|
} catch {
|
|
// 用户取消
|
|
}
|
|
}
|
|
|
|
function formatTime(timeStr: string): string {
|
|
if (!timeStr) return ''
|
|
const date = new Date(timeStr)
|
|
const now = new Date()
|
|
const diff = now.getTime() - date.getTime()
|
|
const minutes = Math.floor(diff / 60000)
|
|
const hours = Math.floor(diff / 3600000)
|
|
const days = Math.floor(diff / 86400000)
|
|
|
|
if (minutes < 1) return '刚刚'
|
|
if (minutes < 60) return `${minutes}分钟前`
|
|
if (hours < 24) return `${hours}小时前`
|
|
if (days < 7) return `${days}天前`
|
|
return date.toLocaleDateString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.chat-list-page {
|
|
max-width: 700px;
|
|
margin: 0 auto;
|
|
padding: 24px 16px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 24px;
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 22px;
|
|
}
|
|
}
|
|
|
|
.mr-1 { margin-right: 4px; }
|
|
.ml-1 { margin-left: 4px; }
|
|
|
|
.chat-items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.chat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 14px 16px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
background: var(--el-fill-color-light);
|
|
|
|
.delete-btn {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.chat-avatar {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chat-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
|
|
.chat-title {
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.chat-preview {
|
|
margin-top: 4px;
|
|
font-size: 13px;
|
|
color: var(--el-text-color-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
}
|
|
|
|
.chat-meta {
|
|
flex-shrink: 0;
|
|
text-align: right;
|
|
|
|
.chat-time {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-placeholder);
|
|
}
|
|
|
|
.chat-count {
|
|
font-size: 11px;
|
|
color: var(--el-text-color-placeholder);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.delete-btn {
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
margin-top: 4px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.character-search {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.character-list {
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.character-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
|
|
&:hover {
|
|
background: var(--el-fill-color-light);
|
|
}
|
|
|
|
.char-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
|
|
.char-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.char-desc {
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
margin-top: 2px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
}
|
|
}
|
|
|
|
.loading-state {
|
|
padding: 20px;
|
|
}
|
|
</style>
|