SSH实现带进度条的Ajax文件上传

相信大家还没有做过这个功能的时候,在其他的博客上面也找到了很多类似的资料,但是对于一些新手来说,很难理解其中的意思。下面这段话是我从其他博客摘取下来的,说的是在Struts2中实现这个功能的原理:

  • 利用Ajax在客户端一直查询服务器端的上传进度,取得进度的状态文本信息(xml,json格式的文本等),然后利用JS解析,显示在前台。
  • 在Struts2. 0中,框架事先已经定义一种监听器:ProgressListener(进度监听器),里面有一个update(long readedBytes, long totalBytes, int currentItem)方法,其中,readedBytes是已经上传到服务器的位数,而totalBytes是上传文件总位数.当文件已二进制的方式上传时,每上传一部分数据,就会调用这个方法一次。故要实现监听进度,必须实现这个接口,并实现update方法,在update方法中保存这个进度到session。当客服端需要进度的信息时,只需要访问某个action,在这个action中读取session中保存的进度状态就可以了。
相信大家看了这段话之后很难理解其中的意思,我当时也是这样的。但是这个文件上传的功能必须实现,那么这个显示进度的功能也必须实现,所以无论如何我们也得实现这个功能,这是一件很痛苦的事情。 但是,经过小编的理解之后,觉得有更好的方式解读这两句话。首先看一个图吧:

看了这个图之后是不是顿时思路清晰多了?当然这里只是知道了大概的思路,但是具体怎么做还没有体现出来。
现在我们开始动手实现具体的细节吧。
首先我这里是使用ssh框架进行开发的,还不会使用ssh框架的同学请自学,主要是Struts2框架。
然后是ajaxfileupload.js这个文件,这个是JQuery的一个插件,用于实现无刷新文件上传。下载链接在这里点击打开链接
然后是,前端我用了Bootstrap框架,进度条的显示的样式使用了Bootstrap的默认样式。
然后是准备自定义的MultiPartRequest类:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package util.upload;

import com.opensymphony.xwork2.LocaleProvider;
import com.opensymphony.xwork2.inject.Inject;
import com.opensymphony.xwork2.util.LocalizedTextUtil;
import com.opensymphony.xwork2.util.logging.Logger;
import com.opensymphony.xwork2.util.logging.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.RequestContext;
import org.apache.commons.fileupload.FileUploadBase.SizeLimitExceededException;
import org.apache.commons.fileupload.disk.DiskFileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest;
import org.apache.struts2.dispatcher.multipart.MultiPartRequest;

public class MyMultiPartRequest implements MultiPartRequest {
    static final Logger LOG = LoggerFactory.getLogger(JakartaMultiPartRequest.class);
    protected Map<String, List<FileItem>> files = new HashMap();
    protected Map<String, List<String>> params = new HashMap();
    protected List<String> errors = new ArrayList();
    protected long maxSize;
    private Locale defaultLocale;

    public MyMultiPartRequest() {
        this.defaultLocale = Locale.ENGLISH;
    }

    @Inject("struts.multipart.maxSize")
    public void setMaxSize(String maxSize) {
        this.maxSize = Long.parseLong(maxSize);
    }

    @Inject
    public void setLocaleProvider(LocaleProvider provider) {
        this.defaultLocale = provider.getLocale();
    }

    public void parse(HttpServletRequest request, String saveDir) throws IOException {
        String errorMessage;
        try {
            this.setLocale(request);
            this.processUpload(request, saveDir);
        } catch (SizeLimitExceededException var5) {
            if(LOG.isWarnEnabled()) {
                LOG.warn("Request exceeded size limit!", var5, new String[0]);
            }

            errorMessage = this.buildErrorMessage(var5, new Object[]{Long.valueOf(var5.getPermittedSize()), Long.valueOf(var5.getActualSize())});
            if(!this.errors.contains(errorMessage)) {
                this.errors.add(errorMessage);
            }
        } catch (Exception var6) {
            if(LOG.isWarnEnabled()) {
                LOG.warn("Unable to parse request", var6, new String[0]);
            }

            errorMessage = this.buildErrorMessage(var6, new Object[0]);
            if(!this.errors.contains(errorMessage)) {
                this.errors.add(errorMessage);
            }
        }

    }

