WeChat アプレットは、vant と springboot に基づいて添付ファイルのアップロードとプレビューを実装します。

序文

画像のアップロードやプレビューは、モバイル端末上で広く頻繁に利用されており、vant コンポーネントライブラリの van-uploader コンポーネントにより、ほとんどの機能を実現できていますが、システム内で頻繁に使用するにはまだ少し面倒です。当社独自の業務システムに合わせて簡素化して開発しました。バックエンドは springboot を使用して jcifs を統合し、ファイル管理マイクロサービスを実装します。

ファイルの更新

添付ファイルのプレビュー

フロントエンドコンポーネント

コンポーネントの紹介

フロントエンドはビュー内のコンポーネントを使用し、必要なパラメータを渡すだけで済みます。

ビジネスID ビジネス ID。業務注文番号を添付ファイルに関連付けるために使用されます。
tmp_id 一時的なビジネス ID。最初に業務注文番号が生成されない場合に添付ファイルに関連付けられます。
名前 さまざまな種類の添付ファイルをさまざまなフォルダに分類するために使用されます。
事業の種類 同じ業務注文番号の下で異なるタイプの添付グループを区別するために使用されます。
読み取り専用 コンポーネントがプレビューされるかアップロードされるかを決定する
<bdog-uploader-image      
	id="visit_uploader"
	businessid="{
   
   {id}}"
	tmp_id="{
   
   {tmp_id}}"
	name="customerVisit"
	businesstype="visit"
    readonly ="{
   
   {readonly}}"
/>  

コンポーネントjsパートコード

const util = require('../../utils/util.js')
var request = require('../../utils/request.js')
var { config } = require('../../utils/config.js')
import Toast from '@vant/weapp/toast/toast';
const app = getApp();

Component({
  properties: {
    businessid: {
      type: String
    },
    tmp_id: {
        type: String
    },
    name: {
        type: String
    },
    businesstype: {
        type: String
    },
    readonly:{
        type:Boolean
    }
  },
  data: {
    fileList: [],
  },
  attached:function(){
    //this.getFileList()
  },
  methods: {
     afterRead(event) {
        Toast.loading({
            duration: 0, // 持续展示 toast
            forbidClick: true,
            message: "上传中"
        })
        var that = this
        const { file } = event.detail
        wx.uploadFile({
          url: config.baseUrl +'/MpAttachment/uploadFile', 
          filePath: file.url,
          name: 'file',
          header: {
            "Content-Type": "multipart/form-data",
            "id":that.data.businessid,
            "tmpId":that.data.tmp_id,
            "name":that.data.name,
            "businesstype":that.data.businesstype,
            "token":app.globalData.userInfo.token,
          },
          success(res) {
            const data = JSON.parse(res.data)
            if(data.code == 200){
                // 上传完成需要更新 fileList
                const { fileList = [] } = that.data;
                const url = config.baseUrl +'/MpAttachment/getImage?id=' + data.data.id
                fileList.push({ ...file, url: url, id: data.data.id })
                that.setData({ fileList })
                Toast.clear();
                Toast({ type: 'success',message: '上传成功',duration:500, })
            }else{
                Toast({ type: 'fail',message: '上传失败',duration:500, })
            }
          },
          fail:function(res){
            Toast({ type: 'fail',message: '上传失败',duration:500, })
          }
        });
      },
      delete(event) {
        Toast.loading({
            duration: 0, // 持续展示 toast
            forbidClick: true,
            message: "删除中"
        })
        var that = this
        var data = {}
        data['id'] = event.detail.file.id
        request.get('/MpAttachment/delete',data)
        .then(function (res) {
          if(res.code == 200){
            const { fileList } = that.data;
            const newFileList = fileList.filter((items) =>{
              return items.id != event.detail.file.id
            })
            that.setData({ fileList : newFileList, })
            Toast.clear();
            Toast({ type: 'success',message: '删除成功',duration:500, })
          }else{
            Toast({ type: 'fail',message: '删除失败',duration:500, })
          }
        }, function (error) {
            Toast({ type: 'fail',message: '删除失败',duration:500, })
        })
      },
      getFileList() {
        var that = this
        var data = {}
        data['businessid'] = that.data.businessid
        data['businesstype'] = that.data.businesstype
        request.get('/MpAttachment/getList',data)
        .then(function (res) {
          if(res.code == 200){
            const fileList = res.data;
            fileList.forEach(function(items){
                items.url = config.baseUrl + '/MpAttachment/getImage?id=' + items.id
                items.type = 'image'
            })
            that.setData({ fileList : fileList, })
          }else{
            Toast({ type: 'fail',message: '附件加载失败',duration:500, })
          }
        }, function (error) {
            Toast({ type: 'fail',message: '附件加载失败',duration:500, })
        })
      }
  }

})

