Files
sionrui/frontend/app/web-gold/src/components/agents/HistoryPanel.vue
sion123 b9a1763e71
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
优化
2026-03-22 22:07:11 +08:00

985 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<teleport to="body">
<transition name="history-modal">
<div v-if="visible" class="history-overlay" @click.self="handleClose">
<!-- Modal Container -->
<div class="history-modal">
<!-- Decorative Elements -->
<div class="modal-glow"></div>
<!-- Header -->
<header class="modal-header">
<div class="header-content">
<div v-if="selectedConversation" class="header-nav">
<button class="nav-back" @click="backToList">
<Icon icon="lucide:arrow-left" class="size-4" />
<span>返回列表</span>
</button>
</div>
<div class="header-title-group">
<h2 class="modal-title">
<Icon icon="lucide:history" class="title-icon" />
{{ selectedConversation ? '对话详情' : '历史记录' }}
</h2>
<p v-if="!selectedConversation" class="modal-subtitle">
查看你与 AI 的所有对话
</p>
</div>
</div>
<button class="close-btn" @click="handleClose">
<Icon icon="lucide:x" class="size-4" />
</button>
</header>
<!-- Content -->
<div class="modal-body">
<!-- Conversation List View -->
<div v-if="!selectedConversation" class="list-view">
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-primary" />
</div>
<!-- Empty State -->
<div v-else-if="groupedConversations.length === 0" class="empty-state">
<div class="empty-illustration">
<div class="illustration-ring"></div>
<div class="illustration-ring delay-1"></div>
<Icon icon="lucide:history" class="illustration-icon" />
</div>
<h3 class="empty-title">暂无历史记录</h3>
<p class="empty-desc">开始对话后记录将保存在这里</p>
</div>
<!-- Grouped Conversations -->
<div v-else class="conversation-groups">
<div
v-for="(group, groupIndex) in groupedConversations"
:key="group.label"
class="conversation-group"
:style="{ '--group-index': groupIndex }"
>
<div class="group-header">
<span class="group-dot"></span>
<span class="group-label">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="group-items">
<button
v-for="(item, itemIndex) in group.items"
:key="item.id"
class="conversation-item"
:style="{ '--item-index': itemIndex }"
@click="selectConversation(item)"
>
<div class="item-indicator"></div>
<div class="item-content">
<h4 class="item-title">{{ item.name || '未命名会话' }}</h4>
<p class="item-preview">{{ item.preview || '暂无预览内容' }}</p>
</div>
<div class="item-meta">
<span class="meta-time">
<Icon icon="lucide:clock" class="size-3" />
{{ formatTime(item.updatedAt || item.createdAt) }}
</span>
<div class="meta-arrow">
<Icon icon="lucide:chevron-right" class="size-4" />
</div>
</div>
</button>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button
class="load-more-btn"
:disabled="loading"
@click="loadConversations(true)"
>
<Icon v-if="loading" icon="lucide:loader-2" class="size-4 animate-spin" />
<span>{{ loading ? '加载中...' : '加载更多' }}</span>
</button>
</div>
</div>
</div>
<!-- Message Detail View -->
<div v-else class="detail-view">
<!-- Loading State -->
<div v-if="messageLoading" class="loading-state">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-primary" />
</div>
<div v-else-if="messageList.length === 0" class="empty-state">
<Icon icon="lucide:message-circle" class="empty-icon-single" />
<p>暂无消息记录</p>
</div>
<div v-else class="message-timeline">
<div
v-for="(msg, index) in messageList"
:key="msg.id"
class="timeline-item"
:style="{ '--msg-index': index }"
>
<!-- User Message -->
<div class="message-block message-block--user">
<div class="message-avatar">
<Icon icon="lucide:user" class="size-4" />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author"></span>
<span class="message-time">{{ formatTime(msg.createdAt) }}</span>
</div>
<div class="message-content">{{ msg.query }}</div>
</div>
</div>
<!-- AI Response -->
<div class="message-block message-block--ai">
<div class="message-avatar">
<Icon icon="lucide:bot" class="size-4" />
</div>
<div class="message-body">
<div class="message-header">
<span class="message-author">AI 助手</span>
<button class="copy-btn" @click="copyContent(msg.answer)" title="复制">
<Icon icon="lucide:copy" class="size-3" />
</button>
</div>
<div class="message-content">{{ msg.answer }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { toast } from 'vue-sonner'
import { Icon } from '@iconify/vue'
import dayjs from 'dayjs'
import { getConversations, getMessages } from '@/api/agent'
import { copyToClipboard } from '@/utils/clipboard'
const props = defineProps({
visible: { type: Boolean, default: false },
agentId: { type: [String, Number], default: null }
})
const emit = defineEmits(['close'])
// State
const loading = ref(false)
const conversationList = ref([])
const selectedConversation = ref(null)
const messageList = ref([])
const messageLoading = ref(false)
const lastId = ref(null)
const hasMore = ref(true)
// 按日期分组(先排序再分组)
const groupedConversations = computed(() => {
const today = dayjs().startOf('day')
const yesterday = dayjs().subtract(1, 'day').startOf('day')
const week = dayjs().subtract(7, 'day').startOf('day')
const groups = {
today: { label: '今天', items: [] },
yesterday: { label: '昨天', items: [] },
week: { label: '近7天', items: [] },
older: { label: '更早', items: [] }
}
// 按时间降序排序
const sortedList = [...conversationList.value].sort((a, b) => {
const timeA = (a.updatedAt || a.createdAt) * 1000
const timeB = (b.updatedAt || b.createdAt) * 1000
return timeB - timeA
})
sortedList.forEach(item => {
const date = dayjs((item.updatedAt || item.createdAt) * 1000)
if (date.isAfter(today)) {
groups.today.items.push(item)
} else if (date.isAfter(yesterday)) {
groups.yesterday.items.push(item)
} else if (date.isAfter(week)) {
groups.week.items.push(item)
} else {
groups.older.items.push(item)
}
})
return Object.values(groups).filter(g => g.items.length > 0)
})
// Methods
const loadConversations = async (loadMore = false) => {
if (!props.agentId) return
if (loadMore && !hasMore.value) return
loading.value = true
try {
const params = { agentId: props.agentId, limit: 20 }
if (loadMore && lastId.value) {
params.lastId = lastId.value
}
const res = await getConversations(params)
if (res.code === 0) {
const data = res.data?.data || []
if (loadMore) {
conversationList.value = [...conversationList.value, ...data]
} else {
conversationList.value = data
}
// 更新分页状态
lastId.value = data.length > 0 ? data[data.length - 1].id : null
hasMore.value = data.length === 20 && res.data?.hasMore !== false
}
} catch (e) {
console.error('加载会话列表失败:', e)
} finally {
loading.value = false
}
}
const selectConversation = async (conversation) => {
selectedConversation.value = conversation
messageLoading.value = true
messageList.value = []
try {
const res = await getMessages({
agentId: props.agentId,
conversationId: conversation.id,
limit: 50
})
if (res.code === 0) {
messageList.value = (res.data?.data || []).reverse()
}
} catch (e) {
console.error('加载消息详情失败:', e)
} finally {
messageLoading.value = false
}
}
const backToList = () => {
selectedConversation.value = null
messageList.value = []
}
const handleClose = () => {
emit('close')
backToList()
}
const copyContent = async (content) => {
const success = await copyToClipboard(content)
if (success) {
toast.success('已复制到剪贴板')
} else {
toast.error('复制失败')
}
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = dayjs(timestamp * 1000)
const now = dayjs()
if (date.isSame(now, 'day')) return date.format('HH:mm')
if (date.isSame(now.subtract(1, 'day'), 'day')) return '昨天 ' + date.format('HH:mm')
if (date.isAfter(now.subtract(7, 'day'))) return date.format('dddd HH:mm')
return date.format('MM-DD HH:mm')
}
// Watch
watch(() => props.visible, (val) => {
if (val) {
backToList()
loadConversations()
}
})
</script>
<style scoped lang="less">
// ========================================
// Overlay & Modal Container
// ========================================
.history-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.history-modal {
position: relative;
width: 100%;
max-width: 720px;
max-height: calc(100vh - 80px);
background: var(--color-bg-page);
border-radius: 20px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.1),
0 20px 50px -12px rgba(0, 0, 0, 0.25),
0 8px 20px -8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle at 30% 20%,
rgba(59, 130, 246, 0.08) 0%,
transparent 40%
);
pointer-events: none;
}
// ========================================
// Header
// ========================================
.modal-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 28px 20px;
border-bottom: 1px solid var(--color-gray-200);
background: linear-gradient(to bottom, rgba(255,255,255,0.8), rgba(255,255,255,0));
}
.header-content {
flex: 1;
}
.header-nav {
margin-bottom: 12px;
}
.nav-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
background: var(--color-gray-100);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--color-gray-600);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-gray-200);
color: var(--color-gray-900);
}
}
.header-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 700;
color: var(--color-gray-900);
margin: 0;
letter-spacing: -0.02em;
}
.title-icon {
font-size: 18px;
color: var(--color-primary-500);
}
.modal-subtitle {
font-size: 14px;
color: var(--color-gray-500);
margin: 0;
padding-left: 28px;
}
.close-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border: none;
background: var(--color-gray-100);
border-radius: 10px;
color: var(--color-gray-500);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-100);
color: var(--color-error-500);
transform: scale(1.05);
}
}
// ========================================
// Modal Body
// ========================================
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px 28px 28px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: 3px;
&:hover {
background: var(--color-gray-400);
}
}
}
// ========================================
// Loading State
// ========================================
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
// ========================================
// Empty State
// ========================================
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-illustration {
position: relative;
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.illustration-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--color-gray-200);
animation: ringPulse 3s ease-in-out infinite;
&.delay-1 {
inset: 8px;
animation-delay: 0.5s;
}
}
@keyframes ringPulse {
0%, 100% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
.illustration-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 28px;
color: var(--color-gray-400);
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: var(--color-gray-900);
margin: 0 0 6px;
}
.empty-desc {
font-size: 14px;
color: var(--color-gray-500);
margin: 0;
}
.empty-icon-single {
font-size: 40px;
color: var(--color-gray-300);
margin-bottom: 16px;
}
// ========================================
// Conversation Groups
// ========================================
.conversation-groups {
display: flex;
flex-direction: column;
gap: 24px;
}
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: 1px solid var(--color-gray-200);
background: var(--color-bg-card);
border-radius: 10px;
font-size: 14px;
color: var(--color-gray-600);
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: var(--color-primary-300);
color: var(--color-primary-500);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.conversation-group {
animation: groupFadeIn 0.4s ease-out backwards;
animation-delay: calc(var(--group-index) * 0.1s);
}
@keyframes groupFadeIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.group-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding: 0 4px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary-500);
}
.group-label {
font-size: 13px;
font-weight: 600;
color: var(--color-gray-700);
letter-spacing: 0.02em;
}
.group-count {
font-size: 11px;
font-weight: 600;
color: var(--color-gray-500);
background: var(--color-gray-100);
padding: 2px 8px;
border-radius: 10px;
}
.group-items {
display: flex;
flex-direction: column;
gap: 8px;
}
// ========================================
// Conversation Item
// ========================================
.conversation-item {
width: 100%;
display: flex;
align-items: center;
gap: 16px;
padding: 16px 18px;
background: var(--color-bg-card);
border: 1px solid var(--color-gray-200);
border-radius: 14px;
cursor: pointer;
text-align: left;
transition: all 0.25s ease;
animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--group-index) * 0.1s + var(--item-index) * 0.05s);
&:hover {
border-color: var(--color-primary-300);
background: rgba(59, 130, 246, 0.02);
box-shadow:
0 4px 12px rgba(59, 130, 246, 0.08),
0 0 0 1px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
.item-indicator {
background: var(--color-primary-500);
transform: scaleY(1);
}
.meta-arrow {
color: var(--color-primary-500);
transform: translateX(3px);
}
}
}
@keyframes itemFadeIn {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.item-indicator {
width: 4px;
height: 32px;
border-radius: 2px;
background: var(--color-gray-200);
flex-shrink: 0;
transition: all 0.25s ease;
transform: scaleY(0.6);
}
.item-content {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 15px;
font-weight: 600;
color: var(--color-gray-900);
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-preview {
font-size: 13px;
color: var(--color-gray-500);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.item-meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.meta-time {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--color-gray-400);
white-space: nowrap;
}
.meta-arrow {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-gray-300);
font-size: 10px;
transition: all 0.25s ease;
}
// ========================================
// Message Detail View
// ========================================
.detail-view {
min-height: 200px;
}
.message-timeline {
display: flex;
flex-direction: column;
gap: 24px;
}
.timeline-item {
animation: msgFadeIn 0.35s ease-out backwards;
animation-delay: calc(var(--msg-index) * 0.08s);
}
@keyframes msgFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-block {
display: flex;
gap: 12px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
.message-block--user & {
background: linear-gradient(135deg, var(--color-gray-100), var(--color-gray-200));
color: var(--color-gray-600);
}
.message-block--ai & {
background: linear-gradient(135deg, var(--color-primary-500), #8b5cf6);
color: white;
}
}
.message-body {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.message-author {
font-size: 12px;
font-weight: 600;
color: var(--color-gray-700);
}
.message-time {
font-size: 11px;
color: var(--color-gray-400);
}
.copy-btn {
margin-left: auto;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--color-gray-400);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background: var(--color-primary-100);
color: var(--color-primary-500);
}
}
.message-content {
padding: 14px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.7;
color: var(--color-gray-800);
white-space: pre-wrap;
word-break: break-word;
.message-block--user & {
background: var(--color-gray-100);
border: 1px solid var(--color-gray-200);
}
.message-block--ai & {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(139, 92, 246, 0.04));
border: 1px solid rgba(59, 130, 246, 0.15);
}
}
// ========================================
// Transitions
// ========================================
.history-modal-enter-active {
animation: modalIn 0.35s cubic-bezier(0.32, 0.72, 0, 1);
}
.history-modal-leave-active {
animation: modalOut 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes modalIn {
from {
opacity: 0;
.history-modal {
transform: scale(0.95) translateY(10px);
}
}
to {
opacity: 1;
.history-modal {
transform: scale(1) translateY(0);
}
}
}
@keyframes modalOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.history-modal-enter-active .history-modal {
animation: modalScaleIn 0.35s cubic-bezier(0.32, 0.72, 0, 1);
}
.history-modal-leave-active .history-modal {
animation: modalScaleOut 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes modalScaleIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalScaleOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.98) translateY(5px);
}
}
// ========================================
// Responsive
// ========================================
@media (max-width: 768px) {
.history-overlay {
padding: 0;
align-items: stretch;
}
.history-modal {
max-width: 100%;
max-height: 100vh;
height: 100vh;
border-radius: 0;
}
.modal-header {
padding: 16px 20px;
}
.modal-body {
padding: 16px 20px 24px;
}
.conversation-item {
padding: 14px 16px;
}
.item-indicator {
display: none;
}
}
</style>