研发效能 # 🚀 测试效能工具:一键扫码上传文件,让测试机与 PC 高效互通!

孤千羽 · 2025年10月31日 · 最后由 bingle 回复于 2025年11月12日 · 6546 次阅读

💡 背景与痛点

在日常测试工作中,测试工程师经常会遇到这样的烦恼:

  1. 测试移动端应用时,需要抓包、截图、录屏,并把这些内容发给开发定位问题。
  2. 但测试机上往往不能登录个人微信。
  3. 公司也没有足够多的测试微信号可用。
  4. 导致文件和信息传输非常低效。

有时候仅仅为了把一个截图发给开发,可能要经历「拍照 → 传到自己手机 → 微信转发 → 再传到 PC」这样的繁琐流程。

🧰 我开发的解决方案:扫码上传助手

基于这些痛点,我开发了一个轻量级的测试效能小工具 —— 扫码上传助手 🧩。

它的目标非常简单: 让测试机与 PC 之间传文件,像发微信一样方便。


✨ 功能特点

✅ PC 端自动生成二维码

每次启动时,PC 端会自动生成一个上传二维码,并展示在浏览器页面上。 扫码后即可进入对应的上传通道。

二维码页面示意图



二维码页面示意

✅ 手机扫码即可上传

测试机打开微信扫码(或浏览器扫码)二维码后,自动跳转到上传页面。

上传文件(截图、录屏、日志等)后,PC 端页面会实时接收并显示文件

手机上传界面示意



手机上传界面示意

手机上传中显示进度



手机上传中显示进度

✅ 实时消息传递

除了文件,测试人员还可以直接发送文字消息到 PC 端,比如:

「复现步骤」「异常时间点」「接口返回错误码」等。

实时消息示意

实时消息示意

✅ 跨设备,无需登录、无需微信号

手机端无需登录账号,也不需要安装 App。

只要能扫码、能上传,就能使用。

真正实现测试机与 PC 的轻量级高效互通


⚙️ 技术架构

整个系统采用 前后端分离架构
📱 手机端(H5 上传页面)
⇅ HTTP / WebSocket
🖥️ PC 端(浏览器显示 + WebSocket 监听)

☁️ 服务端(Spring Boot)

  • 后端:Spring Boot + WebSocket + 定时任务
  • 前端:Vue + Element UI
  • 通信方式:WebSocket 实时推送
  • 文件存储:服务器静态目录映射

🔑 技术关键点实现

1️⃣ 二维码生成接口

通过 ZXing 生成带任务 ID 的二维码,扫描后跳转上传页:

@Slf4j
@RestController
@RequestMapping("qrcode")
public class QrCodeController {

    @Value("${frontend.url}")
    private String frontendUrl;

    @GetMapping("/{taskId}")
    public ResponseEntity<String> generateQRCode(@PathVariable String taskId) throws Exception {
        String baseUrl = frontendUrl + "/upload?taskId=" + URLEncoder.encode(taskId, StandardCharsets.UTF_8.name());
        int width = 300, height = 300;
        Map<EncodeHintType, Object> hints = new HashMap<>();
        hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
        BitMatrix bitMatrix = new QRCodeWriter().encode(baseUrl, BarcodeFormat.QR_CODE, width, height, hints);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos);
        String base64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
        return StatusCode.OK.build(base64);
    }
}

2️⃣ 文件上传接口

支持多文件与文字消息上传,上传后通过 WebSocket 实时推送到前端展示:

@PostMapping("/files")
public ResponseEntity<List> upload(@RequestParam String taskId,
                                   @RequestParam(value = "files", required = false) List<MultipartFile> files,
                                   @RequestParam(value = "content", required = false) String content) throws IOException {
    List<UploadMessage> list = new ArrayList<>();

    // 发送文本消息
    if (content != null && !content.isEmpty()) {
        UploadMessage textMsg = new UploadMessage();
        textMsg.setTime(LocalDateTime.now().toString());
        textMsg.setContent(content);
        UploadWebSocket.broadcast(taskId, textMsg);
        list.add(textMsg);
    }

    // 上传文件
    if (files != null) {
        for (MultipartFile file : files) {
            String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename();
            String uploadDir = staticLocation + "/upload/other/";
            File dest = new File(uploadDir, fileName);
            dest.getParentFile().mkdirs();
            file.transferTo(dest);

            UploadMessage msg = new UploadMessage();
            msg.setTime(LocalDateTime.now().toString());
            msg.setFileUrl("static/upload/other/" + fileName);
            UploadWebSocket.broadcast(taskId, msg);
            list.add(msg);
        }
    }

    return StatusCode.OK.build(list);
}

3️⃣ WebSocket 实时通信