    protected void setLocale(HttpServletRequest request) {
        if(this.defaultLocale == null) {
            this.defaultLocale = request.getLocale();
        }

    }

    protected String buildErrorMessage(Throwable e, Object[] args) {
        String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();
        if(LOG.isDebugEnabled()) {
            LOG.debug("Preparing error message for key: [#0]", new String[]{errorKey});
        }

        return LocalizedTextUtil.findText(this.getClass(), errorKey, this.defaultLocale, e.getMessage(), args);
    }

    protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException {
        Iterator i$ = this.parseRequest(request, saveDir).iterator();

        while(i$.hasNext()) {
            FileItem item = (FileItem)i$.next();
            if(LOG.isDebugEnabled()) {
                LOG.debug("Found item " + item.getFieldName(), new String[0]);
            }

            if(item.isFormField()) {
                this.processNormalFormField(item, request.getCharacterEncoding());
            } else {
                this.processFileField(item);
            }
        }

    }

    protected void processFileField(FileItem item) {
        if(LOG.isDebugEnabled()) {
            LOG.debug("Item is a file upload", new String[0]);
        }

        if(item.getName() != null && item.getName().trim().length() >= 1) {
            Object values;
            if(this.files.get(item.getFieldName()) != null) {
                values = (List)this.files.get(item.getFieldName());
            } else {
                values = new ArrayList();
            }

            ((List)values).add(item);
            this.files.put(item.getFieldName(), (List<FileItem>) values);
        } else {
            LOG.debug("No file has been uploaded for the field: " + item.getFieldName(), new String[0]);
        }
    }

    protected void processNormalFormField(FileItem item, String charset) throws UnsupportedEncodingException {
        if(LOG.isDebugEnabled()) {
            LOG.debug("Item is a normal form field", new String[0]);
        }

        Object values;
        if(this.params.get(item.getFieldName()) != null) {
            values = (List)this.params.get(item.getFieldName());
        } else {
            values = new ArrayList();
        }

        if(charset != null) {
            ((List)values).add(item.getString(charset));
        } else {
            ((List)values).add(item.getString());
        }

        this.params.put(item.getFieldName(), (List<String>) values);
        item.delete();
    }

    protected List<FileItem> parseRequest(HttpServletRequest servletRequest, String saveDir) throws FileUploadException {
        UploadStatus status = new UploadStatus(); // 上传状态
        UploadListener listner = new UploadListener(status); // 监听器
        servletRequest.getSession().setAttribute("uploadStatus", status); // 把状态放到session里去

        DiskFileItemFactory fac = createDiskFileItemFactory(saveDir);
        ServletFileUpload upload = new ServletFileUpload(fac);
        upload.setSizeMax(maxSize);
        upload.setProgressListener(listner);// 添加监听器

        return upload.parseRequest(createRequestContext(servletRequest));
    }

    protected ServletFileUpload createServletFileUpload(DiskFileItemFactory fac) {
        ServletFileUpload upload = new ServletFileUpload(fac);
        upload.setSizeMax(this.maxSize);
        return upload;
    }

    protected DiskFileItemFactory createDiskFileItemFactory(String saveDir) {
        DiskFileItemFactory fac = new DiskFileItemFactory();
        fac.setSizeThreshold(0);
        if(saveDir != null) {
            fac.setRepository(new File(saveDir));
        }

        return fac;
    }

    public Enumeration<String> getFileParameterNames() {
        return Collections.enumeration(this.files.keySet());
    }

