React + SpringBoot实现图片预览和视频在线播放,其中视频实现切片保存和分段播放

news2024/11/25 10:53:42

图片预览和视频在线播放

需求描述

实现播放视频的需求时,往往是前端直接加载一个mp4文件,这样做法在遇到视频文件较大时,容易造成卡顿,不能及时加载出来。我们可以将视频进行切片,然后分段加载。播放一点加载一点,这样同一时间内只会加载一小部分的视频,不容易出现播放卡顿的问题。下面是实现方法。

对视频切片使用的是 ffmpeg,可查看我的这个文章安装使用

后端接口处理

后端需要处理的逻辑有

  1. 根据视频的完整地址找到视频源文件
  2. 根据视频名称进行MD5,在同级目录下创建MD5文件夹,用于存放生成的索引文件和视频切片
  3. 前端调用视频预览接口时先判断有没有索引文件
    1. 如果没有,则先将mp4转为ts,然后对ts进行切片处理并生成index.m3u8索引文件,然后删除ts文件
    2. 如果有,则直接读取ts文件写入到响应头,以流的方式返回给浏览器
  4. 加载视频分片文件时会重复调用视频预览接口,需要对请求进来的参数做判断,判断是否是请求的索引还是分片

首先定义好接口,接收一个文件ID获取到对应的文件信息

@ApiOperation("文件预览")
@GetMapping("preview/{fileId}")
public void preview(@PathVariable String fileId, HttpServletResponse response) {
    if (fileId.endsWith(".ts")) {
        filePanService.readFileTs(fileId, response);
    } else {
        LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();
        qw.eq(FilePan::getFileId, fileId);
        FilePan one = filePanService.getOne(qw);
        if (ObjectUtil.isEmpty(one)) {
            throw new CenterExceptionHandler("文件不存在");
        }
        filePanService.preview(one, response);
    }
}

视频信息如下图

image-20240608111547732

在磁盘上对应的视频

image-20240608111647759

数据库中存放是视频信息

image-20240608111720899

当点击视频时,前端会拿到当前的文件ID请求上面定义好的接口,此时 fielId 肯定不是以 ts 结尾,所以会根据这个 fileId 查询数据库中保存的这条记录,然后调用 filePanService.preview(one, response) 方法

preview方法

preview方法主要处理的几个事情

  1. 首先判断文件类型是图片还是视频
  2. 如果是图片是直接读取图片并返回流
  3. 如果是视频
    1. 首先拿到视频名称,对名称进行md5处理,并生成文件夹
    2. 创建视频ts文件,并对ts进行切片和生成索引
  4. 加载分片文件时调用readFileTs方法
/**
 * 文件预览
 */
@Override
public void preview(FilePan filePan, HttpServletResponse response) {
    // 区分图片还是视频
    if (FileTypeUtil.isImage(filePan.getFileName())) {
        previewImg(filePan, response);
    } else if (FileTypeUtil.isVideo(filePan.getFileName())) {
        previewVideo(filePan, response);
    } else {
        throw new CenterExceptionHandler("该文件不支持预览");
    }
}

/**
 * 图片预览
 *
 * @param filePan
 * @param response
 */
private void previewImg(FilePan filePan, HttpServletResponse response) {
    if (StrUtil.isEmpty(filePan.getFileId())) {
        return;
    }
    // 源文件路径
    String realTargetFile = filePan.getFilePath();
    File file = new File(filePan.getFilePath());
    if (!file.exists()) {
        return;
    }
    readFile(response, realTargetFile);
}

/**
 * 视频预览
 *
 * @param filePan
 * @param response
 */
private void previewVideo(FilePan filePan, HttpServletResponse response) {
    // 根据文件名称创建对应的MD5文件夹
    String md5Dir = FileChunkUtil.createMd5Dir(filePan.getFilePath());
    // 去这个目录下查看是否有index.m3u8这个文件
    String m3u8Path = md5Dir + "/" + FileConstants.M3U8_NAME;
    if (!FileUtil.exist(m3u8Path)) {
        // 创建视频ts文件
        createVideoTs(filePan.getFilePath(), filePan.getFileId(), md5Dir, response);
    } else {
        // 读取切片文件
        readFile(response, m3u8Path);
    }
}

