Qwen3-VL:30B前端开发:Vue.js实现实时交互界面

1. 为什么需要一个能“看懂”图片的前端界面

你有没有遇到过这样的场景:上传一张产品截图,想立刻知道它属于哪个品类、有什么功能特点;或者把会议白板照片发给团队,希望系统自动提炼出待办事项;又或者在设计评审中,快速对比两张UI稿的差异点?这些需求背后,其实都指向同一个技术能力——多模态理解。

Qwen3-VL:30B作为当前性能突出的多模态大模型,不仅能读懂文字,更能理解图像内容。但光有强大的后端能力还不够,真正让技术落地的是那个你每天打开、点击、输入的前端界面。一个好用的前端,应该像一位默契的助手:你随手拖张图进去,它就安静地分析、思考、给出答案,整个过程自然得就像和人对话一样。

这篇文章不讲复杂的模型原理,也不堆砌部署命令,而是聚焦在最实际的问题上:怎么用Vue.js搭建一个真正好用的交互界面?我们会从零开始,一步步实现状态管理、异步加载、图表可视化和响应式适配,让你亲手做出一个能和Qwen3-VL:30B流畅对话的前端应用。不需要你已经是Vue专家,只要会写几行HTML和JavaScript,就能跟着做出来。

2. 环境准备与项目初始化

2.1 创建Vue项目骨架

我们使用Vue官方推荐的Vite工具来快速搭建项目。打开终端,执行以下命令:

# 创建新项目(选择Vue + JavaScript模板)
npm create vite@latest qwen-vue-interface -- --template vue

# 进入项目目录
cd qwen-vue-interface

# 安装依赖
npm install

# 启动开发服务器
npm run dev

启动成功后,浏览器访问 http://localhost:5173,你应该能看到Vue的欢迎页面。这个干净的起点,就是我们接下来要构建交互界面的基础。

2.2 安装必要的依赖库

为了让界面具备完整的交互能力,我们需要几个关键的辅助库:

# 安装Axios用于HTTP请求
npm install axios

# 安装Chart.js用于数据可视化
npm install chart.js vue-chartjs

# 安装Element Plus提供现成UI组件(按钮、表单、卡片等)
npm install element-plus

# 安装FileSaver用于下载生成结果
npm install file-saver

安装完成后,在 src/main.js 中添加Element Plus的全局注册:

import { createApp } from 'vue'
import { ElButton, ElCard, ElInput, ElUpload, ElProgress, ElMessage } from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)
app.component('ElButton', ElButton)
app.component('ElCard', ElCard)
app.component('ElInput', ElInput)
app.component('ElUpload', ElUpload)
app.component('ElProgress', ElProgress)
app.component('ElMessage', ElMessage)
app.mount('#app')

这样我们就有了一个功能完备的开发环境,所有UI组件和工具都已就位,可以开始构建核心功能了。

3. 核心功能模块设计与实现

3.1 多模态交互状态管理

前端界面的核心是状态管理。我们需要跟踪用户上传的图片、正在处理的状态、模型返回的结果,以及可能的错误信息。在Vue中,我们使用组合式API配合ref和reactive来管理这些状态。

创建 src/composables/useQwenInteraction.js 文件:

import { ref, reactive } from 'vue'
import axios from 'axios'

