797 lines
40 KiB
Vue
797 lines
40 KiB
Vue
<template>
|
||
<div class="sessions-view">
|
||
<!-- Stats Cards -->
|
||
<div class="stats-row">
|
||
<div class="stat-card">
|
||
<p class="stat-lbl">活跃会话集群</p>
|
||
<h3 class="stat-val">{{ activeCount }}/{{ total }}</h3>
|
||
</div>
|
||
<div class="stat-card">
|
||
<p class="stat-lbl">当前筛选调用量</p>
|
||
<h3 class="stat-val accent">{{ filteredCalls.toLocaleString() }} <span class="stat-unit">次</span></h3>
|
||
</div>
|
||
<div class="stat-card">
|
||
<p class="stat-lbl">引擎分布情况</p>
|
||
<div class="engine-dots">
|
||
<span v-for="(c, eng) in engineDist" :key="eng" class="engine-dot" :style="{ background: getEngineMeta(eng).color }" :title="`${getEngineMeta(eng).label}: ${c}`">{{ getEngineMeta(eng).label }}({{ c }})</span>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<p class="stat-lbl">异常/中断率</p>
|
||
<h3 class="stat-val success">{{ errorRate }}%</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search + Filter -->
|
||
<div class="search-bar">
|
||
<div class="search-wrap">
|
||
<span class="search-ico">🔍</span>
|
||
<input v-model="query" class="search-input" placeholder="输入摘要、会话 ID、文件路径关键字检索..." @keyup.enter="doSearch" />
|
||
<button v-if="query" class="search-clear" @click="query = ''; doSearch()">✕</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<span class="filter-lbl">状态过滤:</span>
|
||
<button v-for="opt in statusOpts" :key="opt.key" class="filter-btn" :class="{ active: statusFilter === opt.key }" @click="statusFilter = opt.key; doSearch()">{{ opt.label }}</button>
|
||
</div>
|
||
<div class="filter-group">
|
||
<button class="filter-btn" :class="{ active: favoriteOnly }" @click="onFavoriteOnlyChange()" title="只看收藏会话"
|
||
aria-label="只看收藏会话" :aria-pressed="String(favoriteOnly)">⭐ 只看收藏</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session Cards -->
|
||
<div class="session-list">
|
||
<div class="list-header" v-if="sessions.length">
|
||
<span class="list-header-text">已过滤出 {{ displayedSessions.length }} 个智能体会话监控流 (Engine: {{ selectedEngine.toUpperCase() }})</span>
|
||
</div>
|
||
|
||
<div v-if="loading" class="loading-state">加载中...</div>
|
||
<div v-else-if="!displayedSessions.length" class="empty-state">
|
||
<p>{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}</p>
|
||
</div>
|
||
<div v-else class="card-grid">
|
||
<div
|
||
v-for="s in displayedSessions"
|
||
:key="s.key || s.id"
|
||
class="session-card"
|
||
:style="{ borderLeftColor: getEngineMeta(s.engine).color }"
|
||
@click="openDrawer(s)"
|
||
>
|
||
<div class="card-main">
|
||
<div class="card-tags">
|
||
<span class="engine-badge" :style="{ background: getEngineMeta(s.engine).bg, color: getEngineMeta(s.engine).color, borderColor: getEngineMeta(s.engine).border }">
|
||
{{ s.engine_label || getEngineMeta(s.engine).label }}
|
||
</span>
|
||
<span class="status-badge" :class="'status-' + (s.status || 'unknown')">
|
||
<span class="status-dot-card" :class="'dot-' + (s.status || 'unknown')"></span>
|
||
{{ statusLabel(s.status) }}
|
||
</span>
|
||
<span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span>
|
||
</div>
|
||
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
|
||
<div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div>
|
||
<div class="card-path">
|
||
<span class="path-ico">📁</span>
|
||
<span class="path-text">{{ s.cwd || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-side">
|
||
<div class="card-meta">
|
||
<div class="meta-col">
|
||
<span class="meta-col-label">对应版本</span>
|
||
<span class="meta-col-val">{{ s.cli_version || s.model || '-' }}</span>
|
||
</div>
|
||
<div class="meta-col">
|
||
<span class="meta-col-label">工具调用</span>
|
||
<span class="meta-col-val calls" :class="callsClass(s.tool_call_count)">{{ s.tool_call_count || 0 }} 次</span>
|
||
</div>
|
||
<div class="meta-col">
|
||
<span class="meta-col-label">开始时间</span>
|
||
<span class="meta-col-val time">{{ formatTimestamp(s.created_at) }}</span>
|
||
</div>
|
||
<div class="meta-col">
|
||
<button class="detail-btn" @click.stop="goToDetailPage(s)" title="进入详情页">详情 →</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
<button class="action-btn" @click.stop="openNameEditor(s)" title="编辑名称" aria-label="编辑自定义名称">
|
||
<span class="icon">✏️</span><span class="text">编辑</span>
|
||
</button>
|
||
<button
|
||
class="action-btn fav"
|
||
:class="{ 'is-fav': s.is_favorite }"
|
||
:disabled="isFavoritePending(s)"
|
||
@click.stop="toggleFavorite(s)"
|
||
:title="s.is_favorite ? '取消收藏' : '收藏'"
|
||
:aria-label="s.is_favorite ? '取消收藏此会话' : '收藏此会话'"
|
||
:aria-pressed="String(s.is_favorite)"
|
||
>
|
||
<span class="icon">{{ s.is_favorite ? '★' : '☆' }}</span>
|
||
<span class="text">{{ isFavoritePending(s) ? '处理中' : (s.is_favorite ? '已收藏' : '收藏') }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination -->
|
||
<div class="pagination" v-if="total > pageSize">
|
||
<span class="page-info">共 {{ total }} 条</span>
|
||
<div class="page-btns">
|
||
<button class="page-btn" :disabled="page <= 1" @click="goPage(page - 1)">‹</button>
|
||
<button v-for="p in pageNums" :key="p" v-if="p !== '...'" class="page-btn" :class="{ active: p === page }" @click="goPage(p)">{{ p }}</button>
|
||
<span v-else class="page-ellipsis">...</span>
|
||
<button class="page-btn" :disabled="page >= maxPage" @click="goPage(page + 1)">›</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail Drawer -->
|
||
<div v-if="drawerSession" class="drawer-overlay" @click.self="closeDrawer">
|
||
<div class="drawer">
|
||
<div class="drawer-head">
|
||
<div class="drawer-title-bar">
|
||
<span class="drawer-badge" :style="{ background: getEngineMeta(drawerSession.engine).bg, color: getEngineMeta(drawerSession.engine).color, borderColor: getEngineMeta(drawerSession.engine).border }">
|
||
{{ getEngineMeta(drawerSession.engine).label }} · 运行诊断
|
||
</span>
|
||
<button class="drawer-close" @click="closeDrawer">✕</button>
|
||
</div>
|
||
<h3 class="drawer-summary">{{ drawerSession.custom_name || drawerSession.summary || '(无消息)' }}</h3>
|
||
<div v-if="drawerSession.custom_name" class="drawer-original-summary">{{ drawerSession.summary }}</div>
|
||
<div class="drawer-name-row">
|
||
<p class="drawer-id mono">完整会话 UUID: {{ drawerSession.id }}</p>
|
||
<button class="name-edit-btn" @click.stop="openNameEditor(drawerSession)">✏️ 命名</button>
|
||
<button class="name-edit-btn" @click.stop="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</button>
|
||
<button v-if="drawerSession.custom_name" class="name-edit-btn" @click.stop="clearCustomName(drawerSession)">🗑 清除</button>
|
||
<button
|
||
class="fav-btn-drawer"
|
||
:class="{ 'is-fav': drawerSession.is_favorite }"
|
||
:disabled="isFavoritePending(drawerSession)"
|
||
@click.stop="toggleFavorite(drawerSession)"
|
||
:title="drawerSession.is_favorite ? '取消收藏' : '收藏'"
|
||
:aria-label="drawerSession.is_favorite ? '取消收藏此会话' : '收藏此会话'"
|
||
:aria-pressed="String(drawerSession.is_favorite)"
|
||
>{{ drawerSession.is_favorite ? '★' : '☆' }} {{ drawerSession.is_favorite ? '取消收藏' : '收藏' }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="drawer-body" v-if="drawerDetail">
|
||
<!-- detail content unchanged -->
|
||
<div class="drawer-meta-box">
|
||
<div class="meta-row"><span class="meta-lbl">宿主执行用户:</span><span class="meta-val">{{ drawerSession.model || '-' }}</span></div>
|
||
<div class="meta-row"><span class="meta-lbl">引擎运行时版本:</span><span class="meta-val">{{ drawerSession.cli_version || '-' }}</span></div>
|
||
<div class="meta-row"><span class="meta-lbl">工作物理路径:</span><span class="meta-val accent mono">{{ drawerSession.cwd || '-' }}</span></div>
|
||
<div class="meta-row"><span class="meta-lbl">运行总时长:</span><span class="meta-val">{{ drawerSession.duration_ms ? Math.round(drawerSession.duration_ms / 60000) + ' 分钟' : '暂无数据' }}</span></div>
|
||
<div class="meta-row"><span class="meta-lbl">诊断执行状态:</span><span class="meta-val">
|
||
<span class="status-tag-drawer" :class="'tag-' + (drawerSession.status || 'unknown')">{{ statusLabel(drawerSession.status) }}</span>
|
||
</span></div>
|
||
</div>
|
||
|
||
<div class="drawer-section">
|
||
<h4 class="drawer-section-title">交互式智能体思考环与工具调用 Trace 链路</h4>
|
||
<div v-if="!drawerDetail.messages?.length && drawerSession?.engine === 'agy' && drawerSession?.has_detail_db === false" class="trace-empty">该会话的详细 Trace 数据存储在 per-conversation protobuf 中,本地详情缓存不可用。</div>
|
||
<div v-else-if="!drawerDetail.messages?.length" class="trace-empty">该历史会话没有持久化的 Trace 调试详情数据。</div>
|
||
<div v-else class="trace-list">
|
||
<div v-for="(msg, i) in drawerDetail.messages.slice(0, 30)" :key="i" class="trace-item">
|
||
<span class="trace-dot" :class="traceDot(msg.type)"></span>
|
||
<div class="trace-content">
|
||
<span class="trace-tag" :class="'trace-tag-' + traceTag(msg.type)">{{ traceLabel(msg.type) }}</span>
|
||
<span class="trace-msg">{{ msg.message || msg.name || msg.output_preview || '' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="drawerError" class="drawer-body">
|
||
<div class="drawer-error-box">
|
||
<p class="drawer-error-text">{{ drawerError }}</p>
|
||
<button class="drawer-retry-btn" @click="retryDrawer">重试</button>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="!drawerDetail && !drawerError" class="drawer-body">
|
||
<div class="loading-state">加载中...</div>
|
||
</div>
|
||
|
||
<div class="drawer-footer">
|
||
<button class="footer-btn-copy" @click="copyResume(drawerSession)">
|
||
📋 复制恢复命令
|
||
</button>
|
||
<button class="footer-btn-uuid" @click="copyUUID(drawerSession.id)">
|
||
📋 复制 UUID 句柄
|
||
</button>
|
||
<button class="footer-btn-detail" @click="goToDetailPage(drawerSession)">
|
||
→ 进入详情页
|
||
</button>
|
||
<button v-if="drawerSession.can_delete" class="footer-btn-delete" @click="showDeleteConfirm(drawerSession)">
|
||
🗑 删除会话
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Name Editor Dialog -->
|
||
<div v-if="editNameVisible" class="dialog-overlay" @click.self="editNameVisible = false">
|
||
<div class="dialog">
|
||
<div class="dialog-head">
|
||
<h3>自定义名称</h3>
|
||
<button class="dialog-close" @click="editNameVisible = false">✕</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<p class="dialog-hint">为本会话设置一个易记的名称,仅在本地使用。</p>
|
||
<input
|
||
v-model="editNameValue"
|
||
class="dialog-input"
|
||
placeholder="输入自定义名称..."
|
||
maxlength="100"
|
||
@keyup.enter="saveCustomName"
|
||
/>
|
||
<p class="dialog-counter">{{ editNameValue.length }}/100</p>
|
||
</div>
|
||
<div class="dialog-foot">
|
||
<button class="dialog-btn" @click="editNameVisible = false">取消</button>
|
||
<button class="dialog-btn primary" @click="saveCustomName">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Confirmation Dialog -->
|
||
<div v-if="deleteConfirmVisible" class="dialog-overlay" @click.self="deleteConfirmVisible = false">
|
||
<div class="dialog">
|
||
<div class="dialog-head">
|
||
<h3>确认删除会话</h3>
|
||
<button class="dialog-close" @click="deleteConfirmVisible = false">✕</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<p class="dialog-hint">此操作将从 OpenCode 数据库中永久删除该会话,不可恢复。</p>
|
||
<p class="dialog-id mono">{{ deleteTarget?.id }}</p>
|
||
</div>
|
||
<div class="dialog-foot">
|
||
<button class="dialog-btn" @click="deleteConfirmVisible = false" :disabled="deleting">取消</button>
|
||
<button class="dialog-btn danger" @click="confirmDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, inject, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle, deleteSession, favoriteSession, unfavoriteSession } from '../api/index.js'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
|
||
|
||
const sessions = ref([])
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const query = ref('')
|
||
const statusFilter = ref('all')
|
||
const page = ref(1)
|
||
const pageSize = 50
|
||
const drawerSession = ref(null)
|
||
const drawerDetail = ref(null)
|
||
const drawerError = ref(null)
|
||
const drawerAiLoading = ref(false)
|
||
const router = useRouter()
|
||
const activeSessions = ref(0)
|
||
const filteredCalls = ref(0)
|
||
|
||
const statusOpts = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'active', label: '执行中' },
|
||
{ key: 'success', label: '已完成' },
|
||
{ key: 'failed', label: '已中断' },
|
||
]
|
||
|
||
const favoriteOnly = ref(false)
|
||
const favoritePendingMap = ref({})
|
||
|
||
function isFavoritePending(s) {
|
||
return !!favoritePendingMap.value[`${s.engine}:${s.id}`]
|
||
}
|
||
function setFavoritePending(s, val) {
|
||
if (val) {
|
||
favoritePendingMap.value[`${s.engine}:${s.id}`] = true
|
||
} else {
|
||
delete favoritePendingMap.value[`${s.engine}:${s.id}`]
|
||
}
|
||
}
|
||
|
||
const engineDist = computed(() => {
|
||
const dist = {}
|
||
for (const s of sessions.value) {
|
||
dist[s.engine] = (dist[s.engine] || 0) + 1
|
||
}
|
||
return dist
|
||
})
|
||
|
||
const errorCount = computed(() => sessions.value.filter(s => s.status === 'error' || s.status === 'interrupted').length)
|
||
const errorRate = computed(() => sessions.value.length ? Math.round(errorCount.value / sessions.value.length * 100) : 0)
|
||
const activeCount = computed(() => sessions.value.filter(s => s.status === 'active').length)
|
||
|
||
const displayedSessions = computed(() => {
|
||
let list = sessions.value
|
||
if (statusFilter.value === 'success') list = list.filter(s => s.status === 'success')
|
||
else if (statusFilter.value === 'failed') list = list.filter(s => s.status === 'error' || s.status === 'interrupted')
|
||
else if (statusFilter.value === 'active') list = list.filter(s => s.status === 'active')
|
||
return list
|
||
})
|
||
|
||
const maxPage = computed(() => Math.ceil(total.value / pageSize))
|
||
const pageNums = computed(() => {
|
||
const p = [], tp = maxPage.value
|
||
if (tp <= 7) { for (let i = 1; i <= tp; i++) p.push(i) }
|
||
else {
|
||
p.push(1)
|
||
if (page.value > 3) p.push('...')
|
||
for (let i = Math.max(2, page.value - 1); i <= Math.min(tp - 1, page.value + 1); i++) p.push(i)
|
||
if (page.value < tp - 2) p.push('...')
|
||
p.push(tp)
|
||
}
|
||
return p
|
||
})
|
||
|
||
function statusLabel(status) {
|
||
return { success: '已成功', error: '已中断', interrupted: '已中断', active: '执行中', unknown: '未知' }[status] || status
|
||
}
|
||
|
||
function callsClass(c) {
|
||
if (c > 100) return 'calls-danger'
|
||
if (c > 50) return 'calls-warn'
|
||
return 'calls-normal'
|
||
}
|
||
|
||
function traceTag(type) {
|
||
const map = { user_message: 'primary', function_call: 'warning', function_call_output: 'info', assistant_message: 'success' }
|
||
return map[type] || ''
|
||
}
|
||
function traceDot(type) {
|
||
const map = { user_message: 'dot-primary', function_call: 'dot-warning', function_call_output: 'dot-info', assistant_message: 'dot-success' }
|
||
return map[type] || 'dot-default'
|
||
}
|
||
function traceLabel(type) {
|
||
const map = { user_message: '用户', function_call: '工具', function_call_output: '输出', assistant_message: 'AI' }
|
||
return map[type] || type
|
||
}
|
||
|
||
async function loadSessions() {
|
||
loading.value = true
|
||
try {
|
||
// Map frontend status values to backend status values
|
||
let apiStatus = statusFilter.value;
|
||
if (apiStatus === 'all') apiStatus = null;
|
||
// 'failed' is handled by backend as error+interrupted
|
||
|
||
const data = await getSessions({
|
||
engine: props.selectedEngine,
|
||
query: query.value,
|
||
status: apiStatus,
|
||
limit: pageSize,
|
||
offset: (page.value - 1) * pageSize,
|
||
favorite: favoriteOnly.value ? '1' : undefined,
|
||
})
|
||
sessions.value = data.sessions || []
|
||
total.value = data.total || 0
|
||
// Also fetch stats for calls
|
||
const tools = await getGlobalTools({ engine: props.selectedEngine })
|
||
filteredCalls.value = tools.tool_usage?.reduce((a, b) => a + b.total_calls, 0) || 0
|
||
} catch (err) {
|
||
ElMessage.error('加载失败: ' + err.message)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function doSearch() { page.value = 1; loadSessions() }
|
||
function goPage(p) { if (p >= 1 && p <= maxPage.value) { page.value = p; loadSessions() } }
|
||
|
||
async function openDrawer(s) {
|
||
drawerSession.value = s
|
||
drawerDetail.value = null
|
||
drawerError.value = null
|
||
try {
|
||
const data = await getSessionDetail(s.engine, s.id)
|
||
drawerDetail.value = data
|
||
} catch (err) {
|
||
drawerError.value = '加载详情失败: ' + (err.message || '')
|
||
}
|
||
}
|
||
|
||
function retryDrawer() {
|
||
if (drawerSession.value) openDrawer(drawerSession.value)
|
||
}
|
||
|
||
function closeDrawer() { drawerSession.value = null; drawerDetail.value = null; drawerError.value = null }
|
||
|
||
function copyResume(s) {
|
||
if (!s.resume_command) return
|
||
navigator.clipboard.writeText(s.resume_command).then(() => {
|
||
ElMessage.success(`已复制: ${s.resume_command}`)
|
||
}).catch(() => { ElMessage.success(`手动复制: ${s.resume_command}`) })
|
||
}
|
||
|
||
function copyUUID(id) {
|
||
navigator.clipboard.writeText(id).then(() => ElMessage.success('UUID 已复制'))
|
||
}
|
||
|
||
// Custom name editing
|
||
const editNameVisible = ref(false)
|
||
const editNameValue = ref('')
|
||
const editNameTarget = ref(null)
|
||
|
||
function openNameEditor(session) {
|
||
editNameTarget.value = session
|
||
editNameValue.value = session.custom_name || ''
|
||
editNameVisible.value = true
|
||
}
|
||
|
||
async function saveCustomName() {
|
||
const s = editNameTarget.value
|
||
if (!s) return
|
||
try {
|
||
const result = await setCustomName(s.engine, s.id, editNameValue.value)
|
||
// Update local state
|
||
s.custom_name = result.custom_name || null
|
||
// Also update drawer session if same
|
||
if (drawerSession.value?.id === s.id && drawerSession.value?.engine === s.engine) {
|
||
drawerSession.value.custom_name = s.custom_name
|
||
}
|
||
editNameVisible.value = false
|
||
ElMessage.success('名称已保存')
|
||
} catch (err) {
|
||
ElMessage.error('保存失败: ' + err.message)
|
||
}
|
||
}
|
||
|
||
async function clearCustomName(session) {
|
||
try {
|
||
await deleteCustomName(session.engine, session.id)
|
||
session.custom_name = null
|
||
if (drawerSession.value?.id === session.id && drawerSession.value?.engine === session.engine) {
|
||
drawerSession.value.custom_name = null
|
||
}
|
||
ElMessage.success('名称已清除')
|
||
} catch (err) {
|
||
ElMessage.error('清除失败: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const deleteConfirmVisible = ref(false)
|
||
const deleteTarget = ref(null)
|
||
const deleting = ref(false)
|
||
const refreshDashboard = inject('refreshDashboard', () => {})
|
||
|
||
function showDeleteConfirm(session) {
|
||
deleteTarget.value = session
|
||
deleteConfirmVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const session = deleteTarget.value
|
||
if (!session || deleting.value) return
|
||
deleting.value = true
|
||
try {
|
||
await deleteSession(session.engine, session.id)
|
||
ElMessage.success('会话已删除')
|
||
deleteConfirmVisible.value = false
|
||
deleteTarget.value = null
|
||
closeDrawer()
|
||
loadSessions()
|
||
await refreshDashboard()
|
||
} catch (err) {
|
||
ElMessage.error('删除失败: ' + (err.message || ''))
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
function goToDetailPage(session) {
|
||
if (!session) return
|
||
closeDrawer()
|
||
router.push({ name: 'session-detail', params: { id: session.id }, query: { engine: session.engine } })
|
||
}
|
||
|
||
async function generateDrawerTitle() {
|
||
const s = drawerSession.value
|
||
if (!s) return
|
||
drawerAiLoading.value = true
|
||
try {
|
||
const result = await generateTitle(s.engine, s.id)
|
||
if (result.title) {
|
||
editNameTarget.value = s
|
||
editNameValue.value = result.title
|
||
editNameVisible.value = true
|
||
ElMessage.success('标题已生成,可点击保存确认')
|
||
} else {
|
||
ElMessage.warning(result.error || '生成失败')
|
||
}
|
||
} catch (err) {
|
||
ElMessage.error('生成失败: ' + err.message)
|
||
} finally {
|
||
drawerAiLoading.value = false
|
||
}
|
||
}
|
||
|
||
function toggleFavorite(s) {
|
||
if (!s || isFavoritePending(s)) return
|
||
setFavoritePending(s, true)
|
||
|
||
const action = s.is_favorite ? unfavoriteSession : favoriteSession
|
||
action(s.engine, s.id)
|
||
.then((result) => {
|
||
// Use API response as immediate source of truth
|
||
s.is_favorite = result.is_favorite
|
||
s.favorited_at = result.favorited_at
|
||
// Sync drawer immediately if it matches the acted-on session
|
||
if (drawerSession.value && drawerSession.value.id === s.id && drawerSession.value.engine === s.engine) {
|
||
drawerSession.value.is_favorite = result.is_favorite
|
||
drawerSession.value.favorited_at = result.favorited_at
|
||
}
|
||
if (favoriteOnly.value && !result.is_favorite) {
|
||
const idx = sessions.value.findIndex(x => x.id === s.id && x.engine === s.engine)
|
||
if (idx !== -1) {
|
||
sessions.value.splice(idx, 1)
|
||
total.value = Math.max(0, total.value - 1)
|
||
if (displayedSessions.value.length === 0 && page.value > 1) {
|
||
page.value--
|
||
return loadSessions()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
ElMessage.error('收藏操作失败: ' + (err.message || ''))
|
||
})
|
||
.finally(() => {
|
||
setFavoritePending(s, false)
|
||
})
|
||
}
|
||
|
||
function onFavoriteOnlyChange() {
|
||
favoriteOnly.value = !favoriteOnly.value
|
||
}
|
||
|
||
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
|
||
watch(favoriteOnly, () => { page.value = 1; loadSessions() })
|
||
onMounted(loadSessions)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.sessions-view { width: 100%; }
|
||
|
||
/* Stats */
|
||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||
.stat-card { padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); }
|
||
.stat-lbl { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||
.stat-val { font-size: 22px; font-weight: 800; font-family: 'SF Mono', monospace; color: var(--text-primary); }
|
||
.stat-val.accent { color: var(--accent-light); }
|
||
.stat-val.success { color: var(--success); }
|
||
.stat-unit { font-size: 13px; font-weight: 400; color: var(--text-muted); }
|
||
.engine-dots { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||
.engine-dot { font-size: 10px; padding: 1px 6px; border-radius: 4px; color: #fff; font-family: 'SF Mono', monospace; }
|
||
|
||
/* Search */
|
||
.search-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 480px; }
|
||
.search-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; opacity: 0.5; }
|
||
.search-input { width: 100%; padding: 9px 32px 9px 36px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||
.search-input:focus { border-color: var(--accent); }
|
||
.search-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; }
|
||
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.filter-lbl { font-size: 11px; color: var(--text-muted); }
|
||
.filter-btn { padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; }
|
||
.filter-btn:hover { background: var(--accent-bg); }
|
||
.filter-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-light); font-weight: 600; }
|
||
.filter-group + .filter-group .filter-btn { min-height: 44px; }
|
||
|
||
/* Session Cards */
|
||
.session-list { }
|
||
.list-header { margin-bottom: 12px; }
|
||
.list-header-text { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||
|
||
.loading-state { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
|
||
.empty-state { padding: 48px; text-align: center; color: var(--text-muted); border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); font-size: 13px; }
|
||
|
||
.card-grid { display: flex; flex-direction: column; gap: 12px; }
|
||
.session-card {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border);
|
||
background: var(--bg-card); cursor: pointer; transition: all 0.12s;
|
||
border-left: 4px solid var(--accent);
|
||
}
|
||
.session-card:hover { border-color: var(--text-muted); background: var(--bg-card-hover); }
|
||
.card-main { flex: 1; min-width: 0; padding-right: 16px; }
|
||
.card-tags { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||
.engine-badge { padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; border: 1px solid; font-family: 'SF Mono', monospace; }
|
||
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 600; }
|
||
.status-badge.status-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||
.status-badge.status-error { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||
.status-badge.status-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||
.status-badge.status-active { background: rgba(59,130,246,0.1); color: #3b82f6; animation: pulse 1.5s infinite; }
|
||
.status-badge.status-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||
.status-dot-card { width: 6px; height: 6px; border-radius: 50%; }
|
||
.dot-success { background: var(--success); }
|
||
.dot-error { background: var(--danger); }
|
||
.dot-interrupted { background: var(--warning); }
|
||
.dot-active { background: #3b82f6; }
|
||
.dot-unknown { background: var(--text-muted); }
|
||
.id-preview { font-size: 10px; color: var(--text-muted); }
|
||
.id-highlight { color: var(--accent-light); }
|
||
.name-btn-card { background: none; border: none; font-size: 12px; cursor: pointer; opacity: 0.4; padding: 0 2px; }
|
||
.name-btn-card:hover { opacity: 1; }
|
||
.card-summary { font-size: 1rem; font-weight: 700; color: var(--text-primary); line-height: 1.4; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.card-original-summary { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.card-path { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--text-muted); }
|
||
.path-ico { font-size: 12px; }
|
||
.path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', monospace; }
|
||
|
||
.card-side { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
|
||
.card-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; }
|
||
.action-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 44px; min-height: 44px; padding: 4px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; background: transparent; transition: all 0.12s; border: 1px solid var(--border); color: var(--text-secondary); }
|
||
.action-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||
.action-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.action-btn.fav { border-color: var(--border); }
|
||
.action-btn.fav:hover { border-color: #f59e0b; color: #f59e0b; }
|
||
.action-btn.fav.is-fav { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.1); }
|
||
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; border-color: var(--border) !important; color: var(--text-muted) !important; background: transparent !important; }
|
||
|
||
.card-meta { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
|
||
.meta-col { text-align: center; }
|
||
.meta-col-label { display: block; font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
|
||
.meta-col-val { font-size: 11px; font-family: 'SF Mono', monospace; color: var(--text-secondary); }
|
||
.meta-col-val.time { font-size: 10px; }
|
||
.meta-col-val.calls { font-weight: 600; padding: 1px 8px; border-radius: 10px; }
|
||
.calls-danger { color: var(--danger); background: rgba(239,68,68,0.1); }
|
||
.calls-warn { color: var(--warning); background: rgba(245,158,11,0.1); }
|
||
.calls-normal { color: var(--text-secondary); background: rgba(100,116,139,0.1); }
|
||
.arrow { font-size: 18px; color: var(--text-muted); }
|
||
|
||
/* Pagination */
|
||
.pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; padding: 12px; }
|
||
.page-info { font-size: 12px; color: var(--text-muted); }
|
||
.page-btns { display: flex; gap: 4px; align-items: center; }
|
||
.page-btn { min-width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; font-family: 'SF Mono', monospace; cursor: pointer; }
|
||
.page-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.page-btn:disabled { opacity: 0.4; }
|
||
.page-ellipsis { padding: 0 4px; color: var(--text-muted); }
|
||
|
||
/* Drawer */
|
||
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 500; display: flex; justify-content: flex-end; }
|
||
.drawer { width: 100%; max-width: 560px; height: 100%; background: var(--bg-card); border-left: 1px solid var(--border); display: flex; flex-direction: column; animation: slideIn 0.25s ease; }
|
||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||
.drawer-head { padding: 20px; border-bottom: 1px solid var(--border); background: rgba(15,23,42,0.3); }
|
||
.drawer-title-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||
.drawer-badge { padding: 3px 10px; border-radius: 4px; font-size: 10px; font-weight: 700; border: 1px solid; font-family: 'SF Mono', monospace; }
|
||
.drawer-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
|
||
.drawer-close:hover { color: var(--text-primary); }
|
||
.drawer-summary { font-size: 14px; font-weight: 600; color: var(--text-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.drawer-id { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
|
||
|
||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||
.drawer-meta-box { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--border); background: rgba(15,23,42,0.2); margin-bottom: 16px; }
|
||
.meta-row { display: grid; grid-template-columns: 100px 1fr; gap: 8px; padding: 5px 0; font-size: 11px; }
|
||
.meta-lbl { color: var(--text-muted); }
|
||
.meta-val { color: var(--text-primary); font-weight: 500; }
|
||
.meta-val.accent { color: var(--accent-light); }
|
||
.status-tag-drawer { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; }
|
||
.tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||
.tag-error { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||
.tag-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||
.tag-active { background: rgba(59,130,246,0.1); color: #3b82f6; }
|
||
.tag-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||
|
||
.drawer-section { margin-bottom: 16px; }
|
||
.drawer-section-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; }
|
||
.drawer-error-box { padding: 32px; text-align: center; }
|
||
.drawer-error-text { font-size: 13px; color: var(--danger); margin-bottom: 12px; }
|
||
.drawer-retry-btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent-bg); color: var(--accent-light); cursor: pointer; font-size: 12px; }
|
||
.trace-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 12px; border: 1px dashed var(--border); border-radius: 8px; }
|
||
.trace-list { }
|
||
.trace-item { display: flex; gap: 8px; padding: 4px 0; align-items: flex-start; }
|
||
.trace-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
|
||
.dot-primary { background: var(--accent); }
|
||
.dot-success { background: var(--success); }
|
||
.dot-warning { background: var(--warning); }
|
||
.dot-info { background: var(--text-muted); }
|
||
.dot-default { background: var(--text-muted); }
|
||
.trace-content { flex: 1; min-width: 0; }
|
||
.trace-tag { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: 9px; font-weight: 600; margin-right: 6px; }
|
||
.trace-tag-primary { background: var(--accent-bg); color: var(--accent-light); }
|
||
.trace-tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||
.trace-tag-warning { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||
.trace-tag-info { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||
.trace-msg { font-size: 10px; color: var(--text-secondary); }
|
||
|
||
.drawer-footer { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: rgba(15,23,42,0.2); }
|
||
.footer-btn-copy, .footer-btn-uuid { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||
.footer-btn-copy { background: var(--accent); border: none; color: #fff; }
|
||
.footer-btn-copy:hover { opacity: 0.9; }
|
||
.footer-btn-uuid { border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); }
|
||
.footer-btn-uuid:hover { background: var(--bg-card-hover); }
|
||
|
||
/* Name editor */
|
||
.drawer-original-summary { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||
.drawer-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.name-edit-btn { background: none; border: 1px solid var(--border); padding: 2px 8px; border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer; }
|
||
.name-edit-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||
.detail-btn { background: var(--accent-bg); border: 1px solid var(--accent-border); padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--accent-light); cursor: pointer; white-space: nowrap; }
|
||
.detail-btn:hover { background: var(--accent); color: #fff; }
|
||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 600; display: flex; align-items: center; justify-content: center; }
|
||
.dialog { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; width: 90%; max-width: 420px; }
|
||
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||
.dialog-head h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||
.dialog-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
|
||
.dialog-body { padding: 16px 20px; }
|
||
.dialog-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
|
||
.dialog-input { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||
.dialog-input:focus { border-color: var(--accent); }
|
||
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
|
||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; min-height: 44px; min-width: 44px; }
|
||
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.dialog-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||
.dialog-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.dialog-id { font-size: 11px; color: var(--text-muted); font-family: 'SF Mono', monospace; word-break: break-all; }
|
||
.dialog-btn:hover { opacity: 0.9; }
|
||
|
||
@media (max-width: 768px) {
|
||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||
.session-card { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||
.card-main { padding-right: 0; width: 100%; }
|
||
.card-tags { gap: 4px; }
|
||
.id-preview { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
.card-side { flex-direction: column; align-items: flex-end; width: 100%; gap: 12px; }
|
||
.card-meta { width: 100%; justify-content: space-around; }
|
||
.card-actions { width: 100%; justify-content: flex-end; }
|
||
.drawer { max-width: 100%; }
|
||
}
|
||
.footer-btn-detail {
|
||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||
border: 1px solid var(--accent-border); background: var(--accent-bg);
|
||
color: var(--accent-light); transition: all 0.12s;
|
||
min-height: 44px;
|
||
min-width: 44px;
|
||
}
|
||
.footer-btn-detail:hover { background: var(--accent); color: #fff; }
|
||
.footer-btn-delete {
|
||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||
border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08);
|
||
color: var(--danger); transition: all 0.12s;
|
||
min-height: 44px;
|
||
min-width: 44px;
|
||
}
|
||
.footer-btn-delete:hover { background: var(--danger); color: #fff; }
|
||
|
||
@media (max-width: 480px) {
|
||
.drawer-footer { flex-wrap: wrap; }
|
||
.footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; }
|
||
}
|
||
|
||
/* Favorite Buttons */
|
||
.fav-btn-card {
|
||
background: none; border: none; font-size: 14px; cursor: pointer;
|
||
padding: 0; transition: transform 0.12s; line-height: 1;
|
||
color: var(--text-muted);
|
||
min-width: 44px; min-height: 44px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
}
|
||
.fav-btn-card:hover { transform: scale(1.2); }
|
||
.fav-btn-card.is-fav { color: #f59e0b; }
|
||
.fav-btn-card:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||
|
||
.fav-btn-drawer {
|
||
background: none; border: 1px solid var(--border); padding: 2px 8px;
|
||
border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer;
|
||
transition: all 0.12s;
|
||
min-width: 44px; min-height: 44px;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
}
|
||
.fav-btn-drawer:hover { border-color: #f59e0b; color: #f59e0b; }
|
||
.fav-btn-drawer.is-fav { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.1); }
|
||
.fav-btn-drawer:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
</style>
|