智能前端小魔术,让图片开口说单词

这几天做了个小项目 ------ 用户上传图片后,AI能自动识别出对应的英文单词,还能生成例句和语音。整个过程踩了不少坑,也有很多值得说道的细节,今天就从头到尾捋一捋实现思路,尤其是那些容易被忽略的技术点。

从主组件开始

任何 React 项目都需要一个主组件来串联各个功能,App.jsx 就是这个项目的 "大脑",负责管理全局状态和协调各个模块。先看最基础的结构:

jsx 复制代码
import { useState } from "react";
import "./App.css";
import PictureCard from "./components/PictureCard";
import { generateAudio } from "./lib/audio";

function App() {
  // 后面会逐步添加状态和方法

  return (
    <div className='container'>
      {/* 内容后续填充 */}
    </div>
  );
}

export default App;

这个组件的核心任务有三个:存储项目中所有需要共享的数据(比如识别出的单词、语音地址)、处理 AI 接口调用逻辑、把数据和方法传递给子组件。

定义状态

首先要明确,这个项目需要存储哪些数据?我列了一下,至少包括这些:

jsx 复制代码
function App() {
  // 英文单词
  const [word, setWord] = useState("请上传图片");
  // 英文例句
  const [sentence, setSentence] = useState("");
  // 语音地址
  const [audio, setAudio] = useState("");
  // 控制详情区域是否展开
  const [detailExpand, setDetailExpand] = useState(false);
  // 图片的预览地址
  const [imgPreview, setImgPreview] = useState("");
  // 单词的详细解释
  const [explanation, setExplanation] = useState([]);
  // 解释中问句的回复内容
  const [expReply, setExpReply] = useState([]);

}

这些状态各自有明确的用途:word 展示核心单词,sentence 展示例句,audio 存储语音地址...... 最关键的是,这些状态会在不同组件间流转,比如 wordaudio 会传给子组件 PictureCard 展示,而 imgPreview 则会在详情区域显示。

搭建页面结构

状态定义好了,接下来要考虑如何把这些数据展示给用户。页面结构其实很简单,主要分为两部分:图片上传区和结果展示区。

jsx 复制代码
return (
  <div className='container'>
 
    <PictureCard 
      word={word} 
      audio={audio}
      uploadImg={uploadImg}
    />

    {/* 结果展示区域 */}
    <div className="output">
      <div className="sentence">{sentence}</div>

      {/* 详情展开/收起区域 */}
       <div className="details">
          <button onClick={() => setDetailExpand(!detailExpand)}>Talk about it</button>
          {
            detailExpand ? (
              <div className="expand">
                <img src={imgPreview} alt="preview"/>
                {
                  explanation.map((explanation, index) => (
                    <div key={index} className="explanation">
                      {explanation}
                    </div>
                  ))
                }
                {
                  expReply.map((reply, index) => (
                    <div key={index} className="reply">
                      {reply}
                    </div>
                  ))
                }
              </div>
            ): (
              <div className="fold" />
            )
          }
        </div>
    </div>
  </div>
);

这段代码的逻辑很清晰:

  • PictureCard 组件处理图片上传,把需要展示的数据(wordaudio)和处理方法(uploadImg)传进去
  • 结果展示区分为两部分:直接显示的例句,和可展开的详情(包含图片、解释、回复)
  • 点击按钮时通过 setDetailExpand 切换详情的显示状态,这是 React 中常见的条件渲染方式

图片上传与预览

PictureCard 组件是用户交互的入口,负责接收用户上传的图片并实时预览,同时把图片数据传给 App 组件处理。先看它的基础结构:

jsx 复制代码
import { useState } from "react";
import "./style.css";

const PictureCard = (props) => {
  const { word, audio, uploadImg } = props;

  const [imgPreview, setImgPreview] = useState(
    "https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png"
  );


  return (
    <div className="card">
      {/* 上传相关内容 */}
    </div>
  );
};

export default PictureCard;

这个组件的核心任务是:让用户能选择图片 → 读取图片内容 → 实时预览 → 把图片数据传给父组件。

实现图片选择与预览功能

