Files
sionrui/frontend/app/web-gold/src/components/agents/HistoryPanel.vue
sion123 110fe62404 refactor: replace ant-design components with shadcn/ui and update toast notifications
This commit migrates from Ant Design Vue components to Shadcn/Vue components across multiple files in the web-gold frontend application. Key changes include:

- Replaced ant-design-vue imports with shadcn/ui components (Dialog, Button, Input, etc.)
- Swapped ant-design-vue message/toast system for vue-sonner toast notifications
- Updated icon usage from ant-design icons to lucide icons via @iconify/vue
- Removed unused token refresh logic that was incorrectly implemented client-side
- Applied consistent styling updates to match new component library

The token refresh functionality was removed since it should be handled server-side through axios interceptors rather than client-side intervals.
2026-03-16 02:41:26 +08:00

919 lines
21 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>
</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 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: [] }
}
conversationList.value.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 () => {
if (!props.agentId) return
loading.value = true
try {
const res = await getConversations({ agentId: props.agentId, limit: 50 })
if (res.code === 0) {
conversationList.value = res.data?.data || []
}
} 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)
success ? toast.success('已复制到剪贴板') : 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')
} else if (date.isSame(now.subtract(1, 'day'), 'day')) {
return '昨天 ' + date.format('HH:mm')
} else if (date.isAfter(now.subtract(7, 'day'))) {
return date.format('dddd HH:mm')
} else {
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;
}
.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: 20px;
align-items: flex-end;
}
.history-modal {
max-height: calc(100vh - 40px);
border-radius: 20px 20px 12px 12px;
}
.modal-header {
padding: 20px 20px 16px;
}
.modal-body {
padding: 16px 20px 24px;
}
.conversation-item {
padding: 14px 16px;
}
.item-indicator {
display: none;
}
}
</style>