コンポーネントビューの部品コード

<van-cell title="" >
    <van-uploader
        slot="right-icon"
        file-list="{
   
   { fileList }}"
        max-count="9"
        bind:after-read="afterRead"
        bind:delete="delete"  
        show-upload="{
   
   { !readonly }}"
        deletable="{
   
   { !readonly }}"
    />
</van-cell>
<van-toast id="van-toast" />

バックエンドマイクロサービス

バックエンドマイクロサービス

マイクロサービスには、添付ファイルのアップロード、削除、画像取得、リスト取得、および添付ファイルのアップロード サービスが常に含まれます。

​​​​​​​

 

 マイクロサービスコード

@RestController
@RequestMapping("/MpAttachment")
@Api(tags = { Swagger2Config.TAG_MpAttachment })
public class MpAttachmentController implements ServletContextAware {

    protected HttpServletRequest request;
    protected HttpServletResponse response;
    protected HttpSession session;
    protected ServletContext servletContext;
    String FileConnect ="/";
    @Autowired
    protected UserService userService;
    @Autowired
    @Qualifier("dispJdbcTemplate")
    protected JdbcTemplate dispJdbcTemplate;
    @Autowired
    protected MpAttachmentService mpAttachmentService;


    @ApiOperation(value = "获取列表", notes = "")
    @GetMapping(value="/getList")
    public Result getList(@ApiParam(value = "businessid" , required=true ) @RequestParam String businessid,
                          @ApiParam(value = "businesstype" , required=false ) @RequestParam String businesstype) throws ParseException {
        List list =  mpAttachmentService.getViewList(businessid,businesstype);
        return Result.success(list,"成功!");
    }

    @CrossOrigin
    @ApiOperation(value = "附件上传", notes = "")
    @PostMapping("/uploadFile")
    public Result uploadFile(@RequestParam("file") MultipartFile file, @RequestHeader("name") String name, @RequestHeader String id, @RequestHeader String tmpId, @RequestHeader String businesstype) {
        if (file.isEmpty()) {
            return Result.failed("上传文件为空");
        }
        String uuid = UUID.randomUUID().toString();
        // 获取文件名
        String fileName = file.getOriginalFilename();
        String newFileName = uuid + "."+ fileName.split("\\.")[1];
        MpAttachment attachment = new MpAttachment();
        attachment.setBusinessid(id);
        attachment.setTmp_businessid(tmpId);
        attachment.setBusinesstype(businesstype);
        attachment.setFilename(fileName);
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd");
        String uploadPath = name + FileConnect + LocalDate.now().format(fmt);
        attachment.setFilepath(uploadPath + FileConnect + newFileName);

        try {
            //文件上传
            SmbFileUtils.save(file.getBytes(),uploadPath,newFileName);
            attachment.setCreatetime(DateUtils.getNow());
            attachment.setId(UUID.randomUUID().toString());
            mpAttachmentService.add(attachment);
            return Result.success(mpAttachmentService.getView(attachment.getId()),"成功!");
        } catch (IOException e) {
            e.printStackTrace();
            return Result.failed("文件上传失败");
        }
    }

