从零开始的一个React 拍照识图学单词项目

112 阅读4分钟

从零开始的一个React 拍照识图学单词项目

该项目是一个基于React开发,并结合moonshot大模型与火山引擎TTS大模型的应用。其主要功能是通过将上传图片交给moonshot大模型识别,并将识别得到的英文单词和例句交给火山引擎TTS大模型生成音频。用户便可以对不了解的事物通过拍照来直接学习与其相关的英文单词。

一、初始化与基础配置

项目搭建

新建一个speak_word文件夹,执行以下命令

npm init vite  // 初始化项目 初始化react vue 等项目的模板和工程,vite 是工程化的工具,脚手架
npm stall // 安装相关依赖
npm run dev // 测试 将项目跑起来

基础配置

项目搭建完成后,需要进行一些基础配置

在index.html中的meta 属性中添加user-scalable=no

不添加该属性,在移动端双击屏幕会使得屏幕缩放,这是个不好的用户体验,而添加该属性后便可去除

<meta name="viewport" content="width=device-width, initial-scale=1.0,user-scalable=no" />

src同级目录下创建.env.local文件,该文件是用来存放环境变量的,其中存放一些访问大模型的token。在.gitignore中添加.env.local文件名,这是为了防止提交git后token泄漏。

.env.local

# moonshot 密钥
VITE_KIMI_API_KEY=

# tts 密钥配置
VITE_AUDIO_ACCESS_TOKEN=
VITE_AUDIO_APP_ID=
VITE_AUDIO_CLUSTER_ID
VITE_AUDIO_VOICE_NAME=

项目目录结构

src/
  components/
    PictureUpLoad/
    AudioPlay/
    WordList/
  main.js
  App.jsx
  assets/
public/
index.html
.env.local

其中components组件文件夹,如图片上传组件。main.js是;App.js是项目的根组件;assets目录是;public目录是;index.html文件是;.env.local环境配置文件。

二、功能组件化

组件

按照功能逻辑将语言划分为三个主要组件:

  1. PictureUpLoad:图片上传区域,处理拍照/上传逻辑
  2. AudioPlay:音频播放区域,负责单词发音
  3. WordList:单词例句展示区域,显示识别结果

状态管理设计

采用react的props单向数据流,根组件负责获取并持有数据,子组件负责消费数据。数据只能由更组件流向子组件

根组件App.jsx

import { useState } from "react";
import "./App.css";
import PictureUpLoad from "./components/PictureUpLoad";
import WordList from "./components/WordList";
import { generateAudio } from "./lib/audio";
import { getImgWord } from "./lib/picture";
import originImg from "./assets/origin.png";

function App() {
  const [lessonData, setLessonData] = useState({
    word: "", // 单词
    sentence: "", // 例句
    audioUrl: "", // 音频url
    explaination: [], // 解释
    explainationReplys: [], // 解释回复
  });
  //提示
  const [tips, setTips] = useState("请上传图片");
  // 图片预览
  const [imgPreview, setImgPreview] = useState(originImg);

  // 上传图片方法
  const uploadImg = async (imgBase64Data) => {
    // 上传图片预览
    setImgPreview(imgBase64Data);

    // 调用mootshot识别图片
    try {
      setTips("正在识别图片...");
      const data = await getImgWord(imgBase64Data);
      const wordData = JSON.parse(data.choices[0].message.content);
      console.log(wordData);
      
      setLessonData((prev) => ({
        ...prev,
        word: wordData.representative_word || "", // 默认空字符串
        sentence: wordData.example_sentence || "",
        explaination: wordData.explaination?.split("\n") || [], // 安全拆分
        explainationReplys: wordData.explaination_replys || [],
        audioUrl: wordData.audio_url || prev.audioUrl, // 保留旧值或更新
      }));

    } catch (error) {
      setTips("图片识别失败");
    }

    // 大模型返回的是base64编码的音频,这样的数据比较小,能接受token消耗
    // 调用音频接口,返回音频的url
    try {
      setTips("正在生成音频...");
      console.log(lessonData.sentence);
      const audioUrl = await generateAudio(lessonData.sentence);
      setTips("");
      setLessonData((prev) => ({
        ...prev,
        audioUrl: audioUrl,
      }));
    } catch (error) {
      setTips("音频生成失败");
    }
  };

  return (
    <div className="container">
      <PictureUpLoad
        word={lessonData.word}
        audioUrl={lessonData.audioUrl}
        uploadImg={uploadImg}
        originImg={imgPreview}
      />
      {tips && <div>{tips}</div>}
      <WordList
        imgPreview={imgPreview}
        word={lessonData.word}
        sentence={lessonData.sentence}
        explaination={lessonData.explaination}
        explainationReplys={lessonData.explainationReplys}
      />
    </div>
  );
}