export function useQwenInteraction() {
  // 交互状态
  const state = reactive({
    isProcessing: false,
    uploadStatus: 'idle', // idle, uploading, success, error
    result: null,
    error: null,
    progress: 0
  })

  // 用户输入数据
  const input = reactive({
    image: null,
    textPrompt: '',
    analysisType: 'general' // general, product, diagram, document
  })

  // 清除所有状态
  const resetState = () => {
    state.isProcessing = false
    state.uploadStatus = 'idle'
    state.result = null
    state.error = null
    state.progress = 0
    input.image = null
    input.textPrompt = ''
  }

  // 模拟调用Qwen3-VL:30B API(实际项目中替换为真实API地址)
  const callQwenApi = async () => {
    if (!input.image && !input.textPrompt) {
      state.error = '请至少上传一张图片或输入文字描述'
      return
    }

    state.isProcessing = true
    state.error = null
    state.result = null

    try {
      // 模拟API调用过程(实际项目中替换为真实请求)
      // 这里使用setTimeout模拟网络延迟
      await new Promise(resolve => setTimeout(resolve, 2000))

      // 模拟不同分析类型的返回结果
      const mockResults = {
        general: {
          description: '这是一张现代简约风格的网页设计稿,主色调为浅灰和蓝色,包含导航栏、轮播图区域和三个功能卡片',
          tags: ['网页设计', 'UI界面', '用户体验'],
          confidence: 0.92
        },
        product: {
          description: '这是一款无线蓝牙耳机的产品图,展示其充电盒和耳机本体,具有IPX5防水等级和30小时续航能力',
          features: ['主动降噪', '空间音频', '触控操作'],
          confidence: 0.87
        },
        diagram: {
          description: '这是一个UML类图,展示了用户管理系统的三个核心类:User、Role和Permission,以及它们之间的关联关系',
          elements: ['4个类', '3种关系', '2个继承'],
          confidence: 0.95
        }
      }

      state.result = {
        ...mockResults[input.analysisType],
        timestamp: new Date().toLocaleString(),
        inputImage: input.image ? URL.createObjectURL(input.image) : null,
        prompt: input.textPrompt
      }

      state.uploadStatus = 'success'
    } catch (err) {
      state.error = err.response?.data?.message || '分析失败,请检查网络连接'
      state.uploadStatus = 'error'
    } finally {
      state.isProcessing = false
    }
  }

  return {
    state,
    input,
    resetState,
    callQwenApi
  }
}

这个自定义Hook封装了所有与Qwen3-VL:30B交互相关的逻辑,包括状态管理、错误处理和模拟API调用。它遵循Vue的最佳实践,将业务逻辑从组件中抽离出来,让代码更易维护和测试。

3.2 图片上传与预览组件

用户需要一个直观的方式上传图片并看到预览效果。我们创建一个独立的上传组件 src/components/ImageUploader.vue

<template>
  <div class="image-uploader">
    <el-upload
      class="upload-area"
      drag
      :auto-upload="false"
      :show-file-list="false"
      :on-change="handleFileChange"
      :on-remove="handleRemove"
      :on-error="handleError"
      accept="image/*"
    >
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">
        <em>点击上传</em> 或拖拽图片到此区域
      </div>
      <div class="el-upload__tip" slot="tip">
        支持 JPG/PNG/GIF 格式,建议尺寸不超过 2000x2000 像素
      </div>
    </el-upload>

    <!-- 预览区域 -->
    <div v-if="previewUrl" class="preview-container">
      <h3>上传预览</h3>
      <div class="preview-image">
        <img :src="previewUrl" :alt="fileName" />
      </div>
      <div class="preview-info">
        <p><strong>文件名:</strong>{{ fileName }}</p>
        <p><strong>大小:</strong>{{ formatFileSize(fileSize) }}</p>
        <el-button type="danger" size="small" @click="removeImage">
          移除图片
        </el-button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, defineEmits, defineProps } from 'vue'

const props = defineProps({
  modelValue: {
    type: File,
    default: null
  }
})

const emit = defineEmits(['update:modelValue', 'file-change'])

const previewUrl = ref('')
const fileName = ref('')
const fileSize = ref(0)

const handleFileChange = (file) => {
  if (file.raw) {
    const reader = new FileReader()
    reader.onload = (e) => {
      previewUrl.value = e.target.result
      fileName.value = file.name
      fileSize.value = file.size
      emit('update:modelValue', file.raw)
      emit('file-change', file.raw)
    }
    reader.readAsDataURL(file.raw)
  }
}

const handleRemove = () => {
  previewUrl.value = ''
  fileName.value = ''
  fileSize.value = 0
  emit('update:modelValue', null)
}

const handleError = (err) => {
  console.error('上传错误:', err)
}

const removeImage = () => {
  previewUrl.value = ''
  fileName.value = ''
  fileSize.value = 0
  emit('update:modelValue', null)
}