    public String[] getContentType(String fieldName) {
        List items = (List)this.files.get(fieldName);
        if(items == null) {
            return null;
        } else {
            ArrayList contentTypes = new ArrayList(items.size());
            Iterator i$ = items.iterator();

            while(i$.hasNext()) {
                FileItem fileItem = (FileItem)i$.next();
                contentTypes.add(fileItem.getContentType());
            }

            return (String[])contentTypes.toArray(new String[contentTypes.size()]);
        }
    }

    public File[] getFile(String fieldName) {
        List items = (List)this.files.get(fieldName);
        if(items == null) {
            return null;
        } else {
            ArrayList fileList = new ArrayList(items.size());

            File storeLocation;
            for(Iterator i$ = items.iterator(); i$.hasNext(); fileList.add(storeLocation)) {
                FileItem fileItem = (FileItem)i$.next();
                storeLocation = ((DiskFileItem)fileItem).getStoreLocation();
                if(fileItem.isInMemory() && storeLocation != null && !storeLocation.exists()) {
                    try {
                        storeLocation.createNewFile();
                    } catch (IOException var8) {
                        if(LOG.isErrorEnabled()) {
                            LOG.error("Cannot write uploaded empty file to disk: " + storeLocation.getAbsolutePath(), var8, new String[0]);
                        }
                    }
                }
            }

            return (File[])fileList.toArray(new File[fileList.size()]);
        }
    }

    public String[] getFileNames(String fieldName) {
        List items = (List)this.files.get(fieldName);
        if(items == null) {
            return null;
        } else {
            ArrayList fileNames = new ArrayList(items.size());
            Iterator i$ = items.iterator();

            while(i$.hasNext()) {
                FileItem fileItem = (FileItem)i$.next();
                fileNames.add(this.getCanonicalName(fileItem.getName()));
            }

            return (String[])fileNames.toArray(new String[fileNames.size()]);
        }
    }

    public String[] getFilesystemName(String fieldName) {
        List items = (List)this.files.get(fieldName);
        if(items == null) {
            return null;
        } else {
            ArrayList fileNames = new ArrayList(items.size());
            Iterator i$ = items.iterator();

            while(i$.hasNext()) {
                FileItem fileItem = (FileItem)i$.next();
                fileNames.add(((DiskFileItem)fileItem).getStoreLocation().getName());
            }

            return (String[])fileNames.toArray(new String[fileNames.size()]);
        }
    }

    public String getParameter(String name) {
        List v = (List)this.params.get(name);
        return v != null && v.size() > 0?(String)v.get(0):null;
    }

    public Enumeration<String> getParameterNames() {
        return Collections.enumeration(this.params.keySet());
    }

    public String[] getParameterValues(String name) {
        List v = (List)this.params.get(name);
        return v != null && v.size() > 0?(String[])v.toArray(new String[v.size()]):null;
    }

    public List<String> getErrors() {
        return this.errors;
    }

    private String getCanonicalName(String filename) {
        int forwardSlash = filename.lastIndexOf("/");
        int backwardSlash = filename.lastIndexOf("\\");
        if(forwardSlash != -1 && forwardSlash > backwardSlash) {
            filename = filename.substring(forwardSlash + 1, filename.length());
        } else if(backwardSlash != -1 && backwardSlash >= forwardSlash) {
            filename = filename.substring(backwardSlash + 1, filename.length());
        }

        return filename;
    }

    protected RequestContext createRequestContext(final HttpServletRequest req) {
        return new RequestContext() {
            public String getCharacterEncoding() {
                return req.getCharacterEncoding();
            }

            public String getContentType() {
                return req.getContentType();
            }

            public int getContentLength() {
                return req.getContentLength();
            }

            public InputStream getInputStream() throws IOException {
                ServletInputStream in = req.getInputStream();
                if(in == null) {
                    throw new IOException("Missing content in the request");
                } else {
                    return req.getInputStream();
                }
            }
        };
    }