jsx 复制代码
return (
  <div className="card">
    <input
      type="file"
      id="selectImage"
      accept=".jpg,.jpeg,.png,.gif" 
      onChange={uploadImgData}
    />
    
    {/* label 与 input 关联,点击图片就会打开文件选择框 */}
    <label htmlFor="selectImage" className="upload">
      <img src={imgPreview} alt="preview" />
    </label>
  </div>
);

选择图片需要一个文件输入框,但原生的输入框样式不好控制,所以用了个小技巧:把 input[type="file"] 隐藏起来,用 label 关联它,这样就能自定义上传按钮的样式。点击 label 里的图片时,其实触发的是文件选择框,体验会好很多。 接下来是关键的 uploadImgData 方法,它负责读取用户选择的图片并转换格式:

jsx 复制代码
const uploadImgData = async (e) => {
  // 获取用户选择的第一个文件
  const file = e.target.files?.[0];
  
  if (!file) return;

  // 用 Promise 包装,方便父组件知道图片处理完成的时机
  return new Promise((resolve, reject) => {
    // 创建 FileReader 实例,用于读取文件内容
    const reader = new FileReader();
    
    // 以 DataURL 格式读取文件(会把文件转成 base64 字符串)
    reader.readAsDataURL(file);

   
    reader.onload = () => { 
      const data = reader.result;
      
      setImgPreview(data);
      
      uploadImg(data);
      
      resolve(data);
    };

    // 读取失败的回调
    reader.onerror = (error) => {
      reject(error);
    };
  });
};

最关键的是 uploadImgData 函数,这里用到了 FileReader API。刚开始我想直接把文件对象传给后端,后来发现前端预览必须先读文件内容。readAsDataURL 方法会把文件转换成 base64 格式的字符串,长得像 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... 这种。

为什么要用 base64?因为它可以直接作为 img 标签的 src 属性值,不用上传到服务器就能预览。当 reader.onload 触发时,reader.result 就是转换好的 base64 字符串,把它存到 imgPreview 状态里,图片就实时显示出来了。

这里踩过一个坑:如果取消选择文件,e.target.files 会是空的,所以必须加个判断 if (!file) return,否则会报错。另外,用 Promise 包装读取过程,是为了让父组件能知道图片什么时候处理完,方便后续调用 API。

调用月之暗面 API

图片数据传到 App 组件后,下一步就是调用 AI 接口分析图片内容。这部分逻辑在 uploadImg 方法里,这个方法是 PictureCard 组件传图片数据时触发的。

准备提示词

调用 AI 接口时,提示词(prompt)非常关键,直接决定返回结果的质量。我专门写了一段提示词,明确告诉 AI 需要做什么、返回什么格式的数据:

jsx 复制代码
// 定义提示词,指导 AI 如何分析图片
const picPrompt = `
  请分析图片内容,找出最能描述图片的一个英文单词,尽量选择 A1-A2 难度的基础词汇。
  必须严格按照以下 JSON 格式返回结果,不要添加任何额外内容:
  { 
    "image_description": "对图片内容的简要描述",
    "representative_word": "最能代表图片的英文单词",
    "example_sentence": "包含该单词的简单英文例句",
    "explanation": "用英文解释这个单词,要求每句单独一行,以 Look at... 开头,最后加一个与日常生活相关的问句",
    "explanation_reply": ["对问句的第一个回复(英文)", "对问句的第二个回复(英文)"]
  }
`;

提示词里强调了几点:单词难度(A1-A2)、严格的 JSON 格式、解释的结构(每句一行 + 问句)。刚开始没限制格式时,AI 经常返回大段文本,解析起来很麻烦,加了格式限制后就顺畅多了。

实现 API 调用逻辑

有了提示词,就可以编写调用接口的代码了:

