Luxx/dashboard/src/views/ConversationsView.vue

168 lines
7.4 KiB
Vue

<template>
<div class="conversations">
<div class="header">
<h1>会话管理</h1>
<button @click="showModal = true" class="btn-primary">+ 新建会话</button>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
<div v-else-if="!list.length" class="empty">暂无会话</div>
<div v-else class="list">
<div v-for="c in list" :key="c.id" class="card" @click="$router.push(`/conversations/${c.id}`)">
<div class="card-info">
<h3>{{ c.title || c.first_message || '未命名会话' }}</h3>
<p>{{ formatDate(c.created_at) }} • {{ c.model || '默认模型' }}</p>
</div>
<button @click.stop="deleteConv(c)" class="btn-delete">删除</button>
</div>
</div>
<div v-if="totalPages > 1" class="pagination">
<button @click="page--; fetchData()" :disabled="page === 1">← 上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="page++; fetchData()" :disabled="page >= totalPages">下一页 →</button>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal">
<h2>新建会话</h2>
<div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题(可选)" /></div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider_id" @change="onProviderChange">
<option :value="null" disabled>选择 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.provider_type }})
</option>
</select>
</div>
<div class="form-group">
<label>模型</label>
<input v-model="form.model" placeholder="如: gpt-4, deepseek-chat" />
</div>
<div class="modal-actions">
<button @click="showModal = false" class="btn-secondary">取消</button>
<button @click="createConv" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { conversationsAPI, providersAPI } from '../utils/api.js'
const router = useRouter()
const list = ref([])
const providers = ref([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const loading = ref(true)
const error = ref('')
const showModal = ref(false)
const creating = ref(false)
const form = ref({ title: '', provider_id: null, model: '' })
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const fetchData = async () => {
loading.value = true
error.value = ''
try {
const [convRes, provRes] = await Promise.allSettled([
conversationsAPI.list({ page: page.value, page_size: pageSize }),
providersAPI.list()
])
if (convRes.status === 'fulfilled' && convRes.value.success) {
list.value = convRes.value.data.items || []
total.value = convRes.value.data.total || 0
}
if (provRes.status === 'fulfilled' && provRes.value.success) {
providers.value = provRes.value.data.providers || []
// Set default provider
const defaultProvider = providers.value.find(p => p.is_default)
if (defaultProvider) {
form.value.provider_id = defaultProvider.id
form.value.model = defaultProvider.default_model
}
}
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
const createConv = async () => {
creating.value = true
try {
const res = await conversationsAPI.create(form.value)
if (res.success && res.data?.id) {
showModal.value = false
form.value = { title: '', provider_id: null, model: '' }
router.push(`/conversations/${res.data.id}`)
}
} catch (e) { alert(e.message) }
finally { creating.value = false }
}
const deleteConv = async (c) => {
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
await conversationsAPI.delete(c.id)
fetchData()
}
const onProviderChange = () => {
const provider = providers.value.find(p => p.id === form.value.provider_id)
if (provider) {
form.value.model = provider.default_model
}
}
const formatDate = (d) => {
if (!d) return ''
const diff = Date.now() - new Date(d)
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return new Date(d).toLocaleDateString('zh-CN')
}
onMounted(fetchData)
</script>
<style scoped>
.conversations { padding: 0; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.header h1 { font-size: 2rem; margin: 0; color: var(--text-h); }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.loading, .empty, .error-msg { text-align: center; padding: 4rem; color: var(--text); }
.error-msg { background: #fef2f2; border-radius: 12px; }
.list { display: flex; flex-direction: column; gap: 1rem; }
.card { display: flex; justify-content: space-between; align-items: center; padding: 1.25rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card h3 { margin: 0 0 0.5rem; color: var(--text-h); }
.card p { margin: 0; color: var(--text); font-size: 0.875rem; }
.btn-delete { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); font-size: 0.875rem; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 6px; transition: all 0.2s; }
.btn-delete:hover { background: var(--accent); color: white; }
.pagination { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
.pagination button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.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; }
.modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; }
.modal h2 { margin: 0 0 1.5rem; color: var(--text-h); }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
.form-group input, .form-group select { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; background: var(--bg); box-sizing: border-box; }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; }
.spinner { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>