Luxx/dashboard/src/views/ConversationsView.vue

901 lines
19 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>
<div class="conversations">
<div class="header">
<div>
<h1>会话管理</h1>
<p class="subtitle">查看和管理所有会话记录</p>
</div>
<button @click="createNewConversation" class="btn-primary">
<span class="icon">+</span> 新建会话
</button>
</div>
<!-- 筛选和搜索 -->
<div class="filters">
<input
v-model="searchQuery"
type="text"
placeholder="搜索会话..."
class="search-input"
@input="debouncedSearch"
/>
<select v-model="selectedModel" class="model-select" @change="fetchConversations">
<option value="">全部模型</option>
<option value="glm-5">GLM-5</option>
<option value="glm-4">GLM-4</option>
</select>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="spinner"></div>
<p>正在加载会话列表...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<div class="error-icon">⚠️</div>
<p>{{ error }}</p>
<button @click="fetchConversations" class="btn-secondary">重试</button>
</div>
<!-- 空状态 -->
<div v-else-if="conversations.length === 0" class="empty-container">
<div class="empty-icon">💬</div>
<h3>暂无会话</h3>
<p>创建您的第一个会话开始对话</p>
<button @click="createNewConversation" class="btn-primary">新建会话</button>
</div>
<!-- 会话列表 -->
<div v-else class="conversation-list">
<div
v-for="conv in conversations"
:key="conv.id"
class="conversation-card"
@click="openDetail(conv.id)"
>
<div class="conversation-info">
<h3 class="conversation-title">{{ conv.title || '未命名会话' }}</h3>
<div class="conversation-meta">
<span class="meta-item">
<span class="meta-icon">📅</span>
{{ formatDate(conv.created_at) }}
</span>
<span class="meta-item">
<span class="meta-icon">🤖</span>
{{ conv.model || '默认模型' }}
</span>
<span v-if="conv.message_count" class="meta-item">
<span class="meta-icon">💬</span>
{{ conv.message_count }} 条消息
</span>
</div>
</div>
<div class="conversation-actions" @click.stop>
<button @click="editConversation(conv)" class="btn-icon" title="编辑">
✏️
</button>
<button @click="confirmDelete(conv)" class="btn-icon btn-danger" title="删除">
🗑️
</button>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1 && !loading" class="pagination">
<button
@click="prevPage"
:disabled="page === 1"
class="btn-pagination"
>
← 上一页
</button>
<div class="pagination-info">
<span>第 {{ page }} / {{ totalPages }} 页</span>
<span class="divider">|</span>
<span>共 {{ total }} 条</span>
</div>
<button
@click="nextPage"
:disabled="page >= totalPages"
class="btn-pagination"
>
下一页 →
</button>
</div>
<!-- 创建/编辑会话模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingConversation ? '编辑会话' : '新建会话' }}</h2>
<button @click="closeModal" class="btn-close">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>会话标题</label>
<input
v-model="formData.title"
type="text"
placeholder="输入会话标题"
class="form-input"
/>
</div>
<div class="form-group">
<label>模型选择</label>
<select v-model="formData.model" class="form-select">
<option value="glm-5">GLM-5</option>
<option value="glm-4">GLM-4</option>
</select>
</div>
<div class="form-group">
<label>系统提示词 (可选)</label>
<textarea
v-model="formData.system_prompt"
placeholder="设置系统提示词"
class="form-textarea"
rows="3"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>温度参数</label>
<input
v-model.number="formData.temperature"
type="number"
min="0"
max="2"
step="0.1"
class="form-input"
/>
</div>
<div class="form-group">
<label>最大令牌数</label>
<input
v-model.number="formData.max_tokens"
type="number"
min="1"
max="65536"
class="form-input"
/>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
v-model="formData.thinking_enabled"
type="checkbox"
/>
启用思考模式
</label>
</div>
</div>
<div class="modal-footer">
<button @click="closeModal" class="btn-secondary">取消</button>
<button @click="submitForm" class="btn-primary">
{{ editingConversation ? '保存' : '创建' }}
</button>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="closeDeleteModal">
<div class="modal modal-small">
<div class="modal-header">
<h2>确认删除</h2>
</div>
<div class="modal-body">
<p>确定要删除会话「{{ deletingConversation?.title || '未命名会话' }}」吗?</p>
<p class="warning-text">此操作不可撤销</p>
</div>
<div class="modal-footer">
<button @click="closeDeleteModal" class="btn-secondary">取消</button>
<button @click="deleteConversation" class="btn-danger" :disabled="deleting">
{{ deleting ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { conversationsAPI } from '../services/api.js'
const loading = ref(true)
const error = ref(null)
const conversations = ref([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const searchQuery = ref('')
const selectedModel = ref('')
// 模态框状态
const showModal = ref(false)
const editingConversation = ref(null)
const formData = ref({
title: '',
model: 'glm-5',
system_prompt: '',
temperature: 1.0,
max_tokens: 65536,
thinking_enabled: false
})
// 删除模态框状态
const showDeleteModal = ref(false)
const deletingConversation = ref(null)
const deleting = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取会话列表
const fetchConversations = async () => {
loading.value = true
error.value = null
try {
const params = {
page: page.value,
page_size: pageSize.value
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedModel.value) {
params.model = selectedModel.value
}
const response = await conversationsAPI.list(params)
if (response.success) {
conversations.value = response.data.items || []
total.value = response.data.total || 0
} else {
throw new Error(response.message || '获取会话列表失败')
}
} catch (err) {
console.error('获取会话列表失败:', err)
error.value = err.message || '网络错误,请稍后重试'
} finally {
loading.value = false
}
}
// 防抖搜索
let searchTimeout = null
const debouncedSearch = () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
fetchConversations()
}, 300)
}
// 分页
const prevPage = () => {
if (page.value > 1) {
page.value--
fetchConversations()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
fetchConversations()
}
}
// 新建会话
const createNewConversation = () => {
editingConversation.value = null
formData.value = {
title: '',
model: 'glm-5',
system_prompt: '',
temperature: 1.0,
max_tokens: 65536,
thinking_enabled: false
}
showModal.value = true
}
// 编辑会话
const editConversation = (conv) => {
editingConversation.value = conv
formData.value = {
title: conv.title || '',
model: conv.model || 'glm-5',
system_prompt: conv.system_prompt || '',
temperature: conv.temperature || 1.0,
max_tokens: conv.max_tokens || 65536,
thinking_enabled: conv.thinking_enabled || false
}
showModal.value = true
}
// 关闭模态框
const closeModal = () => {
showModal.value = false
editingConversation.value = null
}
// 提交表单
const submitForm = async () => {
try {
if (editingConversation.value) {
// 更新会话
const response = await conversationsAPI.update(
editingConversation.value.id,
formData.value
)
if (response.success) {
closeModal()
fetchConversations()
} else {
throw new Error(response.message || '更新失败')
}
} else {
// 创建会话
const response = await conversationsAPI.create(formData.value)
if (response.success) {
closeModal()
// 如果创建成功,可以跳转到新会话或刷新列表
fetchConversations()
// 或者跳转到新创建的会话详情
if (response.data?.id) {
openDetail(response.data.id)
}
} else {
throw new Error(response.message || '创建失败')
}
}
} catch (err) {
console.error('提交失败:', err)
alert(err.message || '操作失败,请重试')
}
}
// 打开详情
const openDetail = (id) => {
// 可以导航到详情页或打开详情模态框
console.log('打开会话:', id)
// 这里可以导航到会话详情页
// router.push(`/conversations/${id}`)
}
// 确认删除
const confirmDelete = (conv) => {
deletingConversation.value = conv
showDeleteModal.value = true
}
const closeDeleteModal = () => {
showDeleteModal.value = false
deletingConversation.value = null
}
// 执行删除
const deleteConversation = async () => {
if (!deletingConversation.value) return
deleting.value = true
try {
const response = await conversationsAPI.delete(deletingConversation.value.id)
if (response.success) {
closeDeleteModal()
fetchConversations()
} else {
throw new Error(response.message || '删除失败')
}
} catch (err) {
console.error('删除失败:', err)
alert(err.message || '删除失败,请重试')
} finally {
deleting.value = false
}
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '未知时间'
const date = new Date(dateStr)
const now = new Date()
const diff = now - date
// 一天内显示相对时间
if (diff < 86400000) {
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000)
return `${minutes} 分钟前`
}
const hours = Math.floor(diff / 3600000)
return `${hours} 小时前`
}
// 超过一天显示具体日期
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
if (year === now.getFullYear()) {
return `${month}-${day} ${hours}:${minutes}`
}
return `${year}-${month}-${day}`
}
onMounted(() => {
fetchConversations()
})
</script>
<style scoped>
.conversations {
padding: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.header h1 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
color: var(--text-h);
}
.subtitle {
color: var(--text);
font-size: 1rem;
margin: 0;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.search-input {
flex: 1;
max-width: 400px;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
background: var(--bg);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
.model-select {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: var(--text);
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 错误状态 */
.error-container {
text-align: center;
padding: 4rem 2rem;
background: #fef2f2;
border-radius: 12px;
border: 1px solid #fecaca;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* 空状态 */
.empty-container {
text-align: center;
padding: 4rem 2rem;
background: var(--accent-bg);
border-radius: 12px;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-container h3 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
color: var(--text-h);
}
.empty-container p {
color: var(--text);
margin-bottom: 1.5rem;
}
/* 会话列表 */
.conversation-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.conversation-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.conversation-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 12px var(--shadow);
transform: translateY(-2px);
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: var(--text-h);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: var(--text);
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.conversation-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
}
/* 按钮样式 */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--accent-border);
transform: translateY(-1px);
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--code-bg);
}
.btn-icon {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--code-bg);
}
.btn-danger {
background: #fee2e2;
color: #dc2626;
}
.btn-danger:hover {
background: #fecaca;
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.btn-pagination {
padding: 0.5rem 1rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-pagination:hover:not(:disabled) {
background: var(--accent-bg);
border-color: var(--accent);
color: var(--accent);
}
.btn-pagination:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text);
}
.divider {
color: var(--border);
}
/* 模态框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--bg);
border-radius: 16px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-small {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1.25rem;
margin: 0;
color: var(--text-h);
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text);
cursor: pointer;
padding: 0;
line-height: 1;
}
.btn-close:hover {
color: var(--text-h);
}
.modal-body {
padding: 1.5rem;
}
.modal-body p {
margin: 0 0 1rem 0;
color: var(--text);
}
.warning-text {
color: #dc2626 !important;
font-size: 0.875rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid var(--border);
}
/* 表单 */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-h);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
background: var(--bg);
color: var(--text);
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* 响应式 */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 1rem;
}
.filters {
flex-direction: column;
}
.search-input {
max-width: 100%;
}
.conversation-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.conversation-actions {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
.form-row {
grid-template-columns: 1fr;
}
.pagination {
flex-direction: column;
gap: 1rem;
}
}
</style>