jsx 复制代码
const uploadImg = async (imageData) => {
  setImgPreview(imageData);
  setWord("分析中...");

  // 月之暗面 API 的地址
  const endpoint = "https://api.moonshot.cn/v1/chat/completions";
  // 请求头
  const headers = {
    "Content-Type": "application/json",
    // 从环境变量获取 API 密钥,避免明文暴露
    Authorization: `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
  };
  try {
    // 发送 POST 请求
    const response = await fetch(endpoint, {
      method: "POST",
      headers,
      body: JSON.stringify({
        model: "moonshot-v1-8k-vision-preview", 
        messages: [
          {
            role: "user",
            content: [
              {
                type: "image_url",
                image_url: { url: imageData }
              },
              {
                type: "text",
                text: picPrompt
              }
            ]
          }
        ],
        stream: false 
      })
    });

    // 解析接口返回的 JSON 数据
    const data = await response.json();
    const replyData = JSON.parse(data.choices[0].message.content);

    // 更新状态,让页面显示识别结果
    setWord(replyData.representative_word); 
    setSentence(replyData.example_sentence);
   
    setExplanation(replyData.explanation.split("\n"));
    setExpReply(replyData.explanation_reply);

    // 调用 TTS 接口生成语音(后面详细讲)
    const audioUrl = await generateAudio(replyData.example_sentence);
    setAudio(audioUrl); 
  } catch (error) {
    // 出错时显示错误提示
    setWord("分析失败,请重试");
    console.error("API 调用出错:", error);
  }
};

这段代码的关键步骤:

  1. imageData 是从子组件传过来的 base64 字符串,直接作为图片 URL 传给 API,省了先上传到服务器的步骤。
  2. 请求头里的 Authorization 用了环境变量 VITE_KIMI_API_KEY,这是为了安全,密钥不能明文写在代码里,用 Vite 的环境变量管理很方便。
  3. 调用 API 时指定了 model: "moonshot-v1-8k-vision-preview",这是月之暗面支持图片识别的模型。
  4. 拿到返回结果后,先用 JSON.parse 解析,因为 AI 返回的是字符串格式的 JSON,必须转成对象才能用。
  5. 最后调用 generateAudio 生成语音,把例句读出来,这个函数的实现是另一个重点。

按理说我们拿到了数据应该去渲染页面,但是我为了偷懒直接把页面放在前面了😁😁

展示单词和语音播放按钮

除了上传功能,这个组件还要显示识别出的单词和语音播放按钮。在 return 里补充这部分内容:

jsx 复制代码
return (
  <div className="card">
   
    <input type="file" id="selectImage" ... />
    <label htmlFor="selectImage" className="upload">
      <img src={imgPreview} alt="preview" />
    </label>

    <div className="word">{word}</div>

    {/* 语音播放按钮,只有当 audio 有值时才显示 */}
    {audio && (
      <div className="playAudio" onClick={playAudio}>
        <img
          width="20px"
          src="https://res.bearbobo.com/resource/upload/Omq2HFs8/playA-3iob5qyckpa.png"
          alt="play"
        />
      </div>
    )}
  </div>
);

再实现播放语音的方法:

jsx 复制代码
// 播放语音的方法
const playAudio = () => {
  const audioEle = new Audio(audio);
  audioEle.play();
};

这里的逻辑很简单:当父组件传来 audio 地址时,渲染播放按钮;点击按钮时,用 Audio 构造函数创建音频实例并播放。

实现 TTS 功能

最后一步是把生成的例句转成语音,这部分逻辑放在 lib/audio.js 里,涉及到 base64 解码和 Blob 处理,是项目的技术亮点之一。

调用 TTS 接口生成音频数据

首先实现调用 TTS 服务的方法,获取音频的 base64 数据:

js 复制代码
// lib/audio.js
export const generateAudio = async (text) => {
  // 从环境变量获取配置
  const token = import.meta.env.VITE_AUDIO_ACCESS_TOKEN;
  const appId = import.meta.env.VITE_AUDIO_APP_ID;
  const clusterId = import.meta.env.VITE_AUDIO_CLUSTER_ID;
  const voiceName = import.meta.env.VITE_AUDIO_VOICE_NAME;

  // TTS 接口地址
  const endpoint = "/tts/api/v1/tts";
  // 请求头
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer;${token}`
  };

  // 构造请求体,不同服务的参数格式可能不同
  const payload = {
    app: {
      appid: appId,
      token,
      cluster: clusterId
    },
    user: {
      uid: "ai-word-user" // 自定义用户 ID
    },
    audio: {
      voice_type: voiceName, // 发音人
      encoding: "mp3", // 音频格式
      rate: 24000, // 采样率
      speed_ratio: 1.0, // 语速
      emotion: "neutral" // 情感
    },
    request: {
      reqid: Math.random().toString(36).substring(2, 10), // 随机请求 ID
      text, // 要转换的文本(例句)
      text_type: "plain" // 文本类型为纯文本
    }
  };

  try {
    // 调用 TTS 接口
    const response = await fetch(endpoint, {
      method: "POST",
      headers,
      body: JSON.stringify(payload)
    });
    const data = await response.json();
    // 把返回的 base64 音频数据转成可播放的 URL
    return getAudioUrl(data.data);
  } catch (error) {
    console.error("语音生成失败:", error);
    return null;
  }
};