    public void cleanUp() {
        Set names = this.files.keySet();
        Iterator i$ = names.iterator();

        while(i$.hasNext()) {
            String name = (String)i$.next();
            List items = (List)this.files.get(name);
            Iterator i$1 = items.iterator();

            while(i$1.hasNext()) {
                FileItem item = (FileItem)i$1.next();
                if(LOG.isDebugEnabled()) {
                    String msg = LocalizedTextUtil.findText(this.getClass(), "struts.messages.removing.file", Locale.ENGLISH, "no.message.found", new Object[]{name, item});
                    LOG.debug(msg, new String[0]);
                }

                if(!item.isInMemory()) {
                    item.delete();
                }
            }
        }

    }
}
实现自定义的ProgressListener
package util.upload;

import org.apache.commons.fileupload.ProgressListener;

public class UploadListener implements ProgressListener {

    private UploadStatus status;

    public UploadListener(UploadStatus status) {
        this.status = status;

    }

    public void update(long bytesRead, long contentLength, int items) {
        // 上传组件会调用该方法
        status.setBytesRead(bytesRead); // 已读取的数据长度
        status.setContentLength(contentLength); // 文件总长度
        status.setItems(items); // 正在保存第几个文件

    }
}
用来存储下载进度状态的Java bean
package util.upload;

/**
 * Created by martsforever on 2016/2/24.
 */
public class UploadStatus {
    private long bytesRead;//已经上传的字节数,单位:字节
    private long contentLength;//所有文件总长度,单位:字节
    private int items;//正在上传的第几个文件
    private long startTime = System.currentTimeMillis();//开始上传的时间,用于计算上传速度

    public long getBytesRead() {
        return bytesRead;
    }

    public void setBytesRead(long bytesRead) {
        this.bytesRead = bytesRead;
    }

    public long getContentLength() {
        return contentLength;
    }

    public void setContentLength(long contentLength) {
        this.contentLength = contentLength;
    }

    public int getItems() {
        return items;
    }

    public void setItems(int items) {
        this.items = items;
    }

    public long getStartTime() {
        return startTime;
    }

    public void setStartTime(long startTime) {
        this.startTime = startTime;
    }
}
这桑三个文件准备好了之后不要忘了在struts的配置文件中添加一下代码:
<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 2.3//EN"
        "http://struts.apache.org/dtds/struts-2.3.dtd">

<struts>
        <constant name="struts.multipart.parser" value="util.upload.MyMultiPartRequest" />
        <constant name="struts.multipart.handler" value="util.upload.MyMultiPartRequest" />
        <constant name="struts.multipart.maxSize" value="2000000000"/>
</struts>

好了,到现在为止,准备工作已经做完了,现在开始实现刚才图中的两个ajax和两个action:

jsp中的代码:

选择文件的表单:
<div class="col-xs-12 col-sm-12 col-md-12 clean text-center" style="padding-top: 10vh">
            <center>
                <form id="newResourceForm" action="uploadResourceFile" method="post">
                    <input type="text" class="form-control" id="name" placeholder="资源名称"
                           style="width: 30vw; margin-bottom: 5vh">
                    <input type="text" class="form-control" id="introduce" placeholder="简介"
                           style="width: 30vw; margin-bottom: 5vh">

                    <input id="resource" type="file" name="resource" style="display:none">

                    <div class="input-group" style="width: 30vw; margin-bottom: 5vh">
                        <input id="photoCover" type="text" class="form-control" placeholder="文件临时路径"
                               aria-describedby="basic-addon2">
                    <span class="input-group-addon btn btn-default" id="basic-addon2"
                          οnclick="$('input[id=resource]').click();">选择文件</span>
                    </div>
                </form>
                <button type="submit" class="btn btn-success" style="width: 8vw" id="newResourceConfirmBtn">提交</button>
            </center>
        </div>
显示文件进度
<%--上传文件进度模态对话框--%>
<div class="modal fade" id="uploadFileProgressModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span
                        aria-hidden="true">×</span></button>
                <h4 class="modal-title">正在上传文件</h4>
            </div>
            <div class="modal-body">
                <div class="progress">
                    <div id="uploadProgress" class="progress-bar progress-bar-success progress-bar-striped active"
                         role="progressbar"
                         aria-valuenow="40" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
                        <span class="sr-only">40% Complete (success)</span>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