    @CrossOrigin
    @ApiOperation(value = "附件上传并添加水印", notes = "")
    @PostMapping("/uploadImageFile")
    public Result uploadImageFile(@RequestParam("file") MultipartFile file, @RequestHeader("name") String name, @RequestHeader String id, @RequestHeader String tmpId, @RequestHeader String businesstype) {
        User user = userService.findCueernt();
        if (file.isEmpty()) {
            return Result.failed("上传文件为空");
        }
        String uuid = UUID.randomUUID().toString();
        // 获取文件名
        String fileName = file.getOriginalFilename();
        String newFileName = uuid + "."+ fileName.split("\\.")[1];
        MpAttachment attachment = new MpAttachment();
        attachment.setBusinessid(id);
        attachment.setTmp_businessid(tmpId);
        attachment.setBusinesstype(businesstype);
        attachment.setFilename(fileName);
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd");
        String uploadPath = name + FileConnect + LocalDate.now().format(fmt);
        attachment.setFilepath(uploadPath + FileConnect + newFileName);

        try {
            //添加水印
            InputStream input = new ByteArrayInputStream((file.getBytes()));
            /**给图片添加文字水印**/
            ArrayList<String> watermarkList =new ArrayList<String>();
            watermarkList.add("现场拍照[客户照片]");
            watermarkList.add(user.getName() +" " + DateUtils.dateToStr(new Date(),"yyyy-MM-dd HH:mm"));
            InputStream output = ImageWatermarkUtils.markImageByText(watermarkList,input,fileName.split("\\.")[1]);
            //文件上传
            SmbFileUtils.save(FileUtils.StreamToByte(output),uploadPath,newFileName);
            attachment.setCreatetime(DateUtils.getNow());
            attachment.setId(UUID.randomUUID().toString());
            mpAttachmentService.add(attachment);
            return Result.success(mpAttachmentService.getView(attachment.getId()),"成功!");
        } catch (IOException e) {
            e.printStackTrace();
            return Result.failed("文件上传失败");
        }
    }


    @CrossOrigin
    @ApiOperation(value = "base64附件上传", notes = "")
    @PostMapping("/base64UploadFile")
    public Result base64UploadFile(@RequestBody String base64Image, @RequestHeader("fileName") String fileName, @RequestHeader("name") String name, @RequestHeader String id, @RequestHeader String tmpId, @RequestHeader String businesstype) throws UnsupportedEncodingException {
        String uuid = UUID.randomUUID().toString();
        base64Image = java.net.URLDecoder.decode(base64Image,"UTF-8");
        fileName = java.net.URLDecoder.decode(fileName,"UTF-8");
        id = java.net.URLDecoder.decode(id,"UTF-8");
        String newFileName = uuid + "."+ fileName.split("\\.")[1];
        MpAttachment attachment = new MpAttachment();
        attachment.setBusinessid(id);
        attachment.setTmp_businessid(tmpId);
        attachment.setBusinesstype(businesstype);
        attachment.setFilename(fileName);
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd");
        String uploadPath = name + FileConnect + LocalDate.now().format(fmt);
        attachment.setFilepath(uploadPath + FileConnect + newFileName);

        try {

            byte[] imageByte = ImageUtils.base64ImageToByte(base64Image);
            SmbFileUtils.save(imageByte,uploadPath,newFileName);
            attachment.setCreatetime(DateUtils.getNow());
            attachment.setId(UUID.randomUUID().toString());
            mpAttachmentService.add(attachment);
            return Result.success(mpAttachmentService.getView(attachment.getId()),"成功!");
        } catch (Exception e) {
            e.printStackTrace();
            return Result.failed("文件上传失败");
        }
    }

    @ApiOperation(value = "获取图片", notes = "")
    @GetMapping(value="/getImage", produces = {MediaType.IMAGE_PNG_VALUE})
    public BufferedImage getImage(@ApiParam(value = "id" , required=true ) @RequestParam String id) throws IOException {
        MpAttachment attachment = mpAttachmentService.get(id);
        if(attachment !=null)
        {
            InputStream imageInputStream =  SmbFileUtils.getFile(attachment.getFilepath());
            return ImageIO.read(imageInputStream);
        }
        return null;
    }