每个二维码对应一个 WebSocket 任务通道,用于推送实时消息:

@Slf4j
@Component
@ServerEndpoint(value="/ws/upload/{taskId}")
public class UploadWebSocket {

    // taskId -> set of sessions (多个客户端可以订阅同一个 taskId)
    private static final Map<String, Set<Session>> SESSION_MAP = new ConcurrentHashMap<>();
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private String taskId;

    @OnOpen
    public void onOpen(Session session, @PathParam("taskId") String taskId) {
        this.taskId = taskId;
        SESSION_MAP.computeIfAbsent(taskId, k -> Collections.newSetFromMap(new ConcurrentHashMap<>())).add(session);
    }

    @OnMessage
    public void onMessage(Session session, String message, @PathParam("taskId") String taskId) {
        // 可处理客户端消息(例如心跳),这里忽略
    }

    @OnClose
    public void onClose(Session session, @PathParam("taskId") String taskId) {
        Set<Session> set = SESSION_MAP.get(taskId);
        if (set != null) set.remove(session);
    }

    @OnError
    public void onError(Session session, Throwable thr) {
        // 日志忽略
    }

    public static void broadcast(String taskId, UploadMessage msg) {
        Set<Session> sessions = SESSION_MAP.getOrDefault(taskId, Collections.emptySet());
        if (sessions.isEmpty()) return;
        try {
            String text = MAPPER.writeValueAsString(msg);
            // 迭代发送(并发安全)
            for (Session s : sessions) {
                if (s.isOpen()) {
                    try { s.getBasicRemote().sendText(text); } catch (Exception ignored) {}
                }
            }
        } catch (Exception ignored) {}
    }
}

使用步骤

1️⃣ 启动服务端
运行 Spring Boot 项目,打开浏览器访问二维码生成页。

2️⃣ 扫描二维码
测试机用微信 / 浏览器扫码,跳转上传页面。

3️⃣ 上传文件或消息
上传截图、视频或输入文字说明。

4️⃣ PC 端实时显示结果
上传后内容自动推送并显示在 PC 端界面。


🔚 结语

这个 扫码上传工具 虽然小巧,但解决了我们测试工作中一个长期的痛点。它让测试人员能更专注于 问题定位与分析,而不是被低效的文件传输方式拖慢节奏。
如果你也经常为测试机与 PC 之间传文件发愁,不妨试试开发这个工具 👇💬 欢迎留言告诉我你的建议!后续我还计划支持:上传后自动通知开发(Webhook)。

最佳回复

凡是要安装软件或者配置环境的我都觉得麻烦,所以我觉得我这个小工具还是挺方便的。😀 😝

共收到 18 条回复 时间 点赞

大佬牛啊,能优化做成手机截图上传然后直接在电脑桌面上生成文件的吗;因为上传到电脑上其实还是要保存文件再提交到 bug 工具上 这个步骤还是多的

能实现反向吗,PC 端上传,手机端扫码下载。

3楼 已删除

测试机如果连接了 USB,用 adb 命令截图更方便快捷

python: 更新版

# smart_scan_upload.py —— 扫码上传助手
import os, socket, qrcode, cgi, urllib.parse, shutil, datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path

