通义千问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模型,并提供了两个关键接口:

  1. 文件上传接口 (POST /api/upload)

    • 功能:接收用户上传的图片或视频文件
    • 返回:文件在服务器上的存储路径或唯一标识
  2. 多模态搜索接口 (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调用都封装在了一起,好处是:

  1. 统一管理:所有接口地址都在一个地方维护
  2. 错误处理:统一的错误拦截和提示
  3. 类型安全:用TypeScript定义了所有接口的输入输出类型
  4. 易于维护:如果需要换后端接口,只需要改这个文件

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 性能优化建议

在实际使用中,可能会遇到一些性能问题。这里分享几个优化经验:

  1. 图片懒加载:搜索结果中的图片很多时,可以用懒加载技术,只加载可视区域内的图片。
<!-- 在SearchResults组件中使用懒加载 -->
<el-image
  :src="result.url"
  lazy
  :preview-src-list="previewImages"
  fit="cover"
/>
  1. 虚拟滚动:如果搜索结果非常多(比如上千条),可以用虚拟滚动技术,只渲染可视区域内的元素。
<!-- 使用vue-virtual-scroller等库 -->
<RecycleScroller
  :items="results"
  :item-size="300"
  key-field="id"
>
  <template #default="{ item }">
    <ResultCard :result="item" />
  </template>
</RecycleScroller>
  1. 请求防抖:如果搜索输入框有实时搜索功能,记得加防抖,避免频繁请求。
import { debounce } from 'lodash-es'

const search = debounce(async (query: string) => {
  // 搜索逻辑
}, 300) // 300毫秒延迟
  1. 结果缓存:相同的搜索条件可以缓存结果,减少不必要的请求。
// 在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 错误处理与用户反馈

好的错误处理能让用户体验更好。除了基本的错误提示,我们还可以:

  1. 提供重试机制:网络错误时让用户一键重试。
  2. 保存草稿:用户输入的内容意外丢失时能恢复。
  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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