// 创建视频切片文件
private void createVideoTs(String videoPath, String fileId, String targetPath, HttpServletResponse response) {
    // 1.生成ts文件
    String video_2_TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -bsf:v h264_mp4toannexb %s";
    String tsPath = targetPath + "/" + FileConstants.TS_NAME;
    String cmd = String.format(video_2_TS, videoPath, tsPath);
    ProcessUtils.executeCommand(cmd, false);

    // 2.创建切片文件
    String ts_chunk = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 60 %s/%s_%%4d.ts";
    String m3u8Path = targetPath + "/" + FileConstants.M3U8_NAME;
    cmd = String.format(ts_chunk, tsPath, m3u8Path, targetPath, fileId);
    ProcessUtils.executeCommand(cmd, false);

    // 删除index.ts文件
    FileUtil.del(tsPath);

    // 读取切片文件
    readFile(response, m3u8Path);
}

// 加载视频切片文件
@Override
public void readFileTs(String tsFileId, HttpServletResponse response) {
    String[] tsArray = tsFileId.split("_");
    String videoFileId = tsArray[0];
    LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();
    qw.eq(FilePan::getFileId, videoFileId);
    FilePan one = this.getOne(qw);
    // 获取文件对应的MD5文件夹地址
    String md5Dir = FileChunkUtil.createMd5Dir(one.getFilePath());
    // 去MD5目录下读取ts分片文件
    String tsFile = md5Dir + "/" + tsFileId;
    readFile(response, tsFile);
}

用到的几个工具类代码

FileTypeUtil

package com.szx.usercenter.util;

/**
 * @author songzx
 * @create 2024-06-07 13:39
 */