消息提示:
<%--消息提示模态对话框--%>
<div class="modal fade" id="msg" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span
                        aria-hidden="true">×</span></button>
                <h4 class="modal-title">消息提示</h4>
            </div>
            <div class="modal-body">
                <span id="msgContent"></span>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-default" data-dismiss="modal" id="msgBtn">确定</button>
            </div>
        </div>
    </div>
</div>
不要忘了导入刚才的js文件:
<script src="js/ajaxfileupload.js"></script>

两个ajax
<script type="text/javascript">

    var options = {
        backdrop: false,
        keyboard: false
    };
    var uploadInterval;

    $("#newResourceConfirmBtn").click(function () {

//        alert($("#name").val().trim()+$("#introduce").val().trim());

        $.ajaxFileUpload({
            url: 'uploadResourceFile?name='+$("#name").val().trim()+"&"+"introduce="+$("#introduce").val().trim(),
            type: 'post',
            secureuri: false, //一般设置为false
            fileElementId: 'resource', // 上传文件的id、name属性名
            dataType: 'json', //返回值类型,一般设置为json、application/json
            data: {//传递参数到服务器
                "name": $("#name").val().trim(),
                "introduce": $("#introduce").val().trim()
            },
            success: function (data, status) {
                if (data.saveStatus == "success") {
                    $("#msgContent").html("添加成功");
                    $("#msg").modal(options);
                    $("#msgBtn").unbind('click');
                    $("#msgBtn").click(function(){
                        window.location.reload();
                    });
                }
                else("保存失败!");
            },
            error: function (data, status, e) {
                alert(e);
            }
        });

        $("#uploadConfirmBtn").attr({"disabled": "disabled"});
        $("#uploadFileProgressModal").modal(options);

        uploadInterval = setInterval("showuploadProgress()", 1000);

    });

    //    var precent = 0;

    function showuploadProgress() {

        $.ajax({
            type: "POST",  //提交方式
            url: "getFileUploadProgress",//路径
            dataType: "json",//返回的json格式的数据
            success: function (result) {//返回数据根据结果进行相应的处理
                $("#uploadProgress").width(result.percent + '%');
                if(result.percent == 100){
                    clearInterval(uploadInterval);
                }
            },
            error: function (XMLHttpRequest, textStatus, errorThrown) {
                alert(errorThrown);
            }
        });

//        precent+=10;
//        $("#uploadProgress").width(precent+'%');
//        if(precent == 100){
//            $("#uploadConfirmBtn").removeAttr("disabled");
//            clearInterval(uploadInterval);
//        }
    }

    $('input[id=resource]').change(function () {
        $('#photoCover').val($(this).val());
    });
</script>

后端接收文件的action:
@Action(value = "uploadResourceFile")
    public void uploadResourceFile()throws Exception {

        System.out.println("uploadResourceFile");
        String name = request.getParameter("name");
        String introduce = request.getParameter("introduce");
        System.out.println("name="+name);
        System.out.println("introduce="+introduce);
        System.out.println("file is null"+(resource == null));

        System.out.println("fileName"+resourceFileName);
        System.out.println("fileContentType"+resourceContentType);

        JSONObject jsonObject = new JSONObject();
        response.setContentType("text/html;charset=utf-8");
        response.setCharacterEncoding("utf-8");

        if (resource != null) {

            Resource newNesource = new Resource();
            newNesource.setName(name);
            newNesource.setIntroduce(introduce);
            newNesource.setTime(dateFormat.format(new Date()));
            newNesource.setDownloadTimes(0);
            newNesource.setSize(resource.length());
//            resource.setDownloadUrl("resource/"+resourceFileName+resource.getTime());
            resourceContentType = resourceFileName.substring(resourceFileName.lastIndexOf(".") + 1);//获得正真的文件类型
            System.out.println("fileContentType:" + resourceContentType);
            resourceFileName = newNesource.getName() + "." + resourceContentType;//存储的文件名称为用户账号名

            String realpath = ServletActionContext.getServletContext().getRealPath("/resource");
            /*System.out.println("realpath:" + realpath);*/

            File saveFile = new File(new File(realpath), resourceFileName);
            if (!saveFile.getParentFile().exists()) {
                System.out.println("目录不存在,重新创建目录!");
                saveFile.getParentFile().mkdirs();
            }

            FileUtils.copyFile(resource, saveFile);
            String savePath = saveFile.getAbsolutePath();

            newNesource.setDownloadUrl("resource/" + resourceFileName);
            resourceService.add(newNesource);

            System.out.println(resource.toString());

            jsonObject.put("saveStatus", "success");
        } else {
            System.out.println("file is null");
            jsonObject.put("saveStatus", "fail");
        }
        response.getWriter().write(jsonObject.toString());
    }