    @ApiOperation(value = "删除", notes = "")
    @GetMapping(value="/delete")
    public Result delete(@ApiParam(value = "id" , required=true ) @RequestParam String id) {
        MpAttachment attachment = mpAttachmentService.get(id);
        try {
            SmbFileUtils.delete(attachment.getFilepath());
            int result = mpAttachmentService.delete(id);
            if(result >0){
                return Result.success(attachment,"删除成功!");
            }else {
                return Result.success(attachment,"删除失败!");
            }

        } catch (Exception e) {
            e.printStackTrace();
            return Result.failed("失败");
        }

    }


    @ModelAttribute
    public void setReqAndRes(HttpServletRequest request, HttpServletResponse response){
        this.request = request;
        this.response = response;
        this.session = request.getSession();
    }
    @Override
    public void setServletContext(ServletContext servletContext) {
        this.servletContext = servletContext;
    }
}

jcifs ファイル管理ヘルパー クラス

package com.brickdog.common.utils;


import jcifs.CIFSContext;
import jcifs.CIFSException;
import jcifs.context.SingletonContext;
import jcifs.smb.*;

import java.io.*;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Objects;


public class SmbFileUtils {
    static String ip = "127.0.0.1";
    static String domain = "127.0.0.1/upload";
    static String userName = "admin";
    static String password = "admin";

    static void SmbFileUtils(){

    }
    //根据账号密码登录
    private static CIFSContext withNTLMCredentials(CIFSContext ctx) {
        return ctx.withCredentials(new NtlmPasswordAuthenticator(domain,
                userName, password));
    }

