通义千问3-VL-Reranker-8B前端集成:Vue3实现多模态搜索界面
通义千问3-VL-Reranker-8B前端集成:Vue3实现多模态搜索界面
想象一下,你正在开发一个电商应用,用户想找“一只在沙滩上玩耍的金毛犬”的图片。传统的搜索框只能匹配文字标签,但用户上传的图片、视频,甚至是一段描述性的文字,系统该如何理解并找到最相关的结果呢?
这就是多模态搜索要解决的问题。通义千问3-VL-Reranker-8B这类模型,能够同时理解文本、图片、视频等多种信息,并给出它们之间的相关性评分。今天,我们不聊复杂的模型部署和算法,而是聚焦于前端——如何用Vue3构建一个直观、好用的界面,让用户能轻松上传文件、查看搜索结果,并与这个强大的AI模型进行交互。
1. 项目准备与环境搭建
在开始写代码之前,我们需要先把项目的基础框架搭起来。这里我选择Vue3的组合式API,因为它写起来更灵活,逻辑组织也更清晰。
1.1 创建Vue3项目
打开你的终端,用Vite快速创建一个新项目。Vite的启动速度很快,开发体验也不错。
npm create vue@latest
创建过程中,你可以按需选择需要的功能。对于这个项目,我建议勾选:
- TypeScript(让代码更规范,减少错误)
- Vue Router(如果后续需要多页面的话)
- Pinia(状态管理,处理搜索状态和结果很方便)
项目创建好后,进入目录并安装依赖:
cd your-project-name
npm install
1.2 安装必要的依赖
除了Vue3本身,我们还需要一些额外的包来完善功能:
npm install axios element-plus
- axios:用来和后端API通信,发送搜索请求和上传文件。
- element-plus:一个基于Vue3的UI组件库,提供了现成的按钮、输入框、上传组件等,能帮我们快速搭建界面。
安装好后,在main.ts中引入element-plus的样式:
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
1.3 理解后端API接口
在动手写前端之前,得先知道后端提供了哪些接口。假设你的后端同事已经部署好了通义千问3-VL-Reranker-8B模型,并提供了两个关键接口:
-
文件上传接口 (
POST /api/upload)- 功能:接收用户上传的图片或视频文件
- 返回:文件在服务器上的存储路径或唯一标识
-
多模态搜索接口 (
POST /api/search)- 功能:接收搜索查询(文本、图片路径、视频路径的组合),返回相关性排序的结果
- 请求体示例:
{ "query": { "text": "一只在沙滩上玩耍的金毛犬", "image": "/uploads/dog_beach.jpg" }, "documents": [ {"text": "金毛犬在沙滩奔跑的照片"}, {"image": "/uploads/retriever_playing.jpg"}, {"video": "/uploads/beach_dog.mp4"} ] } - 返回:每个文档的相关性分数列表,如
[0.85, 0.92, 0.76]
了解清楚接口后,我们就可以开始设计前端的交互流程了。
2. 核心功能模块实现
一个完整的多模态搜索界面,可以拆解成几个相对独立的部分。我们先从最基础的文件上传开始。
2.1 文件上传组件
用户需要上传图片或视频作为搜索条件,或者作为待检索的文档库。用element-plus的el-upload组件,我们能很快实现这个功能。
我创建了一个FileUpload.vue组件:
<template>
<div class="file-upload">
<el-upload
class="upload-demo"
drag
:action="uploadUrl"
:headers="headers"
:on-success="handleSuccess"
:on-error="handleError"
:before-upload="beforeUpload"
:multiple="multiple"
:accept="acceptTypes"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持上传 {{ acceptTypes }} 文件,单个文件不超过10MB
</div>
</template>
</el-upload>
<!-- 上传文件列表展示 -->
<div v-if="fileList.length > 0" class="file-list">
<h4>已上传文件:</h4>
<div v-for="file in fileList" :key="file.url" class="file-item">
<el-image
v-if="isImage(file.type)"
:src="file.url"
:preview-src-list="[file.url]"
fit="cover"
/>
<video v-else-if="isVideo(file.type)" controls>
<source :src="file.url" :type="file.type">
</video>
<div class="file-info">
<span>{{ file.name }}</span>
<el-button type="danger" size="small" @click="removeFile(file)">
删除
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
interface UploadedFile {
name: string
url: string
type: string
}
const props = defineProps<{
accept?: string // 接受的文件类型,如 'image/*,video/*'
multiple?: boolean // 是否支持多选
}>()
const emit = defineEmits<{
'update:files': [files: UploadedFile[]]
}>()
const uploadUrl = '/api/upload' // 你的上传接口地址
const headers = { 'Authorization': 'Bearer your-token' } // 如果需要认证
const fileList = ref<UploadedFile[]>([])
// 计算接受的类型,默认支持图片和视频
const acceptTypes = computed(() =>
props.accept || 'image/*,video/*'
)
// 上传前的校验
const beforeUpload = (file: File) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error('文件大小不能超过10MB')
return false
}
return true
}
// 上传成功处理
const handleSuccess = (response: any, file: File) => {
const newFile: UploadedFile = {
name: file.name,
url: response.data.url, // 假设后端返回文件URL
type: file.type
}
fileList.value.push(newFile)
emit('update:files', fileList.value)
ElMessage.success(`${file.name} 上传成功`)
}
// 上传失败处理
const handleError = (error: Error) => {
ElMessage.error(`上传失败: ${error.message}`)
}
// 删除文件
const removeFile = (file: UploadedFile) => {
const index = fileList.value.findIndex(f => f.url === file.url)
if (index > -1) {
fileList.value.splice(index, 1)
emit('update:files', fileList.value)
}
}
// 判断文件类型
const isImage = (type: string) => type.startsWith('image/')
const isVideo = (type: string) => type.startsWith('video/')
</script>
<style scoped>
.file-upload {
width: 100%;
}
.upload-demo {
width: 100%;
}
.file-list {
margin-top: 20px;
}
.file-item {
display: flex;
align-items: center;
margin-bottom: 10px;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
}
.file-item .el-image,
.file-item video {
width: 100px;
height: 100px;
object-fit: cover;
margin-right: 15px;
}
.file-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
这个组件做了几件事:提供了拖拽上传的交互、限制了文件大小和类型、上传后能预览图片和视频、还能删除已上传的文件。你可以根据实际的后端接口调整上传逻辑。
2.2 搜索输入与条件组合
多模态搜索的特别之处在于,查询条件可以是多种形式的组合。用户可能只想用文字搜索,也可能想“用图片找相似的图片”,或者“用一段描述文字配上参考图”来搜索。
我在SearchInput.vue组件里实现了这个功能:
<template>
<div class="search-input">
<!-- 文本输入 -->
<div class="input-section">
<label>文本描述:</label>
<el-input
v-model="textQuery"
type="textarea"
:rows="3"
placeholder="请输入搜索描述,例如:一只在沙滩上玩耍的金毛犬"
@input="handleChange"
/>
</div>
<!-- 图片上传作为查询条件 -->
<div class="input-section">
<label>参考图片:</label>
<FileUpload
accept="image/*"
:multiple="false"
@update:files="handleImageFiles"
/>
<div v-if="imageQuery" class="preview">
<el-image :src="imageQuery" fit="cover" />
<el-button type="text" @click="clearImage">清除</el-button>
</div>
</div>
<!-- 视频上传作为查询条件 -->
<div class="input-section">
<label>参考视频:</label>
<FileUpload
accept="video/*"
:multiple="false"
@update:files="handleVideoFiles"
/>
<div v-if="videoQuery" class="preview">
<video :src="videoQuery" controls width="200"></video>
<el-button type="text" @click="clearVideo">清除</el-button>
</div>
</div>
<!-- 搜索按钮 -->
<div class="actions">
<el-button type="primary" @click="handleSearch" :loading="loading">
开始搜索
</el-button>
<el-button @click="handleReset">重置条件</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FileUpload from './FileUpload.vue'
interface SearchQuery {
text?: string
image?: string
video?: string
}
const emit = defineEmits<{
search: [query: SearchQuery]
}>()
const textQuery = ref('')
const imageQuery = ref('')
const videoQuery = ref('')
const loading = ref(false)
// 处理图片文件更新
const handleImageFiles = (files: any[]) => {
imageQuery.value = files.length > 0 ? files[0].url : ''
emitChange()
}
// 处理视频文件更新
const handleVideoFiles = (files: any[]) => {
videoQuery.value = files.length > 0 ? files[0].url : ''
emitChange()
}
// 清除图片
const clearImage = () => {
imageQuery.value = ''
emitChange()
}
// 清除视频
const clearVideo = () => {
videoQuery.value = ''
emitChange()
}
// 触发搜索条件变化
const emitChange = () => {
const query: SearchQuery = {}
if (textQuery.value.trim()) query.text = textQuery.value.trim()
if (imageQuery.value) query.image = imageQuery.value
if (videoQuery.value) query.video = videoQuery.value
// 这里可以触发一个事件,让父组件知道查询条件变化了
}
// 执行搜索
const handleSearch = async () => {
const query: SearchQuery = {}
if (textQuery.value.trim()) query.text = textQuery.value.trim()
if (imageQuery.value) query.image = imageQuery.value
if (videoQuery.value) query.video = videoQuery.value
// 至少需要一个查询条件
if (!query.text && !query.image && !query.video) {
ElMessage.warning('请至少输入一个搜索条件')
return
}
loading.value = true
try {
emit('search', query)
} finally {
loading.value = false
}
}
// 重置所有条件
const handleReset = () => {
textQuery.value = ''
imageQuery.value = ''
videoQuery.value = ''
emitChange()
}
</script>
<style scoped>
.search-input {
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.input-section {
margin-bottom: 20px;
}
.input-section label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.preview {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.preview .el-image,
.preview video {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 4px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
</style>
这个组件把文本输入、图片上传、视频上传整合在一起,用户可以根据需要组合使用。比如,他们可以只输入文字,也可以上传一张图片让系统找相似的,或者两者结合,让搜索更精准。
2.3 搜索结果展示组件
搜索完成后,我们需要把结果清晰地展示给用户。由于是多模态搜索,结果可能包含图片、视频、文字等多种形式,而且每个结果都有个相关性分数。
我设计了SearchResults.vue组件来展示这些结果:
<template>
<div class="search-results">
<!-- 结果统计 -->
<div class="results-header">
<h3>搜索结果 ({{ results.length }}个)</h3>
<div class="sort-controls">
<span>排序方式:</span>
<el-radio-group v-model="sortBy" @change="handleSort">
<el-radio label="score">相关性</el-radio>
<el-radio label="type">文件类型</el-radio>
</el-radio-group>
</div>
</div>
<!-- 结果列表 -->
<div class="results-grid">
<div
v-for="(result, index) in sortedResults"
:key="result.id || index"
class="result-card"
:class="{ 'highlight': result.score > 0.8 }"
>
<!-- 结果内容展示 -->
<div class="result-content">
<!-- 图片结果 -->
<div v-if="result.type === 'image'" class="media-container">
<el-image
:src="result.url"
:preview-src-list="previewImages"
fit="cover"
@click="handlePreview(result.url)"
/>
</div>
<!-- 视频结果 -->
<div v-else-if="result.type === 'video'" class="media-container">
<video :src="result.url" controls @click.stop></video>
</div>
<!-- 文本结果 -->
<div v-else class="text-container">
<div class="text-content">{{ result.content }}</div>
</div>
</div>
<!-- 结果信息 -->
<div class="result-info">
<div class="score-badge">
相关度:<span class="score-value">{{ result.score.toFixed(3) }}</span>
</div>
<div class="result-meta">
<span class="file-type">{{ result.type }}</span>
<span class="file-size" v-if="result.size">{{ formatSize(result.size) }}</span>
</div>
<div class="result-actions">
<el-button type="primary" size="small" @click="handleSelect(result)">
选择
</el-button>
<el-button size="small" @click="handleDetail(result)">
详情
</el-button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="results.length === 0" class="empty-state">
<el-empty description="暂无搜索结果" />
</div>
<!-- 分页 -->
<div v-if="results.length > pageSize" class="pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="results.length"
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
interface SearchResult {
id?: string
type: 'image' | 'video' | 'text'
url?: string
content?: string
score: number
size?: number
metadata?: Record<string, any>
}
const props = defineProps<{
results: SearchResult[]
}>()
const emit = defineEmits<{
select: [result: SearchResult]
detail: [result: SearchResult]
}>()
const sortBy = ref<'score' | 'type'>('score')
const currentPage = ref(1)
const pageSize = 12 // 每页显示12个结果
// 计算排序后的结果
const sortedResults = computed(() => {
const sorted = [...props.results]
if (sortBy.value === 'score') {
// 按相关性分数降序排列
return sorted.sort((a, b) => b.score - a.score)
} else {
// 按文件类型分组排列
return sorted.sort((a, b) => a.type.localeCompare(b.type))
}
})
// 计算当前页显示的结果
const pagedResults = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return sortedResults.value.slice(start, end)
})
// 获取所有图片URL用于预览
const previewImages = computed(() => {
return props.results
.filter(r => r.type === 'image' && r.url)
.map(r => r.url!) as string[]
})
// 处理排序变化
const handleSort = () => {
currentPage.value = 1 // 排序后回到第一页
}
// 处理分页变化
const handlePageChange = (page: number) => {
currentPage.value = page
}
// 处理选择结果
const handleSelect = (result: SearchResult) => {
emit('select', result)
ElMessage.success('已选择该结果')
}
// 处理查看详情
const handleDetail = (result: SearchResult) => {
emit('detail', result)
}
// 处理图片预览
const handlePreview = (url: string) => {
// element-plus的el-image组件已经内置了预览功能
// 这里可以添加额外的逻辑,比如记录预览历史
console.log('预览图片:', url)
}
// 格式化文件大小
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.search-results {
width: 100%;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.sort-controls {
display: flex;
align-items: center;
gap: 10px;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.result-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
background: white;
}
.result-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.result-card.highlight {
border-color: #409eff;
background-color: #f0f9ff;
}
.media-container {
width: 100%;
height: 200px;
overflow: hidden;
}
.media-container .el-image,
.media-container video {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.text-container {
padding: 20px;
height: 200px;
overflow: hidden;
}
.text-content {
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-info {
padding: 12px;
background: #f9f9f9;
border-top: 1px solid #eee;
}
.score-badge {
margin-bottom: 8px;
font-size: 14px;
}
.score-value {
font-weight: bold;
color: #409eff;
}
.result-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 10px;
}
.result-actions {
display: flex;
gap: 8px;
}
.empty-state {
padding: 60px 0;
text-align: center;
}
.pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
</style>
这个组件有几个亮点:结果可以按相关性或文件类型排序、高相关性的结果会有特殊标记、图片可以点击预览、视频可以直接播放,而且做了分页处理,避免一次加载太多内容导致页面卡顿。
2.4 与后端API通信
前面几个组件负责界面展示和用户交互,现在我们需要一个专门处理数据通信的模块。我创建了一个searchService.ts文件:
import axios from 'axios'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
timeout: 30000, // 30秒超时
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器,可以在这里添加token等
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器,统一处理错误
api.interceptors.response.use(
response => response.data,
error => {
console.error('API请求错误:', error)
if (error.response) {
// 服务器返回了错误状态码
const { status, data } = error.response
switch (status) {
case 401:
console.error('未授权,请重新登录')
break
case 403:
console.error('权限不足')
break
case 404:
console.error('接口不存在')
break
case 500:
console.error('服务器内部错误')
break
default:
console.error(`请求失败: ${status}`)
}
return Promise.reject(data?.message || '请求失败')
} else if (error.request) {
// 请求发送了但没有收到响应
console.error('网络错误,请检查网络连接')
return Promise.reject('网络错误')
} else {
// 请求配置出错
console.error('请求配置错误:', error.message)
return Promise.reject(error.message)
}
}
)
// 文件上传接口
export const uploadFile = async (file: File): Promise<{ url: string }> => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response
}
// 多模态搜索接口
export interface SearchQuery {
text?: string
image?: string
video?: string
}
export interface SearchDocument {
text?: string
image?: string
video?: string
}
export interface SearchRequest {
query: SearchQuery
documents: SearchDocument[]
instruction?: string // 可选的指令,如"Retrieval relevant image or text"
}
export interface SearchResponse {
scores: number[]
documents: Array<{
type: 'text' | 'image' | 'video'
content?: string
url?: string
metadata?: Record<string, any>
}>
}
export const searchMultimodal = async (
request: SearchRequest
): Promise<SearchResponse> => {
const response = await api.post('/search', request)
return response
}
// 批量搜索接口(如果有的话)
export const batchSearch = async (
queries: SearchRequest[]
): Promise<SearchResponse[]> => {
const response = await api.post('/search/batch', queries)
return response
}
// 获取搜索历史
export const getSearchHistory = async (): Promise<Array<{
id: string
query: SearchQuery
timestamp: string
resultCount: number
}>> => {
const response = await api.get('/search/history')
return response
}
export default api
这个服务模块把所有的API调用都封装在了一起,好处是:
- 统一管理:所有接口地址都在一个地方维护
- 错误处理:统一的错误拦截和提示
- 类型安全:用TypeScript定义了所有接口的输入输出类型
- 易于维护:如果需要换后端接口,只需要改这个文件
3. 完整页面集成与状态管理
现在我们把各个组件组合起来,创建一个完整的搜索页面。这里我使用Pinia来管理全局状态,比如搜索历史、用户偏好设置等。
3.1 创建状态管理Store
首先创建一个stores/searchStore.ts:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SearchQuery, SearchResponse } from '@/services/searchService'
interface SearchHistoryItem {
id: string
query: SearchQuery
timestamp: Date
results: SearchResponse
}
export const useSearchStore = defineStore('search', () => {
// 状态
const searchHistory = ref<SearchHistoryItem[]>([])
const currentQuery = ref<SearchQuery>({})
const currentResults = ref<SearchResponse | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// 计算属性
const recentSearches = computed(() => {
return searchHistory.value
.slice()
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 10) // 只显示最近10条
})
const totalSearches = computed(() => searchHistory.value.length)
// 动作
const executeSearch = async (query: SearchQuery) => {
isLoading.value = true
error.value = null
currentQuery.value = query
try {
// 这里调用实际的搜索API
// const response = await searchMultimodal({ query, documents: [...] })
// currentResults.value = response
// 模拟搜索结果
await new Promise(resolve => setTimeout(resolve, 1000))
const mockResults: SearchResponse = {
scores: [0.92, 0.85, 0.76, 0.68, 0.61],
documents: [
{ type: 'image', url: 'https://example.com/image1.jpg' },
{ type: 'video', url: 'https://example.com/video1.mp4' },
{ type: 'text', content: '这是一段相关的文本描述...' },
{ type: 'image', url: 'https://example.com/image2.jpg' },
{ type: 'text', content: '另一段相关的文本内容...' }
]
}
currentResults.value = mockResults
// 保存到历史记录
const historyItem: SearchHistoryItem = {
id: Date.now().toString(),
query,
timestamp: new Date(),
results: mockResults
}
searchHistory.value.push(historyItem)
// 保持历史记录不超过50条
if (searchHistory.value.length > 50) {
searchHistory.value.shift()
}
} catch (err: any) {
error.value = err.message || '搜索失败'
console.error('搜索错误:', err)
} finally {
isLoading.value = false
}
}
const clearHistory = () => {
searchHistory.value = []
}
const clearError = () => {
error.value = null
}
return {
// 状态
searchHistory,
currentQuery,
currentResults,
isLoading,
error,
// 计算属性
recentSearches,
totalSearches,
// 动作
executeSearch,
clearHistory,
clearError
}
})
3.2 集成完整搜索页面
现在创建主页面SearchPage.vue,把所有组件组合起来:
<template>
<div class="search-page">
<!-- 页面标题 -->
<div class="page-header">
<h1>多模态智能搜索</h1>
<p class="subtitle">支持文本、图片、视频混合搜索,基于通义千问3-VL-Reranker-8B模型</p>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 左侧:搜索条件 -->
<div class="left-panel">
<SearchInput
@search="handleSearch"
class="search-input-container"
/>
<!-- 搜索历史 -->
<div v-if="store.recentSearches.length > 0" class="search-history">
<h3>最近搜索</h3>
<div class="history-list">
<div
v-for="item in store.recentSearches"
:key="item.id"
class="history-item"
@click="handleHistoryClick(item)"
>
<div class="history-query">
<span v-if="item.query.text" class="text-query">
{{ truncateText(item.query.text, 30) }}
</span>
<span v-if="item.query.image" class="image-query">
<el-icon><Picture /></el-icon>
</span>
<span v-if="item.query.video" class="video-query">
<el-icon><VideoCamera /></el-icon>
</span>
</div>
<div class="history-time">
{{ formatTime(item.timestamp) }}
</div>
</div>
</div>
<el-button type="text" @click="store.clearHistory">
清空历史
</el-button>
</div>
</div>
<!-- 右侧:搜索结果 -->
<div class="right-panel">
<!-- 加载状态 -->
<div v-if="store.isLoading" class="loading-state">
<el-skeleton :rows="6" animated />
</div>
<!-- 错误状态 -->
<div v-else-if="store.error" class="error-state">
<el-alert
:title="store.error"
type="error"
show-icon
@close="store.clearError"
/>
<el-button type="primary" @click="retrySearch">
重试
</el-button>
</div>
<!-- 正常状态 -->
<div v-else>
<!-- 搜索结果 -->
<SearchResults
v-if="store.currentResults"
:results="formatResults(store.currentResults)"
@select="handleResultSelect"
@detail="handleResultDetail"
class="search-results-container"
/>
<!-- 初始状态提示 -->
<div v-else class="initial-state">
<el-empty description="请输入搜索条件开始搜索">
<template #image>
<el-icon size="80"><Search /></el-icon>
</template>
<p class="tips">
提示:<br>
1. 可以输入文字描述进行搜索<br>
2. 可以上传图片搜索相似内容<br>
3. 可以上传视频搜索相关片段<br>
4. 可以组合多种条件进行精确搜索
</p>
</el-empty>
</div>
</div>
</div>
</div>
<!-- 结果详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="结果详情"
width="60%"
>
<ResultDetail
v-if="selectedResult"
:result="selectedResult"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Picture, VideoCamera } from '@element-plus/icons-vue'
import SearchInput from '@/components/SearchInput.vue'
import SearchResults from '@/components/SearchResults.vue'
import ResultDetail from '@/components/ResultDetail.vue'
import { useSearchStore } from '@/stores/searchStore'
import type { SearchQuery, SearchResponse } from '@/services/searchService'
const store = useSearchStore()
const detailDialogVisible = ref(false)
const selectedResult = ref<any>(null)
// 处理搜索
const handleSearch = async (query: SearchQuery) => {
await store.executeSearch(query)
if (store.error) {
ElMessage.error(`搜索失败: ${store.error}`)
} else if (store.currentResults) {
ElMessage.success(`找到 ${store.currentResults.scores.length} 个结果`)
}
}
// 处理历史记录点击
const handleHistoryClick = (historyItem: any) => {
store.currentQuery.value = historyItem.query
store.currentResults.value = historyItem.results
}
// 处理结果选择
const handleResultSelect = (result: any) => {
console.log('选择结果:', result)
// 这里可以触发下载、收藏等操作
}
// 处理结果详情
const handleResultDetail = (result: any) => {
selectedResult.value = result
detailDialogVisible.value = true
}
// 重试搜索
const retrySearch = () => {
if (store.currentQuery.value) {
handleSearch(store.currentQuery.value)
}
}
// 格式化结果用于展示
const formatResults = (response: SearchResponse) => {
return response.scores.map((score, index) => {
const doc = response.documents[index]
return {
...doc,
score,
id: `result-${index}-${Date.now()}`
}
})
}
// 截断文本
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
// 格式化时间
const formatTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return date.toLocaleDateString()
}
</script>
<style scoped>
.search-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.page-header h1 {
margin: 0;
color: #303133;
font-size: 2.5rem;
}
.subtitle {
margin-top: 10px;
color: #606266;
font-size: 1.1rem;
}
.main-content {
display: grid;
grid-template-columns: 350px 1fr;
gap: 30px;
max-width: 1600px;
margin: 0 auto;
}
.left-panel {
display: flex;
flex-direction: column;
gap: 30px;
}
.search-input-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.search-history {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.search-history h3 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
}
.history-list {
max-height: 400px;
overflow-y: auto;
margin-bottom: 15px;
}
.history-item {
padding: 12px;
border: 1px solid #ebeef5;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.history-item:hover {
background-color: #f5f7fa;
border-color: #409eff;
}
.history-query {
display: flex;
align-items: center;
gap: 8px;
}
.text-query {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-query,
.video-query {
color: #409eff;
}
.history-time {
font-size: 12px;
color: #909399;
}
.right-panel {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
min-height: 600px;
}
.loading-state,
.error-state,
.initial-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 600px;
}
.error-state {
gap: 20px;
}
.tips {
margin-top: 20px;
text-align: left;
color: #606266;
line-height: 1.8;
}
.search-results-container {
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 1fr;
}
.left-panel {
order: 2;
}
.right-panel {
order: 1;
}
}
@media (max-width: 768px) {
.page-header h1 {
font-size: 2rem;
}
.main-content {
gap: 20px;
}
.left-panel,
.right-panel {
padding: 15px;
}
}
</style>
这个页面把之前的所有组件都整合在了一起,形成了一个完整的多模态搜索应用。左侧是搜索条件和历史记录,右侧是搜索结果展示。页面还做了响应式设计,在手机和平板上也能正常使用。
4. 优化与进阶功能
基础功能完成后,我们可以考虑添加一些进阶功能来提升用户体验。
4.1 搜索结果可视化增强
对于多模态搜索结果,我们可以用更直观的方式展示相关性。比如,用热力图展示图片中与查询最相关的区域,或者用时间轴展示视频中的关键帧。
我创建了一个ResultVisualization.vue组件:
<template>
<div class="visualization">
<!-- 相关性分数分布图 -->
<div v-if="scores.length > 0" class="score-distribution">
<h4>相关性分数分布</h4>
<div class="distribution-chart">
<div
v-for="(score, index) in normalizedScores"
:key="index"
class="score-bar"
:style="{ height: `${score * 100}%` }"
:title="`分数: ${scores[index].toFixed(3)}`"
>
<span class="bar-label">{{ index + 1 }}</span>
</div>
</div>
</div>
<!-- 多模态结果对比 -->
<div v-if="results.length > 1" class="comparison-view">
<h4>结果对比</h4>
<div class="comparison-grid">
<div
v-for="result in topResults"
:key="result.id"
class="comparison-item"
>
<div class="comparison-media">
<!-- 根据类型显示不同内容 -->
<template v-if="result.type === 'image'">
<el-image :src="result.url" fit="cover" />
</template>
<template v-else-if="result.type === 'video'">
<video :src="result.url" controls></video>
</template>
<template v-else>
<div class="text-preview">{{ result.content }}</div>
</template>
</div>
<div class="comparison-score">
<div class="score-circle" :style="getScoreStyle(result.score)">
{{ (result.score * 100).toFixed(1) }}%
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface VisualizationResult {
id: string
type: 'image' | 'video' | 'text'
url?: string
content?: string
score: number
}
const props = defineProps<{
results: VisualizationResult[]
}>()
// 归一化分数用于可视化
const normalizedScores = computed(() => {
const scores = props.results.map(r => r.score)
const maxScore = Math.max(...scores)
const minScore = Math.min(...scores)
if (maxScore === minScore) {
return scores.map(() => 0.5) // 所有分数相等时显示50%高度
}
return scores.map(score => (score - minScore) / (maxScore - minScore))
})
// 获取前几个结果用于对比
const topResults = computed(() => {
return [...props.results]
.sort((a, b) => b.score - a.score)
.slice(0, 4) // 只显示前4个
})
// 根据分数获取样式
const getScoreStyle = (score: number) => {
const hue = score * 120 // 0-120,从红色到绿色
return {
backgroundColor: `hsl(${hue}, 70%, 50%)`,
color: score > 0.5 ? 'white' : '#333'
}
}
</script>
<style scoped>
.visualization {
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
margin-top: 20px;
}
.score-distribution {
margin-bottom: 30px;
}
.score-distribution h4 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
}
.distribution-chart {
display: flex;
align-items: flex-end;
height: 200px;
gap: 10px;
padding: 20px;
background: white;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.score-bar {
flex: 1;
background: linear-gradient(to top, #409eff, #79bbff);
border-radius: 4px 4px 0 0;
position: relative;
min-height: 20px;
transition: height 0.3s ease;
}
.score-bar:hover {
opacity: 0.8;
}
.bar-label {
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #606266;
}
.comparison-view h4 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
}
.comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.comparison-item {
background: white;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ebeef5;
transition: transform 0.3s ease;
}
.comparison-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.comparison-media {
height: 150px;
overflow: hidden;
}
.comparison-media .el-image,
.comparison-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.text-preview {
padding: 20px;
height: 150px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
.comparison-score {
padding: 15px;
text-align: center;
border-top: 1px solid #ebeef5;
}
.score-circle {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
margin: 0 auto;
transition: all 0.3s ease;
}
</style>
4.2 性能优化建议
在实际使用中,可能会遇到一些性能问题。这里分享几个优化经验:
- 图片懒加载:搜索结果中的图片很多时,可以用懒加载技术,只加载可视区域内的图片。
<!-- 在SearchResults组件中使用懒加载 -->
<el-image
:src="result.url"
lazy
:preview-src-list="previewImages"
fit="cover"
/>
- 虚拟滚动:如果搜索结果非常多(比如上千条),可以用虚拟滚动技术,只渲染可视区域内的元素。
<!-- 使用vue-virtual-scroller等库 -->
<RecycleScroller
:items="results"
:item-size="300"
key-field="id"
>
<template #default="{ item }">
<ResultCard :result="item" />
</template>
</RecycleScroller>
- 请求防抖:如果搜索输入框有实时搜索功能,记得加防抖,避免频繁请求。
import { debounce } from 'lodash-es'
const search = debounce(async (query: string) => {
// 搜索逻辑
}, 300) // 300毫秒延迟
- 结果缓存:相同的搜索条件可以缓存结果,减少不必要的请求。
// 在searchStore中添加缓存
const searchCache = ref<Map<string, SearchResponse>>(new Map())
const executeSearch = async (query: SearchQuery) => {
const cacheKey = JSON.stringify(query)
// 检查缓存
if (searchCache.value.has(cacheKey)) {
currentResults.value = searchCache.value.get(cacheKey)!
return
}
// 执行搜索...
// 搜索完成后存入缓存
searchCache.value.set(cacheKey, response)
}
4.3 错误处理与用户反馈
好的错误处理能让用户体验更好。除了基本的错误提示,我们还可以:
- 提供重试机制:网络错误时让用户一键重试。
- 保存草稿:用户输入的内容意外丢失时能恢复。
- 进度提示:长时间操作时显示进度条。
<!-- 在SearchInput组件中添加自动保存 -->
<script setup>
import { watch, onMounted } from 'vue'
// 自动保存搜索条件
watch([textQuery, imageQuery, videoQuery], () => {
const draft = {
text: textQuery.value,
image: imageQuery.value,
video: videoQuery.value,
timestamp: Date.now()
}
localStorage.setItem('search_draft', JSON.stringify(draft))
}, { deep: true })
// 恢复草稿
onMounted(() => {
const draft = localStorage.getItem('search_draft')
if (draft) {
const parsed = JSON.parse(draft)
// 检查草稿是否过期(比如超过1小时)
if (Date.now() - parsed.timestamp < 3600000) {
textQuery.value = parsed.text || ''
imageQuery.value = parsed.image || ''
videoQuery.value = parsed.video || ''
}
}
})
</script>
5. 总结
通过这篇文章,我们从头到尾实现了一个基于Vue3的多模态搜索界面。从最基础的文件上传,到复杂的条件组合搜索,再到结果的可视化展示,我们一步步构建了一个完整、实用的前端应用。
实际用下来,Vue3的组合式API确实让代码组织更灵活,特别是处理这种有复杂状态交互的场景。Element Plus的组件库也帮我们省去了很多重复的UI开发工作。
当然,这只是个起点。在实际项目中,你可能还需要考虑更多细节,比如用户权限管理、搜索结果的个性化排序、多语言支持等等。但有了这个基础框架,后续的扩展就会容易很多。
如果你正在做类似的多模态搜索项目,建议先从最简单的文本搜索开始,逐步添加图片、视频等复杂功能。每加一个功能,都充分测试,确保用户体验流畅。遇到性能问题时,再用上面提到的优化方法逐个解决。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐

所有评论(0)