export default App;

三、核心功能实现

图片上传功能实现

读取用户上传的图片并转为base64格式编码的数据,并创建Object URL临时引用地址。App.js向mootshot发起请求请求中携带图片数据,获取mootshot返回的单词和例句数据。之后再向火山云tts发起携带单词例句的请求,获取例句音频。

import { useState } from "react";
import "./index.css";
import AudioPlay  from "../AudioPlay";

export default function PictureUpLoad(props) {
  const { word, audioUrl, uploadImg ,originImg} = props;


  // 图片预览
  const [imgPreview, setImgPrview] = useState(
    originImg
  );

  // 上传图片数据 并绑定imgPrive
  const uploadImgData = async (e) => {
    const imgPath = e.target?.files[0];

    if (!imgPath) {
      return;
    }
    new Promise((resolve, reject) => {
      const reader = new FileReader();
      // 读取图片数据并转为base64格式
      reader.readAsDataURL(imgPath);
      reader.onload = () => {
        setImgPrview(reader.result);
        // 调用父组件的函数
        uploadImg(reader.result);

        resolve(reader.result);
      };
      reader.onerror = () => {
        reject(reader.error);
      };
    });

  };
  
  return (
    <div className="card">
      <input
        id="selectImage"
        type="file"
        accept=".jpg,.jeg,.png"
        onChange={uploadImgData}
      ></input>
      <label htmlFor="selectImage" className="upload">
        <img src={imgPreview} alt="preview" />
      </label>
      <div className="word">{word}</div>
      <AudioPlay audioUrl={audioUrl} />
    </div>
  );
}

播放组件

根据上级组件传递过来的URL地址,控制单词例句的播放

export default function AudioPlay(props) {
  const { audioUrl } = props;
  function playAudio() {
    const audio = new Audio(audioUrl);
    audio.play();
  }
  return (
    <div>
      {audioUrl && (
        <div className="playAudio" onClick={playAudio}>
          <img
            width="20px"
            src="https://res.bearbobo.com/resource/upload/Omq2HFs8/playA-3iob5qyckpa.png"
            alt="logo"
          />
        </div>
      )}
    </div>
  );
}

例句展示组件

根组件获取mootshot返回的单词和例句,并将其传递给WordList组件进行数据展示

import { useState } from "react";

export default function WordList(props) {
  const { sentence, explaination, explainationReplys, imgPreview } = props;
  const [detailExpand, setDetailExpand] = useState(false);
  return (
    <div className="output">
      <div>{sentence}</div>
      <div className="details">
        <button
          onClick={() => {
            setDetailExpand(!detailExpand);
          }}
        >
          Talk about
        </button>
        {detailExpand ? (
          <div className="expand">
            <img src={imgPreview} alt="preview" />
            {explaination.map((ex, index) => (
              <div key={index} className="explanation">
                {ex}
              </div>
            ))}

            {explainationReplys.map((reply, index) => (
              <div key={index} className="explanation-reply">
                {reply}
              </div>
            ))}
          </div>
        ) : (
          <div className="fold" />
        )}
      </div>
    </div>
  );
}