const formatFileSize = (bytes) => {
  if (bytes === 0) return '0 Bytes'
  const k = 1024
  const sizes = ['Bytes', '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>
.image-uploader {
  margin-bottom: 24px;
}

.upload-area {
  border: 2px dashed #d9d9d9;
  border-radius: 8px;
  padding: 40px 20px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s ease;
}

.upload-area:hover {
  border-color: #409eff;
  background-color: #f5f7fa;
}

.preview-container {
  margin-top: 24px;
  padding: 16px;
  background-color: #f9f9f9;
  border-radius: 8px;
}

.preview-image {
  text-align: center;
  margin-bottom: 16px;
}

.preview-image img {
  max-width: 100%;
  max-height: 300px;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.preview-info p {
  margin: 8px 0;
  color: #606266;
}
</style>

这个组件提供了完整的图片上传体验:支持拖拽、文件选择、实时预览、文件信息显示和移除功能。它使用Vue 3的组合式API编写,通过props和emits与父组件通信,符合Vue的最佳实践。

3.3 实时分析结果展示与交互

当Qwen3-VL:30B返回分析结果后,我们需要一个清晰、直观的界面来展示这些信息。创建 src/components/AnalysisResult.vue 组件:

<template>
  <div class="analysis-result" v-if="result">
    <el-card class="result-card">
      <template #header>
        <div class="card-header">
          <h2>分析结果</h2>
          <span class="timestamp">{{ result.timestamp }}</span>
        </div>
      </template>

      <!-- 输入图片预览 -->
      <div v-if="result.inputImage" class="input-preview">
        <h3>您上传的图片</h3>
        <div class="image-wrapper">
          <img :src="result.inputImage" :alt="result.prompt || '分析输入'" />
        </div>
      </div>

      <!-- 文字描述 -->
      <div class="description-section">
        <h3>内容描述</h3>
        <p class="description-text">{{ result.description }}</p>
      </div>

      <!-- 标签云 -->
      <div v-if="result.tags" class="tags-section">
        <h3>识别标签</h3>
        <div class="tag-cloud">
          <span 
            v-for="(tag, index) in result.tags" 
            :key="index" 
            class="tag-item"
          >
            {{ tag }}
          </span>
        </div>
      </div>

      <!-- 特征列表 -->
      <div v-if="result.features" class="features-section">
        <h3>关键特征</h3>
        <ul class="feature-list">
          <li v-for="(feature, index) in result.features" :key="index">
            {{ feature }}
          </li>
        </ul>
      </div>

      <!-- 置信度指示器 -->
      <div class="confidence-section">
        <h3>分析置信度</h3>
        <div class="confidence-bar">
          <el-progress 
            :percentage="Math.round(result.confidence * 100)" 
            :stroke-width="20"
            :color="getConfidenceColor(result.confidence)"
          />
          <span class="confidence-value">{{ Math.round(result.confidence * 100) }}%</span>
        </div>
      </div>

      <!-- 操作按钮 -->
      <div class="action-buttons">
        <el-button type="primary" @click="copyToClipboard">
          复制结果
        </el-button>
        <el-button @click="downloadResult">
          下载报告
        </el-button>
      </div>
    </el-card>
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'
import { ElMessage } from 'element-plus'
import { saveAs } from 'file-saver'

const props = defineProps({
  result: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['copy', 'download'])

const getConfidenceColor = (confidence) => {
  if (confidence >= 0.9) return '#67c23a' // 绿色
  if (confidence >= 0.7) return '#e6a23c' // 橙色
  return '#f56c6c' // 红色
}

const copyToClipboard = () => {
  const text = `【Qwen3-VL分析结果】\n\n${props.result.description}\n\n标签:${props.result.tags?.join('、') || '无'}\n置信度:${Math.round(props.result.confidence * 100)}%`
  
  navigator.clipboard.writeText(text)
  ElMessage.success('结果已复制到剪贴板')
}

const downloadResult = () => {
  const content = `Qwen3-VL分析报告\n\n时间:${props.result.timestamp}\n\n内容描述:${props.result.description}\n\n识别标签:${props.result.tags?.join('、') || '无'}\n\n置信度:${Math.round(props.result.confidence * 100)}%\n\n---\n由Qwen3-VL:30B多模态大模型生成`
  
  const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
  saveAs(blob, `qwen-analysis-${new Date().toISOString().slice(0,10)}.txt`)
}
</script>

<style scoped>
.analysis-result {
  margin-top: 24px;
}

.result-card {
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.timestamp {
  font-size: 14px;
  color: #909399;
}

.input-preview {
  margin-bottom: 24px;
}

.input-preview h3 {
  margin-bottom: 12px;
  color: #303133;
}

.image-wrapper {
  text-align: center;
}

.image-wrapper img {
  max-width: 100%;
  max-height: 400px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.description-section {
  margin-bottom: 24px;
}

.description-section h3 {
  margin-bottom: 12px;
  color: #303133;
}

.description-text {
  line-height: 1.6;
  color: #606266;
  font-size: 16px;
}

.tags-section {
  margin-bottom: 24px;
}

.tags-section h3 {
  margin-bottom: 12px;
  color: #303133;
}

.tag-cloud {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.tag-item {
  background-color: #ecf5ff;
  color: #409eff;
  padding: 6px 12px;
  border-radius: 20px;
  font-size: 14px;
  border: 1px solid #d9ecff;
}

.features-section {
  margin-bottom: 24px;
}

.features-section h3 {
  margin-bottom: 12px;
  color: #303133;
}

.feature-list {
  list-style: none;
  padding: 0;
}

.feature-list li {
  padding: 8px 0;
  border-bottom: 1px solid #f0f0f0;
  color: #606266;
}

.confidence-section {
  margin-bottom: 24px;
}

.confidence-section h3 {
  margin-bottom: 12px;
  color: #303133;
}

.confidence-bar {
  display: flex;
  align-items: center;
  gap: 12px;
}

.confidence-value {
  font-weight: bold;
  font-size: 16px;
  min-width: 50px;
  text-align: right;
}

.action-buttons {
  display: flex;
  gap: 12px;
  justify-content: center;
}

@media (max-width: 768px) {
  .card-header {
    flex-direction: column;
    gap: 8px;
  }
  
  .action-buttons {
    flex-direction: column;
  }
}
</style>

这个组件以卡片形式展示分析结果,包含了图片预览、文字描述、标签云、特征列表、置信度指示器和操作按钮。样式上采用了响应式设计,确保在移动设备上也能良好显示。

4. 可视化图表集成与数据呈现

4.1 分析结果统计图表

除了展示单次分析结果,我们还可以为用户提供历史分析的统计视图,帮助他们了解自己的使用习惯和模型表现。我们使用Chart.js来创建一个简单的饼图,展示不同分析类型的分布情况。

创建 src/components/AnalysisStats.vue 组件:

<template>
  <div class="stats-container">
    <h2>使用统计</h2>
    <div class="chart-container">
      <canvas ref="chartCanvas"></canvas>
    </div>
    
    <div class="stats-summary">
      <div class="stat-item">
        <h3>总分析次数</h3>
        <p class="stat-value">{{ totalAnalyses }}</p>
      </div>
      <div class="stat-item">
        <h3>平均置信度</h3>
        <p class="stat-value">{{ averageConfidence }}%</p>
      </div>
      <div class="stat-item">
        <h3>最常用类型</h3>
        <p class="stat-value">{{ mostCommonType }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import { Chart, registerables } from 'chart.js'

// 注册所有Chart.js组件
Chart.register(...registerables)

const props = defineProps({
  analyses: {
    type: Array,
    default: () => []
  }
})

const chartCanvas = ref(null)
let chartInstance = null

const totalAnalyses = computed(() => props.analyses.length)

const averageConfidence = computed(() => {
  if (props.analyses.length === 0) return 0
  const sum = props.analyses.reduce((acc, item) => acc + (item.confidence || 0), 0)
  return Math.round((sum / props.analyses.length) * 100)
})

const mostCommonType = computed(() => {
  if (props.analyses.length === 0) return '暂无数据'
  
  const typeCount = {}
  props.analyses.forEach(item => {
    const type = item.analysisType || 'general'
    typeCount[type] = (typeCount[type] || 0) + 1
  })
  
  return Object.keys(typeCount).reduce((a, b) => 
    typeCount[a] > typeCount[b] ? a : b
  )
})

onMounted(() => {
  initChart()
})

watch(() => props.analyses, () => {
  if (chartInstance) {
    chartInstance.destroy()
  }
  initChart()
})

const initChart = () => {
  if (!chartCanvas.value) return

  // 计算各类型分析次数
  const typeCount = {}
  props.analyses.forEach(item => {
    const type = item.analysisType || 'general'
    typeCount[type] = (typeCount[type] || 0) + 1
  })

  const labels = Object.keys(typeCount)
  const data = Object.values(typeCount)
  const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c']

  chartInstance = new Chart(chartCanvas.value, {
    type: 'pie',
    data: {
      labels: labels.map(type => {
        const typeMap = {
          'general': '通用分析',
          'product': '产品识别',
          'diagram': '图表解析',
          'document': '文档理解'
        }
        return typeMap[type] || type
      }),
      datasets: [{
        data: data,
        backgroundColor: colors.slice(0, labels.length),
        borderWidth: 0
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: {
        legend: {
          position: 'bottom',
          labels: {
            padding: 20,
            usePointStyle: true,
            pointStyle: 'circle'
          }
        },
        tooltip: {
          callbacks: {
            label: function(context) {
              const label = context.label || ''
              const value = context.parsed || 0
              const total = context.dataset.data.reduce((a, b) => a + b, 0)
              const percentage = Math.round((value / total) * 100)
              return `${label}: ${value}次 (${percentage}%)`
            }
          }
        }
      }
    }
  })
}
</script>

<style scoped>
.stats-container {
  margin-top: 32px;
  padding: 24px;
  background-color: #f9f9f9;
  border-radius: 12px;
}

.stats-container h2 {
  margin-bottom: 24px;
  color: #303133;
  text-align: center;
}

.chart-container {
  height: 300px;
  margin-bottom: 24px;
}

.stats-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

.stat-item {
  text-align: center;
  padding: 16px;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}

.stat-item h3 {
  margin-bottom: 8px;
  color: #606266;
  font-size: 14px;
}

.stat-value {
  font-size: 24px;
  font-weight: bold;
  color: #409eff;
}

@media (max-width: 768px) {
  .stats-summary {
    grid-template-columns: 1fr;
  }
}
</style>

这个组件展示了用户历史分析的统计信息,包括饼图和关键指标摘要。它使用了Vue的响应式特性,当分析历史数据变化时,图表会自动更新。

4.2 实时处理进度可视化

在等待Qwen3-VL:30B处理图片时,用户需要明确的反馈来了解当前进度。我们创建一个进度指示器组件 src/components/ProcessingIndicator.vue

<template>
  <div class="processing-indicator" v-if="isVisible">
    <div class="indicator-content">
      <div class="spinner"></div>
      <h3>正在分析中...</h3>
      <p class="status-text">{{ statusText }}</p>
      <el-progress 
        v-if="showProgress" 
        :percentage="progress" 
        :stroke-width="12"
        :color="progressColor"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

const props = defineProps({
  isVisible: {
    type: Boolean,
    default: false
  },
  progress: {
    type: Number,
    default: 0
  },
  statusText: {
    type: String,
    default: '正在理解图片内容...'
  }
})

const showProgress = computed(() => props.progress > 0 && props.progress < 100)

const progressColor = computed(() => {
  if (props.progress < 30) return '#e6a23c'
  if (props.progress < 70) return '#409eff'
  return '#67c23a'
})

// 模拟进度动画(实际项目中由API返回真实进度)
watch(() => props.isVisible, (newVal) => {
  if (newVal) {
    // 重置进度条
    // 在实际项目中,这里会监听WebSocket或Server-Sent Events
  }
})
</script>

<style scoped>
.processing-indicator {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,0.5);
  z-index: 1000;
  display: flex;
  justify-content: center;
  align-items: center;
}

.indicator-content {
  background-color: white;
  padding: 32px;
  border-radius: 12px;
  text-align: center;
  max-width: 400px;
  width: 90%;
  box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}

.spinner {
  width: 48px;
  height: 48px;
  border: 4px solid #f0f0f0;
  border-top: 4px solid #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 24px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.indicator-content h3 {
  margin-bottom: 12px;
  color: #303133;
}

.status-text {
  color: #606266;
  margin-bottom: 24px;
  line-height: 1.5;
}

@media (max-width: 480px) {
  .indicator-content {
    padding: 24px;
  }
}
</style>

这个组件提供了一个全屏的处理指示器,包含旋转动画和进度条,让用户清楚地知道系统正在工作,并且可以大致估计还需要等待多久。

5. 响应式设计与用户体验优化

5.1 移动端适配策略

现代Web应用必须在各种设备上都能良好运行。我们的界面需要在手机、平板和桌面设备上都有出色的体验。我们采用移动优先的设计策略,在 src/assets/styles/main.css 中添加响应式样式:

/* 基础重置和移动端样式 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  line-height: 1.6;
  color: #303133;
  background-color: #f5f7fa;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}

/* 移动端断点 */
@media (max-width: 768px) {
  .container {
    padding: 0 16px;
  }
  
  .main-content {
    padding: 16px;
  }
  
  .analysis-section {
    margin-top: 16px;
  }
  
  .control-panel {
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  
  .upload-section {
    margin-bottom: 16px;
  }
  
  .result-section {
    margin-top: 16px;
  }
  
  .stats-container {
    padding: 16px;
  }
}

/* 平板断点 */
@media (min-width: 769px) and (max-width: 1024px) {
  .container {
    max-width: 960px;
  }
  
  .main-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 24px;
  }
  
  .control-panel {
    grid-column: 1;
  }
  
  .result-section {
    grid-column: 1;
  }
}

/* 桌面断点 */
@media (min-width: 1025px) {
  .main-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 24px;
  }
  
  .control-panel {
    grid-column: 1;
  }
  
  .result-section {
    grid-column: 2;
  }
  
  .stats-container {
    grid-column: 1 / -1;
  }
}

这些CSS规则确保了我们的应用在不同屏幕尺寸下都能有良好的布局和间距。我们使用了现代的CSS Grid和Flexbox布局,避免了过时的浮动布局。

5.2 交互细节优化

好的用户体验往往体现在细节上。我们在 src/App.vue 中添加一些交互优化:

<template>
  <div id="app">
    <header class="app-header">
      <div class="container">
        <h1>Qwen3-VL多模态分析平台</h1>
        <p class="subtitle">用Vue.js构建的实时交互界面</p>
      </div>
    </header>

    <main class="main-content">
      <div class="container">
        <div class="main-grid">
          <!-- 控制面板 -->
          <div class="control-panel">
            <el-card class="control-card">
              <template #header>
                <h2>分析设置</h2>
              </template>
              
              <div class="upload-section">
                <h3>上传图片</h3>
                <ImageUploader 
                  v-model="input.image" 
                  @file-change="onFileChange"
                />
              </div>
              
              <div class="prompt-section">
                <h3>补充说明(可选)</h3>
                <el-input
                  v-model="input.textPrompt"
                  type="textarea"
                  :rows="3"
                  placeholder="例如:请重点分析这张图中的产品功能特点..."
                  maxlength="200"
                  show-word-limit
                />
              </div>
              
              <div class="type-section">
                <h3>分析类型</h3>
                <el-radio-group v-model="input.analysisType">
                  <el-radio label="general">通用分析</el-radio>
                  <el-radio label="product">产品识别</el-radio>
                  <el-radio label="diagram">图表解析</el-radio>
                  <el-radio label="document">文档理解</el-radio>
                </el-radio-group>
              </div>
              
              <div class="action-section">
                <el-button 
                  type="primary" 
                  size="large" 
                  :loading="state.isProcessing"
                  @click="handleAnalyze"
                  style="width: 100%; margin-top: 16px;"
                >
                  {{ state.isProcessing ? '分析中...' : '开始分析' }}
                </el-button>
                
                <el-button 
                  @click="resetAll"
                  style="width: 100%; margin-top: 12px;"
                >
                  重置所有
                </el-button>
              </div>
            </el-card>
          </div>
          
          <!-- 结果展示区 -->
          <div class="result-section">
            <div v-if="!state.result && !state.error">
              <el-card class="placeholder-card">
                <template #header>
                  <h2>分析结果</h2>
                </template>
                <div class="placeholder-content">
                  <i class="el-icon-picture"></i>
                  <h3>等待您的分析请求</h3>
                  <p>上传一张图片,选择分析类型,然后点击"开始分析"按钮</p>
                </div>
              </el-card>
            </div>
            
            <AnalysisResult 
              v-else-if="state.result" 
              :result="state.result" 
              @copy="handleCopy" 
              @download="handleDownload"
            />
            
            <div v-else-if="state.error" class="error-section">
              <el-alert 
                :title="state.error" 
                type="error" 
                show-icon 
                :closable="false"
              />
              <el-button @click="resetAll" style="margin-top: 16px;">
                重新尝试
              </el-button>
            </div>
          </div>
        </div>
        
        <!-- 统计图表 -->
        <AnalysisStats :analyses="analysisHistory" />
      </div>
    </main>
    
    <ProcessingIndicator 
      :is-visible="state.isProcessing" 
      :progress="state.progress" 
      :status-text="getProcessingStatus()"
    />
    
    <footer class="app-footer">
      <div class="container">
        <p>© {{ new Date().getFullYear() }} Qwen3-VL Vue前端界面示例 | 基于Vue 3和Element Plus构建</p>
      </div>
    </footer>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useQwenInteraction } from './composables/useQwenInteraction'
import ImageUploader from './components/ImageUploader.vue'
import AnalysisResult from './components/AnalysisResult.vue'
import AnalysisStats from './components/AnalysisStats.vue'
import ProcessingIndicator from './components/ProcessingIndicator.vue'

const { state, input, resetState, callQwenApi } = useQwenInteraction()

// 模拟历史分析记录(实际项目中从localStorage或API获取)
const analysisHistory = ref([
  { analysisType: 'general', confidence: 0.92, timestamp: '2024-
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