UPLOAD_DIR = "uploads"
DOWNLOAD_DIR = "to_phone"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(DOWNLOAD_DIR, exist_ok=True)

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args): pass
    def send_html(self, html, status=200):
        self.send_response(status)
        self.send_header("Content-type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(html.encode('utf-8'))

    def do_GET(self):
        if self.path == '/':
            self.send_html('''
            <meta name="viewport" content="width=device-width">
            <h2>🚀 扫码助手</h2>
            <a href="/upload" style="display:block;margin:15px;padding:12px;background:#007AFF;color:white;text-decoration:none;border-radius:8px;">📤 上传文件</a>
            <a href="/download" style="display:block;margin:15px;padding:12px;background:#34C759;color:white;text-decoration:none;border-radius:8px;">📥 下载PC文件</a>
            <button onclick="let m=prompt('输入文字消息');m&&fetch('/text',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'c='+encodeURIComponent(m)}).then(()=>alert('✅ 已发送'))" style="margin:15px;padding:12px;background:#FF9500;color:white;border:none;border-radius:8px;">💬 发文字</button>
            ''')
        elif self.path == '/upload':
            self.send_html(f'''
            <meta name="viewport" content="width=device-width">
            <h2>📤 上传文件</h2>
            <form method="post" enctype="multipart/form-data" action="/upload">
                <input type="file" name="files" multiple required><br><br>
                <button type="submit" style="padding:10px 20px;background:#007AFF;color:white;border:none;border-radius:6px;">上传</button>
            </form>
            <a href="/">← 返回</a>
            ''')
        elif self.path == '/download':
            files = [f.name for f in Path(DOWNLOAD_DIR).iterdir() if f.is_file()]
            links = ''.join(f'<li><a href="/f/{urllib.parse.quote(f)}">{f}</a></li>' for f in sorted(files))
            self.send_html(f'''
            <meta name="viewport" content="width=device-width">
            <h2>📥 下载文件</h2>
            <p>把文件放进电脑的 <code>{DOWNLOAD_DIR}</code> 文件夹即可下载。</p>
            <ul>{links or "<li>暂无文件</li>"}</ul>
            <a href="/">← 返回</a>
            ''')
        elif self.path.startswith('/f/'):
            fname = urllib.parse.unquote(self.path[3:])
            path = os.path.join(DOWNLOAD_DIR, os.path.basename(fname))
            if os.path.exists(path):
                self.send_response(200)
                self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(path)}"')
                self.end_headers()
                with open(path, 'rb') as f: shutil.copyfileobj(f, self.wfile)
            else:
                self.send_error(404)
        else:
            self.send_error(404)

    def do_POST(self):
        if self.path == '/upload':
            form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={'REQUEST_METHOD': 'POST'})
            files = form['files']
            if not isinstance(files, list): files = [files]
            for f in files:
                if f.filename:
                    name = os.path.basename(f.filename)
                    base, ext = os.path.splitext(name)
                    counter = 1
                    save_path = os.path.join(UPLOAD_DIR, name)
                    while os.path.exists(save_path):
                        save_path = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
                        counter += 1
                    with open(save_path, 'wb') as out: out.write(f.file.read())
                    print(f"📥 已保存: {save_path}")
            self.send_html('<h3 style="color:green">✅ 上传成功!</h3><script>setTimeout(()=>location="/",2000)</script>')
        elif self.path == '/text':
            length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(length).decode()
            msg = urllib.parse.parse_qs(body).get('c', [''])[0]
            if msg.strip():
                with open(os.path.join(UPLOAD_DIR, "messages.txt"), "a", encoding="utf-8") as log:
                    log.write(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
                print(f"💬 收到文字: {msg}")
                self.send_html('<h3>✅ 文字已接收!</h3>')
            else:
                self.send_error(400)

def get_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except: return "127.0.0.1"

if __name__ == "__main__":
    import sys
    port = 8081
    ip = get_ip()
    url = f"http://{ip}:{port}"

    print("🚀 扫码上传助手已启动!")
    print(f"📁 文件保存在当前目录的 'uploads' 文件夹")
    print(f"📤 想让手机下载PC文件?把文件放进 'to_phone' 文件夹\n")

    qr = qrcode.QRCode(); qr.add_data(url); qr.print_ascii(invert=True)
    print(f"\n🌐 手机扫码地址: {url}\n")
    print("⏳ 等待上传...(按 Ctrl+C 停止)\n")

    HTTPServer(('', port), Handler).serve_forever()

👍 👍 👍
这里也分享一点经验:
安装 android studio,在 Logcat 窗口可以直接对手机进行截屏和录屏然后保存到 pc 指定路径。提 bug 需要贴图时结合 snipaste 截图工具使用很方便。

社会菜鸡 回复

非常的好用 nice 点赞

牛的。还有一个可以在电脑安装一个 utools,有一个插件,fileshare 也可以直接上传。

之前一直都是 adb,ios 就直接隔空传

atx-server 安卓集群管理 和 模拟器装包 是不是可以在 PC 端直接截图

不是已经有类似的工具了吗???

https://aya.liriliri.io/zh/guide/

【远程控制,上传文件,日志,截图..........】











社会菜鸡 回复

这种是否能支持上传文件、文字消息(app 抓包的请求和响应数据)、录屏呢?

_YI 回复

之前有试过用 scrcpy 通过 adb 连接到手机,把手机端屏幕直接投屏到电脑,但是我发现这种方式确实截屏很方便,但是手机里面抓包的文字信息、包括文件信息、录屏信息都无法弄出来。

jenny 回复

不止是截图一个操作,还需要支持到文字消息发送、文件传输、视频传输。

孤千羽 回复

支持录屏和拿日志,locat 窗口本身就是用来看日志的。不过 ios 本身的开发工具没用过不知道是否支持,安卓和鸿蒙(装鸿蒙的 dev studio)是很方便的。

我喜欢用文叔叔😆

凡是要安装软件或者配置环境的我都觉得麻烦,所以我觉得我这个小工具还是挺方便的。😀 😝

mac 电脑的话,可以使用 iphone 镜像,ios 手机直接投屏到 mac 电脑上

这个解决你的问题 https://www.text-sync.com

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册