内容: 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
719 lines
17 KiB
Markdown
719 lines
17 KiB
Markdown
# Studio Vue 3 版本 - Agent Search 分页实施指南
|
||
|
||
**版本**: 1.0
|
||
**日期**: 2026-10-11
|
||
**技术栈**: Vue 3 + TypeScript + Tauri 2
|
||
|
||
---
|
||
|
||
## 🎯 实施目标
|
||
|
||
**补充缺失功能**:
|
||
1. ✅ More 按钮 + 分页加载
|
||
2. ✅ Session 管理
|
||
3. ⚠️ 智能意图识别(可选)
|
||
|
||
---
|
||
|
||
## 📋 文件修改清单
|
||
|
||
### 需要修改的文件
|
||
|
||
| 文件 | 修改内容 | 优先级 |
|
||
|------|---------|:------:|
|
||
| `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">`
|
||
|
||
```typescript
|
||
// === 新增状态 ===
|
||
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 修改现有搜索函数
|
||
|
||
**当前代码**:
|
||
```typescript
|
||
// 当前一次性加载所有结果
|
||
const pageSize = 20
|
||
```
|
||
|
||
**修改为**:
|
||
```typescript
|
||
// 初始加载改为 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 函数
|
||
|
||
```typescript
|
||
/**
|
||
* 加载更多结果(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>` 中结果列表之后
|
||
|
||
```vue
|
||
<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 样式
|
||
|
||
```vue
|
||
<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**:
|
||
|
||
```typescript
|
||
// === 新增: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`
|
||
|
||
```typescript
|
||
// === 新增类型定义 ===
|
||
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 临时方案**:
|
||
|
||
```typescript
|
||
// 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 完整修改
|
||
|
||
```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 实施方案完成!只需修改一个文件即可实现分页功能。** |