feat: add persistent session favorites
This commit is contained in:
@@ -30,7 +30,7 @@ export async function getHealth() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSessions({ engine = 'all', query, status, from, to, limit, offset } = {}) {
|
||||
export async function getSessions({ engine = 'all', query, status, from, to, limit, offset, favorite } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (engine) params.set('engine', engine)
|
||||
if (query) params.set('query', query)
|
||||
@@ -39,6 +39,7 @@ export async function getSessions({ engine = 'all', query, status, from, to, lim
|
||||
if (to) params.set('to', String(to))
|
||||
if (limit) params.set('limit', String(limit))
|
||||
if (offset) params.set('offset', String(offset))
|
||||
if (favorite) params.set('favorite', String(favorite))
|
||||
const qs = params.toString()
|
||||
return request(`/sessions${qs ? '?' + qs : ''}`)
|
||||
}
|
||||
@@ -93,6 +94,18 @@ export async function deleteSession(engine, sessionId) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function favoriteSession(engine, sessionId) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/favorite`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
export async function unfavoriteSession(engine, sessionId) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/favorite`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts)
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<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 -->
|
||||
@@ -43,7 +47,7 @@
|
||||
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="!displayedSessions.length" class="empty-state">
|
||||
<p>无符合当前引擎或状态过滤条件的会话实例。</p>
|
||||
<p>{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}</p>
|
||||
</div>
|
||||
<div v-else class="card-grid">
|
||||
<div
|
||||
@@ -64,6 +68,15 @@
|
||||
</span>
|
||||
<span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span>
|
||||
<button class="name-btn-card" @click.stop="openNameEditor(s)" title="自定义名称">✏️</button>
|
||||
<button
|
||||
class="fav-btn-card"
|
||||
:class="{ 'is-fav': s.is_favorite }"
|
||||
:disabled="favoritePending"
|
||||
@click.stop="toggleFavorite(s)"
|
||||
:title="s.is_favorite ? '取消收藏' : '收藏'"
|
||||
:aria-label="s.is_favorite ? '取消收藏此会话' : '收藏此会话'"
|
||||
:aria-pressed="String(s.is_favorite)"
|
||||
>{{ s.is_favorite ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
|
||||
<div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div>
|
||||
@@ -121,6 +134,15 @@
|
||||
<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="favoritePending"
|
||||
@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>
|
||||
|
||||
@@ -226,7 +248,7 @@
|
||||
<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 } from '../api/index.js'
|
||||
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' } })
|
||||
@@ -253,6 +275,9 @@ const statusOpts = [
|
||||
{ key: 'failed', label: '已中断' },
|
||||
]
|
||||
|
||||
const favoriteOnly = ref(false)
|
||||
const favoritePending = ref(false) // global lock: one favorite mutation at a time
|
||||
|
||||
const engineDist = computed(() => {
|
||||
const dist = {}
|
||||
for (const s of sessions.value) {
|
||||
@@ -324,6 +349,7 @@ async function loadSessions() {
|
||||
status: apiStatus,
|
||||
limit: pageSize,
|
||||
offset: (page.value - 1) * pageSize,
|
||||
favorite: favoriteOnly.value ? '1' : undefined,
|
||||
})
|
||||
sessions.value = data.sessions || []
|
||||
total.value = data.total || 0
|
||||
@@ -467,7 +493,38 @@ async function generateDrawerTitle() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFavorite(s) {
|
||||
if (!s || favoritePending.value) return
|
||||
favoritePending.value = 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
|
||||
}
|
||||
page.value = 1
|
||||
return loadSessions()
|
||||
})
|
||||
.catch((err) => {
|
||||
ElMessage.error('收藏操作失败: ' + (err.message || ''))
|
||||
})
|
||||
.finally(() => {
|
||||
favoritePending.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function onFavoriteOnlyChange() {
|
||||
favoriteOnly.value = !favoriteOnly.value
|
||||
}
|
||||
|
||||
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
|
||||
watch(favoriteOnly, () => { page.value = 1; loadSessions() })
|
||||
onMounted(loadSessions)
|
||||
</script>
|
||||
|
||||
@@ -497,6 +554,7 @@ onMounted(loadSessions)
|
||||
.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 { }
|
||||
@@ -642,6 +700,9 @@ onMounted(loadSessions)
|
||||
@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-meta { width: 100%; justify-content: space-around; }
|
||||
.drawer { max-width: 100%; }
|
||||
}
|
||||
@@ -668,4 +729,27 @@ onMounted(loadSessions)
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user