    //保存文件
    public static String save(byte[] byteArr, String url,String fileName) throws IOException {
        InputStream in = new ByteArrayInputStream(byteArr);
        String status = "";
        try {
            CIFSContext context = withNTLMCredentials(SingletonContext.getInstance());
            SmbFileWriter.createDirectory("smb://" + domain  +"/" + url, context);
            boolean result = SmbFileWriter.writeSmbFile(in, "smb://" + domain  +"/" + url +"/" + fileName, context);
            status = "success";
        } catch (Exception e) {
            e.printStackTrace();
            status = "error";
        } finally {
            in.close();
            return status;
        }
    }
    //获取文件
    public static  InputStream getFile(String filePath) throws IOException {
        String url = "smb://" + domain + "/" + filePath;

        try {
            CIFSContext context = withNTLMCredentials(SingletonContext.getInstance());
            SmbFileReader reader = new SmbFileReader();
            byte[] byteArr = reader.readSmbFile(url, context);
            InputStream input = new ByteArrayInputStream(byteArr);
            return  input;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
    //删除文件
    public static String delete(String filePath) throws IOException {
        String status = "";
        String url = "smb://" + domain + "/" + filePath;

        try {
            CIFSContext context = withNTLMCredentials(SingletonContext.getInstance());
            SmbFile file = new SmbFile(url, context);
            if (file.exists()) {
                file.delete();
                status = "success";
            }
        } catch (Exception e) {
            e.printStackTrace();
            status = "error";
        }
        return status;
    }

    static class SmbFileReader {
        public byte[] readSmbFile(String path, CIFSContext context) throws IOException {
            try  {
                SmbFile smbFile = new SmbFile(path, context);
                long fileSize = smbFile.length();
                if (fileSize > Integer.MAX_VALUE) {
                    System.out.println("file too big...");
                    return null;
                }
                InputStream fi = smbFile.getInputStream();
                byte[] buffer = new byte[(int) fileSize];
                int offset = 0;
                int numRead = 0;
                while (offset < buffer.length
                        && (numRead = fi.read(buffer, offset, buffer.length - offset)) >= 0) {
                    offset += numRead;
                }
                // 确保所有数据均被读取
                if (offset != buffer.length) {
                    throw new IOException("Could not completely read file "
                            + smbFile.getName());
                }
                fi.close();
                return buffer;
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
    }

    static class SmbFileWriter {
        static boolean writeSmbFile(String source, String target, CIFSContext context) throws IOException {
            if (StrUtils.isEmpty(source) || StrUtils.isEmpty(target)) {
                return false;
            }
            return writeSmbFile(Files.newInputStream(Paths.get(source)),
                    target, context);
        }

        static boolean writeSmbFile(InputStream in, String target, CIFSContext context) throws IOException {
            if (Objects.nonNull(in) && StrUtils.isNotEmpty(target)) {
                try (SmbFile file = new SmbFile(target, context)) {
                    try (SmbFile parent = new SmbFile(file.getParent(), context)) {
                        if (!parent.exists()) {
                            createDirectory(file.getParent(), context);
                        }
                        if (!file.exists()) {
                            file.createNewFile();
                        }
                    }
                    try (OutputStream os = file.getOutputStream()) {
                        byte[] bytes = new byte[1024];
                        while (in.read(bytes) != -1) {
                            os.write(bytes);
                        }
                        return true;
                    }
                }finally {
                    in.close();
                }
            }
            return false;
        }

        static SmbFile createDirectory(String targetDir, CIFSContext context) throws MalformedURLException,
                CIFSException, MalformedURLException {
            try (SmbFile dir = new SmbFile(targetDir, context)) {
                if (!dir.exists()) {
                    dir.mkdir();
                }
                return dir;
            }
        }
    }
}

pomファイル

ここでは、2.0 以降の jcifs パッケージを使用する必要があります。2.0 未満では、ネットワーク ディスクのアクセス許可認証がスタックすることが多く、その結果、添付ファイルの読み取りまたはアップロードが非常に遅くなります。

<dependency>
    <groupId>eu.agno3.jcifs</groupId>
    <artifactId>jcifs-ng</artifactId>
    <version>2.1.3</version>
</dependency>

ファイルを生成する

ファイルの種類ごとに分類し、添付ファイルを毎日別のフォルダーに保存します

 テーブル構造

透かしを追加する

使用事例

//添加水印
InputStream input = new ByteArrayInputStream((file.getBytes()));
/**给图片添加文字水印**/
ArrayList<String> watermarkList =new ArrayList<String>();
watermarkList.add("现场拍照[客户照片]");
watermarkList.add(user.getName() +" " + DateUtils.dateToStr(new Date(),"yyyy-MM-dd HH:mm"));
InputStream output = ImageWatermarkUtils.markImageByText(watermarkList,input,fileName.split("\\.")[1]);
//文件上传
SmbFileUtils.save(FileUtils.StreamToByte(output),uploadPath,newFileName);

画像ヘルパー

package com.brickdog.common.utils;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.ArrayList;

/**
 * 图片添加水印工具类
 * 文字水印 图片水印 利用jdk ,不依赖第三方
 */
public class ImageWatermarkUtils {

    static final String NEW_IMAGE_NAME_PRE_STR = "_water";
    // 水印透明度
    private static float alpha = 0.5f;
    // 水印文字字体
    private static Font font = new Font("宋体", Font.BOLD, 12);
    // 水印文字颜色
    private static Color color = Color.white;


    /**
     * 给图片添加水印、可设置水印图片旋转角度
     *
     * @param iconPath   水印图片路径
     * @param srcImgPath 源图片路径
     * @param targerPath 目标图片路径
     * @param degree     水印图片旋转角度
     */
    public static void markImageByIcon(String iconPath, String srcImgPath, String targerPath, Integer degree) {
        OutputStream os = null;
        try {
            if (StrUtils.isBlank(targerPath)) {
                targerPath = srcImgPath.substring(0, srcImgPath.lastIndexOf(".")) + NEW_IMAGE_NAME_PRE_STR + srcImgPath.substring(srcImgPath.lastIndexOf("."));
            }
            Image srcImg = ImageIO.read(new File(srcImgPath));
            BufferedImage buffImg = new BufferedImage(srcImg.getWidth(null), srcImg.getHeight(null), BufferedImage.TYPE_INT_RGB);
            // 得到画笔对象
            // Graphics g= buffImg.getGraphics();
            Graphics2D g = buffImg.createGraphics();

            // 设置对线段的锯齿状边缘处理
            g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);

            g.drawImage(srcImg.getScaledInstance(srcImg.getWidth(null), srcImg.getHeight(null), Image.SCALE_SMOOTH), 0, 0, null);

            if (null != degree) {
                // 设置水印旋转
                g.rotate(Math.toRadians(degree),
                        (double) buffImg.getWidth() / 2, (double) buffImg
                                .getHeight() / 2);
            }
            // 水印图象的路径 水印一般为gif或者png的,这样可设置透明度
            ImageIcon imgIcon = new ImageIcon(iconPath);
            // 得到Image对象。
            Image img = imgIcon.getImage();
            float alpha = 1f; // 透明度
            g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, alpha));
            /**
             * 以下一算水印图位置,右下角
             */
            int width = srcImg.getWidth(null);
            int height = srcImg.getHeight(null);
            int iconWidth = img.getWidth(null);
            int iconHeight = img.getHeight(null);
            int x = width - iconWidth;
            int y = height - iconHeight;
            x = x < 0 ? 0 : x;
            y = y < 0 ? 0 : y;
            // 表示水印图片的位置
            g.drawImage(img, x, y, null);
            g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER));
            g.dispose();
            os = new FileOutputStream(targerPath);
            // 生成图片
            ImageIO.write(buffImg, "JPG", os);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (null != os)
                    os.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 给图片添加水印文字、可设置水印文字的旋转角度
     *
     */
    public static InputStream markImageByText(ArrayList<String> watermarkList, InputStream imageInputStream,String formatName) {

        try {

            // 1、源图片
            Image srcImg = ImageIO.read(imageInputStream);
            int srcImgWidth = srcImg.getWidth(null);
            int srcImgHeight = srcImg.getHeight(null);
            BufferedImage buffImg = new BufferedImage(srcImgWidth, srcImgHeight, BufferedImage.TYPE_INT_RGB);

            // 2、得到画笔对象
            Graphics2D g = buffImg.createGraphics();
            // 3、设置对线段的锯齿状边缘处理
            g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
            g.drawImage(
                    srcImg.getScaledInstance(srcImg.getWidth(null),
                            srcImg.getHeight(null), Image.SCALE_SMOOTH), 0, 0, null);
            // 4、设置黑色遮罩
            int rowHeight = 20;
            int padding = 6;
            int height = rowHeight * watermarkList.size() + padding;
            int x = padding;
            int y = srcImgHeight - height;
            g.setColor(Color.black);
            g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.3f));
            g.fillRect(0,y,srcImgWidth,height);

            // 5、设置水印文字颜色
            g.setColor(color);
            // 6、设置水印文字Font
            g.setFont(font);
            // 7、设置水印文字透明度
            g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1f));
            // 8、第一参数->设置的内容,后面两个参数->文字在图片上的坐标位置(x,y)
            for(int i=0;i<watermarkList.size();i++)
            {
               g.drawString(watermarkList.get(i), x, y + rowHeight);
               y =y+rowHeight;
            }
            // 9、释放资源
            g.dispose();
            // 10、生成图片
            ByteArrayOutputStream os = new ByteArrayOutputStream();

            ImageIO.write(buffImg, formatName, os);
            InputStream  outStream = new ByteArrayInputStream(os.toByteArray());

            return  outStream;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {

            } catch (Exception e) {
                e.printStackTrace();
            }
            try {

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return  null;
    }

    /**
     * 获取水印文字总长度
     *
     * @param waterMarkContent
     *            水印的文字
     * @param g
     * @return 水印文字总长度
     */
    private static int getWatermarkLength(String waterMarkContent, Graphics2D g) {
        return g.getFontMetrics(g.getFont()).charsWidth(waterMarkContent.toCharArray(), 0, waterMarkContent.length());
    }

}

参考文献

https://github.com/codelibs/jcifs
https://github.com/xuanyiying/jcifs-ng-smb2-demo

おすすめ

転載: blog.csdn.net/qq243348167/article/details/126819511