/**
     * 获取文件上传的进度
     */
    @Action(value = "getFileUploadProgress")
    public void getFileUploadProgress() throws Exception{
        UploadStatus status = (UploadStatus) session.get("uploadStatus");

        if (status == null) {
            System.out.println("uploadStatus is null");
            return;
        }

        long startTime = status.getStartTime(); //上传开始时间
        long currentTime = System.currentTimeMillis(); //现在时间
        long time = (currentTime - startTime) / 1000 + 1; //已传输的时间 单位:s

        //传输速度单位:byte/s
        double velocity = ((double) status.getBytesRead()) / (double) time;
        //估计总时间
        double totalTime = status.getContentLength();
        //估计剩余时间
        double timeLeft = totalTime - time;
        //已经完成的百分比
        int percent = (int) (100 * (double) status.getBytesRead() / (double) status.getContentLength());
        //已经完成数单位:m
        double length = ((double) status.getBytesRead()) / 1024 / 1024;
        //总长度 单位:m
        double totalLength = ((double) status.getContentLength()) / 1024 / 1024;

        System.out.println("bytesRead:"+status.getBytesRead());
        System.out.println("ContentLength:"+status.getContentLength());

//        System.out.println("percent:"+percent);
//        System.out.println("length:"+length);
//        System.out.println("totalLength:"+totalLength);
//        System.out.println("velocity:"+velocity);
//        System.out.println("time:"+time);
//        System.out.println("totalTime:"+totalTime);
//        System.out.println("timeLeft:"+timeLeft);
//        System.out.println("fileNumber:"+status.getItems());

        JSONObject jsonObject = new JSONObject();


        jsonObject.put("percent", String.valueOf(percent));
        jsonObject.put("length", String.valueOf(length));
        jsonObject.put("totalLength", String.valueOf(totalLength));
        jsonObject.put("velocity", String.valueOf(velocity));
        jsonObject.put("time", String.valueOf(time));
        jsonObject.put("totalTime", String.valueOf(totalTime));
        jsonObject.put("timeLeft", String.valueOf(timeLeft));
        jsonObject.put("fileNumber", String.valueOf(status.getItems()));

        response.setContentType("text/html;charset=utf-8");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(jsonObject.toString());
    }
不要忘了该Action中添加三个成员变量:
private File resource;
    private String resourceFileName;
    private String resourceContentType;
还要提供get和set方法,这个成员变量的名称必须和ajax中的fileElementId一致,而且html中input的id和name都是这个。(这里看不清楚的可以提问,我也觉得这里表达不清楚)。
这里面使用了JsonObject,其中可以通过maven添加依赖:
<!--导入org.json的jar包--><!--org的json解析包-->
        <dependency>
            <groupId>org.json</groupId>
            <artifactId>json</artifactId>
            <version>20151123</version>
        </dependency>
因为这个功能已经完成到项目中去了,所以没有办法将源码公布,请大家见谅,此文章仅供参考。









  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值