feat: add persistent session favorites

This commit is contained in:
liujing
2026-07-20 15:17:08 +08:00
parent 5118e1f0ab
commit d49cc8a1c2
8 changed files with 709 additions and 53 deletions
+14 -1
View File
@@ -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)
+86 -2
View File
@@ -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>