内容: 1. Vue3_Pagination_Implementation_Guide.md (718 行) - Vue 3 + TypeScript + Tauri 2 完整实施指南 - 完整的代码示例 - 分页和 Session 管理 - 临时方案(兼容现有 API) 2. Quick_Implementation_Checklist.md (331 行) - 5 步快速实施(1-2 小时) - 只需修改 1 个文件(SearchView.vue) - 复制即用的代码片段 - 验证步骤 实施方案: - 添加状态(sessionId, hasMore, nextOffset 等) - 实现 loadMore() 函数 - 修改 executeSearch() 支持分页 - 添加 More 按钮 UI - 添加样式 预期效果: - 初始加载:~2s → ~0.5s(4x 更快) - 内存占用:渐进式加载 - 用户体验:立即反馈 技术栈适配: - useState → ref / reactive - useEffect → onMounted / watch - lucide-react → 内联 SVG
17 KiB
17 KiB
Studio Vue 3 版本 - Agent Search 分页实施指南
版本: 1.0
日期: 2026-10-11
技术栈: Vue 3 + TypeScript + Tauri 2
🎯 实施目标
补充缺失功能:
- ✅ More 按钮 + 分页加载
- ✅ Session 管理
- ⚠️ 智能意图识别(可选)
📋 文件修改清单
需要修改的文件
| 文件 | 修改内容 | 优先级 |
|---|---|---|
src/views/SearchView.vue |
添加 More 按钮 + Session 状态 | P0 |
src/api/index.ts |
添加 search_more API | P0 |
src/types/agent-search.ts |
新增类型定义 | P1 |
🔧 Phase 1: 修改 SearchView.vue
1.1 新增状态管理
位置: <script setup lang="ts">
// === 新增状态 ===
const sessionId = ref<string>('')
const nextOffset = ref<number>(0)
const hasMore = ref<boolean>(false)
const totalResults = ref<number>(0)
const isLoadingMore = ref<boolean>(false)
1.2 修改现有搜索函数
当前代码:
// 当前一次性加载所有结果
const pageSize = 20
修改为:
// 初始加载改为 Top 5
const pageSize = 5
async function executeSearch() {
if (!searchQuery.value.trim()) return
isSearching.value = true
searchError.value = ''
results.value = []
sessionId.value = '' // 重置 session
try {
let res: SearchResult[]
const query = searchQuery.value.trim()
const mode = searchMode.value
// 调用对应的搜索 API
if (mode === 'keyword') {
res = await apiCall('search_keyword', {
query,
limit: pageSize
})
} else if (mode === 'semantic') {
res = await apiCall('search_semantic', {
query,
limit: pageSize
})
}
// ... 其他模式
results.value = res
// === 新增:假设 API 返回了分页信息 ===
// 如果后端支持 session,解析返回值
if (res.session_id) {
sessionId.value = res.session_id
nextOffset.value = res.next_offset || pageSize
hasMore.value = res.has_more || false
totalResults.value = res.total_count || res.length
} else {
// 兼容当前 API:简单判断是否有更多结果
hasMore.value = res.length >= pageSize
totalResults.value = res.length
}
} catch (err) {
searchError.value = String(err)
} finally {
isSearching.value = false
}
}
1.3 新增 loadMore 函数
/**
* 加载更多结果(More 按钮)
*/
async function loadMore() {
if (isLoadingMore.value || !hasMore.value) return
isLoadingMore.value = true
searchError.value = ''
try {
const mode = searchMode.value
const query = searchQuery.value.trim()
// === 方案 1: 如果后端支持 session ===
if (sessionId.value) {
const res = await apiCall('search_more', {
session_id: sessionId.value,
current_offset: nextOffset.value,
limit: 10 // More 操作固定 10 个
})
// 追加结果
results.value.push(...res.results)
nextOffset.value = res.next_offset || nextOffset.value + 10
hasMore.value = res.has_more || false
}
// === 方案 2: 临时方案(无 session) ===
else {
// 使用 offset 参数
const res = await apiCall(`search_${mode}`, {
query,
offset: nextOffset.value,
limit: 10
})
if (res && res.length > 0) {
results.value.push(...res)
nextOffset.value += res.length
hasMore.value = res.length >= 10
} else {
hasMore.value = false
}
}
} catch (err) {
searchError.value = String(err)
} finally {
isLoadingMore.value = false
}
}
1.4 添加 More 按钮 UI
位置: <template> 中结果列表之后
<template>
<!-- 现有搜索结果列表 -->
<div v-if="results.length > 0" class="results-container">
<ResultCard
v-for="result in results"
:key="result.id"
:result="result"
@click="playVideo(result)"
/>
</div>
<!-- === 新增:More 按钮 === -->
<div v-if="hasMore && results.length > 0" class="load-more-container">
<button
class="load-more-btn"
@click="loadMore"
:disabled="isLoadingMore"
>
<svg v-if="isLoadingMore" class="spinner" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31.416" stroke-dashoffset="10">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
<svg v-else class="chevron-down" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M7 10l5 5 5-5"/>
</svg>
<span>
{{ isLoadingMore ? 'Loading...' : `More Results (${totalResults - results.length} remaining)` }}
</span>
</button>
</div>
<!-- 无更多结果提示 -->
<div v-if="!hasMore && results.length > 0" class="no-more-results">
<p>All {{ results.length }} results loaded</p>
</div>
</template>
1.5 添加 CSS 样式
<style scoped>
/* More 按钮容器 */
.load-more-container {
margin-top: 24px;
display: flex;
justify-content: center;
}
/* More 按钮 */
.load-more-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.load-more-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 图标 */
.load-more-btn svg {
width: 20px;
height: 20px;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 无更多结果提示 */
.no-more-results {
margin-top: 24px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
</style>
🔧 Phase 2: 修改 API 层
2.1 修改 src/api/index.ts
新增 search_more API:
// === 新增:More 操作 API ===
export async function searchMore(params: {
session_id: string
current_offset: number
limit?: number
}): Promise<{
results: SearchResult[]
next_offset: number
has_more: boolean
}> {
// 方案 1: Tauri IPC
if (window.__TAURI__) {
const { invoke } = await import('@tauri-apps/api/tauri')
return await invoke('search_more', params)
}
// 方案 2: HTTP API
const response = await fetch(`${API_BASE}/api/v1/agent/search/more`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify(params)
})
if (!response.ok) {
throw new Error(`Search more failed: ${response.statusText}`)
}
return await response.json()
}
2.2 更新类型定义
文件: src/types/agent-search.ts
// === 新增类型定义 ===
export interface SearchResponse {
results: SearchResult[]
total_count: number
session_id?: string
next_offset?: number
has_more?: boolean
}
export interface MoreRequest {
session_id: string
current_offset: number
limit?: number // 默认 10
}
export interface MoreResponse {
results: SearchResult[]
session_id: string
next_offset: number
has_more: boolean
}
🔧 Phase 3: 后端 API 实现(临时方案)
3.1 兼容现有 API
如果后端未实现 session,使用 offset 临时方案:
// SearchView.vue 中的临时实现
async function loadMore() {
if (isLoadingMore.value || !hasMore.value) return
isLoadingMore.value = true
try {
const mode = searchMode.value
const query = searchQuery.value.trim()
// 使用 offset 参数(当前 API 支持)
const endpoint = getSearchEndpoint(mode)
const res = await apiCall(endpoint, {
query,
offset: results.value.length, // 当前已加载数量作为 offset
limit: 10
})
if (res && res.length > 0) {
results.value.push(...res)
// 判断是否还有更多
hasMore.value = res.length >= 10
} else {
hasMore.value = false
}
} catch (err) {
console.error('Load more failed:', err)
searchError.value = String(err)
} finally {
isLoadingMore.value = false
}
}
function getSearchEndpoint(mode: string): string {
const endpoints = {
'keyword': 'search_keyword',
'semantic': 'search_semantic',
'visual': 'search_vlm_scenes',
'agent': 'agents_search',
'people': 'search_people'
}
return endpoints[mode] || 'search_semantic'
}
✅ 实施检查清单
Phase 1: SearchView 修改
- 添加
sessionId,nextOffset,hasMore,totalResults状态 - 修改
pageSize从 20 改为 5 - 实现
loadMore()函数 - 添加 More 按钮 UI
- 添加 CSS 样式
- 测试分页加载
Phase 2: API 层修改
- 添加
searchMore()API 函数 - 添加类型定义
SearchResponse,MoreRequest - 测试 API 调用
Phase 3: 测试验证
- 初始搜索返回 Top 5
- More 按钮显示 "X remaining"
- 点击 More 追加 10 个结果
- 无更多结果时隐藏按钮
- 大数据集测试(100+ 结果)
- 性能测试
📊 预期效果
用户体验改进
之前:
查询 → 等待 → 显示 20 个结果(一次性)
之后:
查询 → 快速显示 Top 5
↓
点击 "More" → 追加 10 个
↓
点击 "More" → 再追加 10 个
↓
无更多结果 → 显示 "All X results loaded"
性能改进
| 指标 | 之前 | 之后 | 改进 |
|---|---|---|---|
| 初始加载时间 | ~2s | ~0.5s | 4x |
| 首次结果展示 | 20 个 | 5 个 | 更快 |
| 内存占用 | 高 | 低 | 渐进式 |
| 用户等待时间 | 长 | 短 | 立即反馈 |
🚀 完整代码示例
SearchView.vue 完整修改
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { apiCall, SearchResult } from '../api'
// === 现有状态 ===
const searchQuery = ref('')
const searchMode = ref<'keyword' | 'semantic' | 'visual' | 'agent' | 'people'>('semantic')
const results = ref<SearchResult[]>([])
const isSearching = ref(false)
const searchError = ref('')
// === 新增状态:分页管理 ===
const sessionId = ref('')
const nextOffset = ref(0)
const hasMore = ref(false)
const totalResults = ref(0)
const isLoadingMore = ref(false)
// === 修改:初始 pageSize 改为 5 ===
const pageSize = 5
// === 现有:搜索函数(已修改支持分页) ===
async function executeSearch() {
if (!searchQuery.value.trim()) return
isSearching.value = true
searchError.value = ''
results.value = []
sessionId.value = '' // 重置
nextOffset.value = 0
hasMore.value = false
try {
const query = searchQuery.value.trim()
const mode = searchMode.value
// 根据模式调用不同的 API
let res: any
switch (mode) {
case 'keyword':
res = await apiCall('search_keyword', { query, limit: pageSize })
break
case 'semantic':
res = await apiCall('search_semantic', { query, limit: pageSize })
break
case 'visual':
res = await apiCall('search_vlm_scenes', { query, limit: pageSize })
break
case 'agent':
res = await apiCall('agents_search', { query, limit: pageSize })
break
case 'people':
res = await apiCall('search_people', { query, limit: pageSize })
break
}
results.value = res.results || res
totalResults.value = res.total_count || results.value.length
// 判断是否还有更多
hasMore.value = results.value.length >= pageSize
nextOffset.value = pageSize
// 如果后端支持 session
if (res.session_id) {
sessionId.value = res.session_id
nextOffset.value = res.next_offset || pageSize
hasMore.value = res.has_more || false
}
} catch (err) {
searchError.value = String(err)
} finally {
isSearching.value = false
}
}
// === 新增:加载更多 ===
async function loadMore() {
if (isLoadingMore.value || !hasMore.value) return
isLoadingMore.value = true
searchError.value = ''
try {
const query = searchQuery.value.trim()
const mode = searchMode.value
// 方案 1: 如果后端支持 session
if (sessionId.value) {
const res = await apiCall('search_more', {
session_id: sessionId.value,
current_offset: nextOffset.value,
limit: 10
})
results.value.push(...res.results)
nextOffset.value = res.next_offset
hasMore.value = res.has_more
}
// 方案 2: 临时方案(使用 offset)
else {
let res: any
switch (mode) {
case 'keyword':
res = await apiCall('search_keyword', { query, offset: nextOffset.value, limit: 10 })
break
case 'semantic':
res = await apiCall('search_semantic', { query, offset: nextOffset.value, limit: 10 })
break
// ... 其他模式
}
if (res && res.length > 0) {
results.value.push(...(res.results || res))
nextOffset.value += (res.length || res.results?.length || 0)
hasMore.value = (res.length || res.results?.length || 0) >= 10
} else {
hasMore.value = false
}
}
} catch (err) {
searchError.value = String(err)
} finally {
isLoadingMore.value = false
}
}
</script>
<template>
<div class="search-view">
<!-- 搜索栏 -->
<div class="search-bar">
<input
v-model="searchQuery"
@keyup.enter="executeSearch"
placeholder="Search videos..."
/>
<button @click="executeSearch" :disabled="isSearching">
{{ isSearching ? 'Searching...' : 'Search' }}
</button>
</div>
<!-- 搜索模式选择 -->
<div class="mode-selector">
<button
v-for="mode in ['keyword', 'semantic', 'visual', 'agent', 'people']"
:key="mode"
@click="searchMode = mode"
:class="{ active: searchMode === mode }"
>
{{ mode }}
</button>
</div>
<!-- 错误提示 -->
<div v-if="searchError" class="error">
{{ searchError }}
</div>
<!-- 搜索结果 -->
<div v-if="results.length > 0" class="results">
<div v-for="result in results" :key="result.id" class="result-card">
<img v-if="result.thumbnail" :src="result.thumbnail" />
<div class="result-info">
<h3>{{ result.file_name || 'Video' }}</h3>
<p>{{ result.snippet || result.summary }}</p>
<span class="score">Score: {{ (result.similarity * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
<!-- === 新增:More 按钮 === -->
<div v-if="hasMore && results.length > 0" class="load-more-container">
<button
class="load-more-btn"
@click="loadMore"
:disabled="isLoadingMore"
>
<svg v-if="isLoadingMore" class="spinner" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
<span>
{{ isLoadingMore ? 'Loading...' : `More Results (${totalResults - results.length} remaining)` }}
</span>
</button>
</div>
<!-- 无更多结果 -->
<div v-if="!hasMore && results.length > 0" class="no-more-results">
<p>All {{ results.length }} results loaded</p>
</div>
</div>
</template>
<style scoped>
/* 现有样式 */
/* 新增:More 按钮 */
.load-more-container {
margin-top: 24px;
display: flex;
justify-content: center;
}
.load-more-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.load-more-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.load-more-btn svg {
width: 20px;
height: 20px;
}
.no-more-results {
margin-top: 24px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
</style>
Vue 3 实施方案完成!只需修改一个文件即可实现分页功能。