这个函数的作用是把例句文本传给 TTS 服务,拿到音频数据。这里的 reqid 用随机字符串生成,是为了避免请求冲突。返回的 data.data 是 base64 编码的音频内容,不能直接用,调用 getAudioUrl 方法把数据转成浏览器能播放的格式。

将 base64 音频数据转换为可播放的 URL

TTS 服务返回的是 base64 编码的音频数据,不能直接播放,需要将其转换为浏览器可以识别的格式:

js 复制代码
const getAudioUrl = (base64Data) => {
  // 1. 解码 base64 字符串,得到二进制字符串
  const byteCharacters = atob(base64Data);
  
  // 2. 创建字节数组
  const byteNumbers = new Array(byteCharacters.length);
  for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
  }
  
  // 3. 创建 Uint8Array(8 位无符号整数数组)
  const byteArray = new Uint8Array(byteNumbers);
  
  // 4. 创建 Blob 对象(二进制大对象)
  const blob = new Blob([byteArray], { type: 'audio/mp3' });
  
  // 5. 生成可播放的 URL
  return URL.createObjectURL(blob);
};

这个过程涉及几个关键步骤:

  1. 解码 base64 :使用 atob() 函数将 base64 字符串解码为原始二进制数据

  2. 转换为字节数组:将二进制字符串转换为数字数组,每个数字对应一个字节

  3. 创建类型化数组 :使用 Uint8Array 将普通数组转换为 JavaScript 可以处理的二进制数据

  4. 创建 Blob 对象 :将二进制数据封装为 Blob 对象,并指定 MIME 类型为 audio/mp3

  5. 生成 URL :使用 URL.createObjectURL() 为 Blob 对象生成一个临时 URL,这个 URL 可以直接作为 <audio> 元素的 src

为什么不直接使用 base64 字符串作为音频源?虽然可以这样做,但性能会较差,尤其是对于较大的音频文件。而使用 Blob URL 可以让浏览器更高效地处理音频数据,并且在不再需要时可以通过 URL.revokeObjectURL() 释放资源。

好了,整个项目的流程就是这样了,下面让我们上传一张图片来看看具体效果吧!

这个项目虽然功能不复杂,但把图片处理、AI 接口调用、语音生成这些知识点串起来了。尤其是前端直接处理文件和二进制数据的部分,以前总觉得很深奥,实际做起来才发现,掌握 FileReaderBlobURL 这些 API 后,很多需求都能迎刃而解。最后想问一句,这个苹果是不是看起来很好吃?

项目地址:github.com/LgFvm9353/l...

相关推荐
zwjapple2 小时前
docker-compose一键部署全栈项目。springboot后端,react前端
前端·spring boot·docker
像风一样自由20204 小时前
HTML与JavaScript:构建动态交互式Web页面的基石
前端·javascript·html
伍哥的传说4 小时前
React 各颜色转换方法、颜色值换算工具HEX、RGB/RGBA、HSL/HSLA、HSV、CMYK
深度学习·神经网络·react.js
aiprtem5 小时前
基于Flutter的web登录设计
前端·flutter
浪裡遊5 小时前
React Hooks全面解析:从基础到高级的实用指南
开发语言·前端·javascript·react.js·node.js·ecmascript·php
why技术5 小时前
Stack Overflow,轰然倒下!
前端·人工智能·后端
GISer_Jing5 小时前
0704-0706上海,又聚上了
前端·新浪微博
止观止6 小时前
深入探索 pnpm:高效磁盘利用与灵活的包管理解决方案
前端·pnpm·前端工程化·包管理器
whale fall6 小时前
npm install安装的node_modules是什么
前端·npm·node.js
烛阴6 小时前
简单入门Python装饰器
前端·python