public class FileTypeUtil {
    /**
     * 是否是图片类型的文件
     */
    public static boolean isImage(String fileName) {
        String[] imageSuffix = {"jpg", "jpeg", "png", "gif", "bmp", "webp"};
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        for (String s : imageSuffix) {
            if (s.equals(suffix)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 是否是视频文件
     */
    public static boolean isVideo(String fileName) {
        String[] videoSuffix = {"mp4", "avi", "rmvb", "mkv", "flv", "wmv"};
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        for (String s : videoSuffix) {
            if (s.equals(suffix)) {
                return true;
            }
        }
        return false;
    }
}

FileChunkUtil

package com.szx.usercenter.util;

import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.digest.MD5;

import java.io.File;

/**
 * 文件上传后的各种处理操作
 * @author songzx
 * @create 2024-06-07 13:25
 */
public class FileChunkUtil {
    /**
     * 合并完文件后根据文件名称创建MD5目录
     * 用于存放文件缩略图
     */
    public static String createMd5Dir(String filePath) {
        File targetFile = new File(filePath);
        String md5Dir = MD5.create().digestHex(targetFile.getName());
        String targetDir = targetFile.getParent() + File.separator + md5Dir;
        FileUtil.mkdir(targetDir);
        return targetDir;
    }
}

readFile

/**
 * 读取文件方法
 *
 * @param response
 * @param filePath
 */
public static void readFile(HttpServletResponse response, String filePath) {
    OutputStream out = null;
    FileInputStream in = null;
    try {
        File file = new File(filePath);
        if (!file.exists()) {
            return;
        }
        in = new FileInputStream(file);
        byte[] byteData = new byte[1024];
        out = response.getOutputStream();
        int len = 0;
        while ((len = in.read(byteData)) != -1) {
            out.write(byteData, 0, len);
        }
        out.flush();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (out != null) {
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (in != null) {
            try {
                in.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

ProcessUtils

这个方法用于执行CMD命令

package com.szx.usercenter.util;

import com.szx.usercenter.handle.CenterExceptionHandler;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

/**
 * 可以执行命令行命令的工具
 *
 * @author songzx
 * @create 2024-06-06 8:56
 */
public class ProcessUtils {
  private static final Logger logger = LoggerFactory.getLogger(ProcessUtils.class);

  public static String executeCommand(String cmd, Boolean outPrintLog) {
    if (StringUtils.isEmpty(cmd)) {
      logger.error("--- 指令执行失败!---");
      return null;
    }

    Runtime runtime = Runtime.getRuntime();
    Process process = null;
    try {
      process = Runtime.getRuntime().exec(cmd);
      // 取出输出流
      PrintStream errorStream = new PrintStream(process.getErrorStream());
      PrintStream inputStream = new PrintStream(process.getInputStream());
      errorStream.start();
      inputStream.start();
      // 获取执行的命令信息
      process.waitFor();
      // 获取执行结果字符串
      String result = errorStream.stringBuffer.append(inputStream.stringBuffer + "\n").toString();
      // 输出执行的命令信息
      if (outPrintLog) {
        logger.info("执行命令:{},已执行完毕,执行结果:{}", cmd, result);
      } else {
        logger.info("执行命令:{},已执行完毕", cmd);
      }
      return result;
    } catch (Exception e) {
      e.printStackTrace();
      throw new CenterExceptionHandler("命令执行失败");
    } finally {
      if (null != process) {
        ProcessKiller processKiller = new ProcessKiller(process);
        runtime.addShutdownHook(processKiller);
      }
    }
  }

  private static class ProcessKiller extends Thread {
    private Process process;

    public ProcessKiller(Process process) {
      this.process = process;
    }

    @Override
    public void run() {
      this.process.destroy();
    }
  }

  static class PrintStream extends Thread {
    InputStream inputStream = null;
    BufferedReader bufferedReader = null;

    StringBuffer stringBuffer = new StringBuffer();

    public PrintStream(InputStream inputStream) {
      this.inputStream = inputStream;
    }

    @Override
    public void run() {
      try {
        if (null == inputStream) {
          return;
        }
        bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
        String line = null;
        while ((line = bufferedReader.readLine()) != null) {
          stringBuffer.append(line);
        }
      } catch (Exception e) {
        logger.error("读取输入流出错了!错误信息:" + e.getMessage());
      } finally {
        try {
          if (null != bufferedReader) {
            bufferedReader.close();
          }
          if (null != inputStream) {
            inputStream.close();
          }
        } catch (IOException e) {
          logger.error("关闭流时出错!");
        }
      }
    }
  }
}

前端方法实现

前端使用的是React

定义图片预览组件 PreviewImage

import React, { forwardRef, useImperativeHandle } from 'react';
import {
  DownloadOutlined,
  UndoOutlined,
  RotateLeftOutlined,
  RotateRightOutlined,
  SwapOutlined,
  ZoomInOutlined,
  ZoomOutOutlined,
} from '@ant-design/icons';
import { Image, Space } from 'antd';

const PreviewImage: React.FC = forwardRef((props, ref) => {
  const [src, setSrc] = React.useState('');

  const showPreview = (fileId: string) => {
    setSrc(`/api/pan/preview/${fileId}`);
    document.getElementById('previewImage').click();
  };

  useImperativeHandle(ref, () => {
    return {
      showPreview,
    };
  });

  const onDownload = () => {
    fetch(src)
      .then((response) => response.blob())
      .then((blob) => {
        const url = URL.createObjectURL(new Blob([blob]));
        const link = document.createElement('a');
        link.href = url;
        link.download = 'image.png';
        document.body.appendChild(link);
        link.click();
        URL.revokeObjectURL(url);
        link.remove();
      });
  };

  return (
    <Image
      id={'previewImage'}
      style={{ display: 'none' }}
      src={src}
      preview={{
        toolbarRender: (
          _,
          {
            transform: { scale },
            actions: {
              onFlipY,
              onFlipX,
              onRotateLeft,
              onRotateRight,
              onZoomOut,
              onZoomIn,
              onReset,
            },
          },
        ) => (
          <Space size={12} className="toolbar-wrapper">
            <DownloadOutlined onClick={onDownload} />
            <SwapOutlined rotate={90} onClick={onFlipY} />
            <SwapOutlined onClick={onFlipX} />
            <RotateLeftOutlined onClick={onRotateLeft} />
            <RotateRightOutlined onClick={onRotateRight} />
            <ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
            <ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
            <UndoOutlined onClick={onReset} />
          </Space>
        ),
      }}
    />
  );
});

export default PreviewImage;

定义视频预览组件

视频预览用到了 dplayer ,安装

pnpm add dplayer hls.js
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import DPlayer from 'dplayer';
import './style/video-model.less';

const Hls = require('hls.js');

const PreviewVideo = forwardRef((props, ref) => {
  let dp = useRef();

  const [modal2Open, setModal2Open] = useState(false);
  const [fileId, setFileId] = useState('');

  const showPreview = (fileId) => {
    setFileId(fileId);
    setModal2Open(true);
  };

  const hideModal = () => {
    setModal2Open(false);
  };

  const clickModal = (e) => {
    if (e.target.dataset.tagName === 'parentBox') {
      hideModal();
    }
  };

  useEffect(() => {
    if (modal2Open) {
      console.log(fileId, 'videovideovideo');
      dp.current = new DPlayer({
        container: document.getElementById('video'), // 注意:这里一定要写div的dom
        lang: 'zh-cn',
        video: {
          url: `/api/pan/preview/${fileId}`, // 这里填写.m3u8视频连接
          type: 'customHls',
          customType: {
            customHls: function (video) {
              const hls = new Hls();
              hls.loadSource(video.src);
              hls.attachMedia(video);
            },
          },
        },
      });
      dp.current.play();
    }
  }, [modal2Open]);

  useImperativeHandle(ref, () => {
    return {
      showPreview,
    };
  });

  return (
    <>
      {modal2Open && (
        <div className={'video-box'} data-tag-name={'parentBox'} onClick={clickModal}>
          <div id="video"></div>
          <button className="ant-image-preview-close" onClick={hideModal}>
            <span role="img" aria-label="close" className="anticon anticon-close">
              <svg
                fill-rule="evenodd"
                viewBox="64 64 896 896"
                focusable="false"
                data-icon="close"
                width="1em"
                height="1em"
                fill="currentColor"
                aria-hidden="true"
              >
                <path d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"></path>
              </svg>
            </span>
          </button>
        </div>
      )}
    </>
  );
});

export default PreviewVideo;

父组件引入并使用

import PreviewImage from '@/components/Preview/PreviewImage';
import PreviewVideo from '@/components/Preview/PreviewVideo';

const previewRef = useRef();
const previewVideoRef = useRef();

// 点击的是文件
const clickFile = async (item) => {
    // 预览图片
    if (isImage(item.fileType)) {
        previewRef.current.showPreview(item.fileId);
        return;
    }

    // 预览视频
    if (isVideo(item.fileType)) {
        previewVideoRef.current.showPreview(item.fileId);
        return;
    }

    message.error('暂不支持预览该文件');
};

// 点击的文件夹
const clickFolder = (item) => {
    props.pushBread(item);  // 更新面包屑
};

// 点击某一行时触发
const clickRow = (item: { fileType?: string }) => {
    if (item.fileType) {
        clickFile(item);
    } else {
        clickFolder(item);
    }
};

<PreviewImage ref={previewRef} />
<PreviewVideo ref={previewVideoRef} />

判断文件类型的方法

// 判断文件是否为图片
export function isImage(fileType): boolean {
  const imageTypes = ['.jpg', '.png', '.jpeg', '.gif', '.bmp', '.webp']
  return imageTypes.includes(fileType);
}

// 判断是否为视频
export function isVideo(fileType): boolean {
  const videoTypes = ['.mp4', '.avi', '.rmvb', '.mkv', '.flv', '.wmv']
  return videoTypes.includes(fileType);
}

实现效果

图片预览效果

image-20240608121403097

视频预览效果

image-20240608121438971

并且在播放过程中是分段加载的视频

image-20240608121550836

查看源文件,根据文件名创建一个MD5的文件夹

image-20240608121012668

文件夹中对视频进行了分片处理,每一片都是以文件ID开头,方便加载分片时找到分片对应的位置

image-20240608121809812

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1803735.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

openGauss系数据库逻辑复制实现双写

本篇关于逻辑复制实现双写的案例&#xff0c;本来准备了3个环境&#xff0c;分别是306、501和505&#xff0c;奈何在5版本向3版本订阅的时候&#xff0c;出现了报错&#xff0c;但也将整个过程都记录下来吧。 环境准备 节点信息 MogDB# select version(); …

【Linux】进程间通信之匿名管道

&#x1f466;个人主页&#xff1a;Weraphael ✍&#x1f3fb;作者简介&#xff1a;目前正在学习c和算法 ✈️专栏&#xff1a;Linux &#x1f40b; 希望大家多多支持&#xff0c;咱一起进步&#xff01;&#x1f601; 如果文章有啥瑕疵&#xff0c;希望大佬指点一二 如果文章对…

易飞销货单出货时审核库存检查

公司接到一客户因品种多而数量少&#xff0c;单一出货计划行比较多&#xff0c;而只上了生产ERP易飞&#xff0c;审核时经常会出现倒催货&#xff0c;提前做销售单&#xff0c;行数有时超30行以上&#xff0c;审核跳窗报错时也不方便查找&#xff0c;特写一外挂程序&#xff0c…

代码随想录算法训练营第36期DAY49

DAY49 139单词拆分 没有思路。 回溯法 回溯怎么做呢&#xff1a;拼接str&#xff0c;看能不能拼出来。注意每个单词能用多次&#xff0c;不是用了就没。 但是语法还是难写。 自己的思路不好&#xff0c;题解思路&#xff1a;枚举所有分割字符串&#xff0c;判断是否在字典…

红队神器Evil-winrm的使用

前言 Evil-winrm 工具最初是由 Hackplayers 团队开发的。开发该工具的目的是尽可能简化渗透测试&#xff0c;尤其是在 Microsoft Windows 环境中。 Evil-winrm 使用 PowerShell 远程协议 (PSRP)&#xff0c;且系统和网络管理员经常使用Windows Remote Management 协议进行上传和…

哈尔滨三级等保测评需要测哪些设备?

哈尔滨三级等保测评需要测的设备&#xff0c;主要包括物理安全设备、网络安全设备和应用安全设备三大类别。这些设备在保障哈尔滨地区信息系统安全方面发挥着至关重要的作用。 首先&#xff0c;物理安全设备是确保信息系统实体安全的基础。在哈尔滨三级等保测评中&#xff0c;物…

Deepin安装PostGresql

最近要把开发环境完全从Windows移到Deepin上&#xff0c;本次介绍在Deepin借助apt-get安装和配置数据库。同时可以用Dbever提供图形化管理工具。 安装PostGreSQL数据库和创建数据库 #安装postgresql zhanglianzhuzhanglianzhu-PC:/$ sudo apt-get install postgresql-16 正在…

PyCharm中 Fitten Code插件的使用说明一

一. 简介 Fitten Code插件是是一款由非十大模型驱动的 AI 编程助手&#xff0c;它可以自动生成代码&#xff0c;提升开发效率&#xff0c;帮您调试 Bug&#xff0c;节省您的时间&#xff0c;另外还可以对话聊天&#xff0c;解决您编程碰到的问题。 前一篇文章学习了 PyCharm…

mysql数据库密码破解

一、Mysql数据库密码破解 ①一旦获取了网站一定的权限后&#xff0c;如果能够获取MSQL中保存用户数据&#xff0c;通过解密后&#xff0c;即可通过正常途径来访问数据库;一方面可以直接操作数据库中的数据&#xff0c;另一方面可以用来提升权限。 ②MySQL数据库用户密码跟其它…

安泰高压放大器应用领域分享:介电电泳(DEP)技术的具体应用

介电电泳&#xff08;Dielectrophoresis—DEP&#xff09;技术描述的是位于非匀称电场的中性微粒由于介电极化的作用而产生的平移运动。产生在微粒上的偶极矩可以有两个相同带电量但极性相反的电荷来表示&#xff0c;当它们在微粒界面上不对称分布时&#xff0c;产生一个宏观的…

小型柴油发电机不发电的原因

小型柴油发电机不发电的原因 小型柴油发电机不发电的原因可能有多种&#xff0c;以下是一些常见的原因&#xff1a; 发动机问题&#xff1a; 发动机油路不通畅&#xff0c;可能导致燃油无法顺利到达燃烧室。 气缸压缩不正常&#xff0c;影响发动机的正常工作。 润滑油粘度过大…

【c语言】自定义类型----结构体

结构体是c语言的一种自定义类型&#xff0c;自定义类型对于开发者及其重要的类型&#xff0c;它可以随意由开发者进行谱写功能&#xff0c;而今天的结构体可以用来表示一种变量的单个或多种具体属性&#xff0c;再编写代码时有着不可替代的作用&#xff01;&#xff01;&#x…

Android 代码打印meminfo

旨在替代adb shell dumpsys meminfo packageName&#xff0c;在log打印meminfo&#xff0c;以便分析内存情况 ActivityManager.MemoryInfo memoryInfo new ActivityManager.MemoryInfo(); activityManager.getMemoryInfo(memoryInfo); long totalMemory Runtime.getRuntime(…

绘唐官网绘唐科技

绘唐AI工具是一种基于人工智能技术的绘画辅助工具。 使用教程&#xff1a;https://iimenvrieak.feishu.cn/docx/CWwldSUU2okj0wxmnA0cHOdjnF 它可以根据用户提供的输入或指令生成各种类型的图像。 绘唐AI工具可以理解用户的绘画需求&#xff0c;并根据用户的要求生成具有艺术…

Python爬取与可视化-豆瓣电影数据

引言 在数据科学的学习过程中&#xff0c;数据获取与数据可视化是两项重要的技能。本文将展示如何通过Python爬取豆瓣电影Top250的电影数据&#xff0c;并将这些数据存储到数据库中&#xff0c;随后进行数据分析和可视化展示。这个项目涵盖了从数据抓取、存储到数据可视化的整个…

【python解决】查询报%d format: a number is required, not str问题

【Python解决】查询报%d format: a number is required, not str问题 在Python中&#xff0c;字符串格式化是一种常见的操作&#xff0c;用于创建包含变量的字符串。如果你在使用%操作符进行格式化时遇到了%d format: a number is required, not str的错误&#xff0c;这意味着…

当前主流的App开发技术综述

一、引言 随着移动互联网的蓬勃发展&#xff0c;App&#xff08;应用程序&#xff09;已经成为人们日常生活中不可或缺的一部分。无论是社交、购物、娱乐还是工作学习&#xff0c;App都以其便捷、高效和个性化的特点深受用户喜爱。而在这一过程中&#xff0c;App开发技术也在不…

3D39无人机摇杆电位器食用指南

这个摇杆精度会非常的不错&#xff0c;虚位只在后面有一点&#xff0c;当然价格也比较贵。最便宜某宝上也得 &#xffe5;15 一个。 使用这个摇杆和使用2块钱一个的 PS 写的代码都一样&#xff0c;只是注意下接线上的一些问题就行。 需要注意两个ADC引脚最好不要挨着&#xf…

加热炉钢坯温度计算传热学应用

非常感谢“计算传热学大叔”&#xff0c;大家了解更多&#xff0c;请移步前期文章&#xff1a;https://blog.csdn.net/weixin_37928884/article/details/127709215 第一类边界条件 clc clear close all %直接在此修改参数 length 0.135; %长度 Tb 930; %初始…

记录一次springboot、ruoyi若依前后端不分离项目和vue项目的合并整合问题,搞了一天总结

项目场景&#xff1a; 因为此功能只做为客户方一个小模块&#xff0c;客户方使用的是springboot前后端不分离的架构。而我们的项目是使用前后端分离springbootvue的架构。在接项目前&#xff0c;项目已经存在&#xff0c;所以不存在设计架构的前提。实际是在原有基础上修改的。…