liu
11 小时以前 724be707ec15ac6558f1561366dd367d75f28bf7
chore(scripts): 新增二维码与联调诊断脚本

排查过程中沉淀的一次性诊断工具,保留以便后续复现同类问题。

- CompareQrRender / DecodeQr / DiagnoseQrFailures:判定「解不开的标签」
是标签本身损坏还是解码器局限(zxing 编解码复现 + 渲染结果逐像素比对)
- diag-login / diag-bagcode / diag-pill:Playwright 页面结构 dump,
用于校准冒烟测试选择器、区分前端缺陷与测试选择器问题
- split-hunks.py:从 git diff 中精确提取指定 hunk,用于非交互式精确暂存

scripts/qr-export-test-out/ 为上述诊断产生的截图产物,不入库。

Co-Authored-By: Claude <noreply@anthropic.com>
已添加7个文件
534 ■■■■■ 文件已修改
scripts/CompareQrRender.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/DecodeQr.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/DiagnoseQrFailures.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/diag-bagcode.cjs 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/diag-login.cjs 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/diag-pill.cjs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/split-hunks.py 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/CompareQrRender.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.EnumMap;
import java.util.Map;
/**
 * åˆ¤å®š"解不开的标签"到底是标签坏了,还是解码器的局限
 *
 * ç”¨æ³•:java CompareQrRender <标签目录> <文件名1> [文件名2 ...]
 *
 * åšæ³•:用与导出完全相同的编码参数(纠错级别 H、静区 4、边长 260)重新生成同一码值的二维码,
 * å†ä¸Žæ ‡ç­¾å›¾ç‰‡ä¸­çš„二维码区域逐像素比对。
 * - å®Œå…¨ä¸€è‡´ â†’ æ ‡ç­¾æ˜¯è¯¥ç å€¼çš„忠实渲染,二维码数据有效,解不出是解码器的问题
 * - å­˜åœ¨å·®å¼‚ â†’ æ ‡ç­¾æ¸²æŸ“环节确实出了问题
 */
public class CompareQrRender {
    /** ä¸Ž QrCodeImageUtils ä¿æŒä¸€è‡´ */
    private static final int PADDING = 16;
    private static final int QR_SIZE = 260;
    /** ä¸Ž MesProQrLabelBuilder ä¸€è‡´ï¼šäºŒç»´ç å†…容为追溯网址 + code å‚æ•° */
    private static final String TRACE_BASE = "http://192.168.0.10:48080/trace.html";
    public static void main(String[] args) throws Exception {
        File dir = new File(args[0]);
        for (int i = 1; i < args.length; i++) {
            File f = new File(dir, args[i]);
            String name = args[i];
            String code = name.endsWith(".png") ? name.substring(0, name.length() - 4) : name;
            BufferedImage label = ImageIO.read(f);
            if (label == null) {
                System.out.println(name + " -> æ— æ³•读取");
                continue;
            }
            BufferedImage expect = generate(code);
            BufferedImage actual = label.getSubimage(PADDING, PADDING, QR_SIZE, QR_SIZE);
            if (expect.getWidth() != actual.getWidth() || expect.getHeight() != actual.getHeight()) {
                System.out.println(name + " -> å°ºå¯¸ä¸ä¸€è‡´ï¼Œæ— æ³•比对");
                continue;
            }
            long diff = 0;
            for (int y = 0; y < QR_SIZE; y++) {
                for (int x = 0; x < QR_SIZE; x++) {
                    if (expect.getRGB(x, y) != actual.getRGB(x, y)) {
                        diff++;
                    }
                }
            }
            long total = (long) QR_SIZE * QR_SIZE;
            System.out.printf("%s%n  é‡æ–°ç”Ÿæˆä¸Žæ ‡ç­¾äºŒç»´ç åŒºåŸŸé€åƒç´ æ¯”对:不一致 %d / %d (%.4f%%)%n",
                    name, diff, total, diff * 100.0 / total);
            System.out.println("  åˆ¤å®šï¼š" + (diff == 0
                    ? "标签为该码值的忠实渲染 â†’ äºŒç»´ç æ•°æ®æœ‰æ•ˆï¼Œè§£ä¸å‡ºå±žè§£ç ç«¯é—®é¢˜"
                    : "标签渲染与预期不符 â†’ æ¸²æŸ“环节存在问题"));
        }
    }
    private static BufferedImage generate(String code) throws Exception {
        String content = TRACE_BASE + "?code=" + code;
        Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
        hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
        hints.put(EncodeHintType.MARGIN, 4);
        hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
        BitMatrix matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, QR_SIZE, QR_SIZE, hints);
        int w = matrix.getWidth();
        int h = matrix.getHeight();
        BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
        for (int y = 0; y < h; y++) {
            for (int x = 0; x < w; x++) {
                img.setRGB(x, y, matrix.get(x, y) ? 0x000000 : 0xFFFFFF);
            }
        }
        return img;
    }
}
scripts/DecodeQr.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.LuminanceSource;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.NotFoundException;
import com.google.zxing.Result;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
 * äºŒç»´ç æ ‡ç­¾è§£ç æ ¡éªŒ
 *
 * ç”¨æ³•:java DecodeQr <标签目录>
 *
 * å¯¹æ¯å¼ æ ‡ç­¾åˆ†åˆ«ç”¨ã€Œæ•´å›¾ã€å’Œã€Œè£åˆ°äºŒç»´ç åŒºåŸŸã€ä¸¤ç§æ–¹å¼è§£ç ï¼š
 * æ•´å›¾å¤±è´¥è€Œè£å›¾æˆåŠŸï¼Œè¯´æ˜Žæ ‡ç­¾æœ¬èº«æ²¡åï¼Œæ˜¯ä¸‹æ–¹æ–‡å­—å¹²æ‰°äº†æ£€æµ‹å™¨ï¼Œå±žè§£ç ç«¯é—®é¢˜ï¼›
 * ä¸¤ç§æ–¹å¼éƒ½å¤±è´¥ï¼Œæ‰æ˜¯æ ‡ç­¾çœŸçš„不可识读。
 */
public class DecodeQr {
    /** æ ‡ç­¾æŽ’版参数,须与 QrCodeImageUtils ä¿æŒä¸€è‡´ */
    private static final int PADDING = 16;
    private static final int QR_SIZE = 260;
    /** è£å›¾æ—¶å‘外多留的余量,保证二维码静区不被切掉 */
    private static final int CROP_MARGIN = 10;
    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            System.out.println("用法: java DecodeQr <标签目录>");
            return;
        }
        File dir = new File(args[0]);
        File[] files = dir.listFiles((d, n) -> n.toLowerCase().endsWith(".png"));
        if (files == null || files.length == 0) {
            System.out.println("目录内没有 PNG æ–‡ä»¶ï¼š" + dir.getAbsolutePath());
            return;
        }
        Arrays.sort(files);
        int fullOk = 0;
        int cropOk = 0;
        int bothFail = 0;
        List<String> fullFailCropOk = new ArrayList<>();
        List<String> hardFail = new ArrayList<>();
        List<String> mismatched = new ArrayList<>();
        String sampleContent = null;
        for (File f : files) {
            String name = f.getName();
            String code = name.endsWith(".png") ? name.substring(0, name.length() - 4) : name;
            BufferedImage image = ImageIO.read(f);
            if (image == null) {
                hardFail.add(name + " -> å›¾ç‰‡æ— æ³•读取");
                bothFail++;
                continue;
            }
            String full = decode(image);
            String crop = decode(cropQrArea(image));
            if (full != null) {
                fullOk++;
            }
            if (crop != null) {
                cropOk++;
            }
            if (full == null && crop == null) {
                bothFail++;
                hardFail.add(name + " -> æ•´å›¾ä¸Žè£å›¾å‡è§£ç å¤±è´¥");
                continue;
            }
            if (full == null) {
                fullFailCropOk.add(name);
            }
            String text = full != null ? full : crop;
            if (!text.contains(code)) {
                mismatched.add(name + " -> å†…容与码值不符: " + text);
            }
            if (sampleContent == null) {
                sampleContent = "码值 " + code + "\n        äºŒç»´ç å†…容 " + text;
            }
        }
        int total = files.length;
        System.out.println("标签总数        : " + total);
        System.out.printf ("整图解码成功    : %d (%.2f%%)%n", fullOk, fullOk * 100.0 / total);
        System.out.printf ("裁图解码成功    : %d (%.2f%%)%n", cropOk, cropOk * 100.0 / total);
        System.out.printf ("两种方式都失败  : %d (%.2f%%)%n", bothFail, bothFail * 100.0 / total);
        System.out.println("整图失败但裁图成功(检测受下方文字干扰,标签本身正常): " + fullFailCropOk.size());
        System.out.println("\n--- æ ·æœ¬ ---\n         " + sampleContent);
        if (!fullFailCropOk.isEmpty()) {
            System.out.println("\n--- ä»…整图失败的码(前 10) ---");
            fullFailCropOk.stream().limit(10).forEach(s -> System.out.println("  " + s));
        }
        if (!mismatched.isEmpty()) {
            System.out.println("\n!!! å†…容与码值不符(前 10) !!!");
            mismatched.stream().limit(10).forEach(s -> System.out.println("  " + s));
        }
        if (!hardFail.isEmpty()) {
            System.out.println("\n--- çœŸæ­£ä¸å¯è¯†è¯»çš„æ ‡ç­¾ï¼ˆå‰ 20) ---");
            hardFail.stream().limit(20).forEach(s -> System.out.println("  " + s));
        }
    }
    /** è£å‡ºäºŒç»´ç æ‰€åœ¨åŒºåŸŸï¼ŒåŽ»æŽ‰ä¸‹æ–¹å åŠ çš„æ–‡å­— */
    private static BufferedImage cropQrArea(BufferedImage src) {
        int x = Math.max(0, PADDING - CROP_MARGIN);
        int y = Math.max(0, PADDING - CROP_MARGIN);
        int w = Math.min(src.getWidth() - x, QR_SIZE + CROP_MARGIN * 2);
        int h = Math.min(src.getHeight() - y, QR_SIZE + CROP_MARGIN * 2);
        return src.getSubimage(x, y, w, h);
    }
    /** å°è¯•多种二值化策略解码,全部失败返回 null */
    private static String decode(BufferedImage image) {
        LuminanceSource source = new BufferedImageLuminanceSource(image);
        Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
        hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
        hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
        hints.put(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE));
        // HybridBinarizer é€‚合光照不均,GlobalHistogramBinarizer å¯¹é«˜å¯¹æ¯”度图更稳,两者互补
        for (var binarizer : List.of(new HybridBinarizer(source), new GlobalHistogramBinarizer(source))) {
            try {
                Result r = new MultiFormatReader().decode(new BinaryBitmap(binarizer), hints);
                if (r != null && r.getText() != null) {
                    return r.getText();
                }
            } catch (NotFoundException ignored) {
                // æ¢ä¸‹ä¸€ç§ç­–ç•¥
            } catch (Exception ignored) {
                // åŒä¸Š
            }
        }
        return null;
    }
}
scripts/DiagnoseQrFailures.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.LuminanceSource;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.Result;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import javax.imageio.ImageIO;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
 * è§£ç å¤±è´¥æ ·æœ¬çš„æˆå› åˆ¤å®š
 *
 * ç”¨æ³•:java DiagnoseQrFailures <标签目录> <文件名1> [文件名2 ...]
 *
 * æ€è·¯ï¼šæŠŠå›¾ç‰‡æŒ‰æ•´æ•°å€æœ€è¿‘邻放大后重新解码。二维码是纯黑白方块,
 * æœ€è¿‘邻放大不引入新信息,只是给解码器的采样网格更多余地。
 * - æ”¾å¤§åŽèƒ½è§£å‡º â†’ äºŒç»´ç æ•°æ®æœ¬èº«æ˜¯å¯¹çš„,只是模块密度/采样位置过于临界
 *   ï¼ˆå±žè§£ç ç«¯é—®é¢˜ï¼Œä½†å°åˆ·å°ºå¯¸åå°æ—¶çœŸå®žæ‰«ç æžªä¹Ÿå¯èƒ½è¯»ä¸å‡ºï¼Œåº”缩短二维码内容或加大标签)
 * - æ”¾å¤§åŽä»è§£ä¸å‡º â†’ è¯¥æ ‡ç­¾ç¡®æœ‰ç¼ºé™·ï¼Œéœ€æŒ‰åºŸå“å¤„理
 */
public class DiagnoseQrFailures {
    public static void main(String[] args) throws Exception {
        File dir = new File(args[0]);
        for (int i = 1; i < args.length; i++) {
            File f = new File(dir, args[i]);
            if (!f.isFile()) {
                System.out.println(args[i] + " -> æ–‡ä»¶ä¸å­˜åœ¨");
                continue;
            }
            BufferedImage img = ImageIO.read(f);
            String at1 = decode(img);
            System.out.println("\n=== " + args[i] + " ===");
            System.out.println("  åŽŸå§‹å°ºå¯¸        : " + img.getWidth() + " x " + img.getHeight());
            System.out.println("  1x è§£ç          : " + (at1 == null ? "失败" : "成功 -> " + at1));
            if (at1 != null) {
                continue;
            }
            boolean rescued = false;
            for (int scale : List.of(2, 3, 4, 6)) {
                BufferedImage up = scaleNearest(img, scale);
                String text = decode(up);
                System.out.println("  " + scale + "x æ”¾å¤§è§£ç      : " + (text == null ? "失败" : "成功 -> " + text));
                if (text != null) {
                    rescued = true;
                    break;
                }
            }
            System.out.println("  ç»“论            : " + (rescued
                    ? "二维码数据有效,属模块密度临界(解码端),非废码"
                    : "放大后仍不可识读,标签确有缺陷"));
        }
    }
    /** æ•´æ•°å€æœ€è¿‘邻放大:不插值、不引入新像素值,只放大采样空间 */
    private static BufferedImage scaleNearest(BufferedImage src, int scale) {
        BufferedImage out = new BufferedImage(
                src.getWidth() * scale, src.getHeight() * scale, BufferedImage.TYPE_INT_RGB);
        var g = out.createGraphics();
        try {
            g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
            g.drawImage(src, 0, 0, out.getWidth(), out.getHeight(), null);
        } finally {
            g.dispose();
        }
        return out;
    }
    private static String decode(BufferedImage image) {
        LuminanceSource source = new BufferedImageLuminanceSource(image);
        Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
        hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
        hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
        hints.put(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE));
        try {
            Result r = new MultiFormatReader().decode(
                    new BinaryBitmap(new HybridBinarizer(source)), hints);
            return r == null ? null : r.getText();
        } catch (Exception e) {
            return null;
        }
    }
}
scripts/diag-bagcode.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
/** è¢‹ç é¡µç»“构诊断:登录后 dump æœç´¢é¡¹ä¸Žå·¥å…·æ æŒ‰é’®ï¼Œç”¨äºŽæ ¡å‡†æµ‹è¯•选择器 */
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5666';
(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
  page.on('pageerror', (e) => console.log('pageerror:', e.message));
  await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded' });
  await page.locator('input[name=username]').waitFor({ timeout: 60_000 });
  await page.locator('input[name=username]').fill('admin');
  await page.locator('input[name=password]').fill('admin123');
  await page.locator('button:has-text("登录")').last().click();
  await page.waitForURL((u) => !u.pathname.includes('/auth/login'), { timeout: 60_000 });
  console.log('登录后 URL:', page.url());
  await page.waitForTimeout(3000);
  console.log('\n--- èœå•项(含“袋码/赋码”的)---');
  for (const el of await page.locator('a, span, div').all()) {
    const t = (await el.innerText().catch(() => '')).trim();
    if (t && t.length < 20 && /袋码|赋码|一袋一码/.test(t)) {
      console.log(JSON.stringify(t));
    }
  }
  for (const target of ['/bagcode/bag-code', '/bagcode', '/mes/pro/bagCode']) {
    await page.goto(`${BASE}${target}`, { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(4000);
    const url = page.url();
    const hasExport = (await page.getByRole('button', { name: '导出二维码' }).count()) > 0;
    console.log(`\n>>> å°è¯• ${target} â†’ å®žé™… ${url},导出按钮数=${hasExport}`);
    if (!hasExport) continue;
    console.log('--- æœç´¢è¾“入框 placeholder ---');
    for (const el of await page.locator('input').all()) {
      const ph = await el.getAttribute('placeholder');
      if (ph && (await el.isVisible().catch(() => false))) console.log(JSON.stringify(ph));
    }
    console.log('--- å…¨éƒ¨å¯è§æŒ‰é’®æ–‡æœ¬ ---');
    for (const el of await page.locator('button').all()) {
      const t = (await el.innerText().catch(() => '')).trim();
      if (t && (await el.isVisible().catch(() => false))) console.log(JSON.stringify(t));
    }
    break;
  }
  await browser.close();
})();
scripts/diag-login.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
/** ç™»å½•页结构诊断:把实际渲染出来的表单元素全部打出来 */
const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
  const errors = [];
  page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
  page.on('console', (m) => {
    if (m.type() === 'error') errors.push('console: ' + m.text());
  });
  page.on('requestfailed', (r) =>
    errors.push('reqfail: ' + r.url() + ' ' + (r.failure() || {}).errorText),
  );
  await page.goto('http://127.0.0.1:5666/auth/login', { waitUntil: 'domcontentloaded' });
  await page.waitForTimeout(8000);
  console.log('URL:', page.url());
  console.log('TITLE:', await page.title());
  const bodyText = (await page.locator('body').innerText().catch(() => '')).slice(0, 800);
  console.log('--- å¯è§æ–‡æœ¬ ---');
  console.log(bodyText);
  console.log('--- input å…ƒç´  ---');
  for (const el of await page.locator('input').all()) {
    console.log(
      JSON.stringify({
        type: await el.getAttribute('type'),
        name: await el.getAttribute('name'),
        autocomplete: await el.getAttribute('autocomplete'),
        placeholder: await el.getAttribute('placeholder'),
        id: await el.getAttribute('id'),
        visible: await el.isVisible().catch(() => false),
      }),
    );
  }
  console.log('--- button/role=radio å…ƒç´  ---');
  for (const el of await page.locator('button, [role=radio], .ant-radio-wrapper').all()) {
    const t = (await el.innerText().catch(() => '')).trim().slice(0, 40);
    if (t) console.log(JSON.stringify({ tag: await el.evaluate((n) => n.tagName), text: t }));
  }
  console.log('--- é”™è¯¯ ---');
  console.log(errors.slice(0, 20).join('\n') || '(无)');
  await page.screenshot({
    path: require('node:path').join(__dirname, 'qr-export-test-out', 'diag-login.png'),
  });
  await browser.close();
})();
scripts/diag-pill.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
/** æµ®æ ‡å±•开诊断:判断「点浮标恢复面板」失败是前端 bug è¿˜æ˜¯æµ‹è¯•选择器问题 */
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5666';
const UUID = 'ae1229655a3e48d7b4bc842e3e74e15c';
(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
  await page.addInitScript(() => {
    Object.defineProperty(window, 'showSaveFilePicker', { value: undefined, configurable: true });
  });
  await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded' });
  await page.locator('input[name=username]').waitFor({ timeout: 60_000 });
  await page.locator('input[name=username]').fill('admin');
  await page.locator('input[name=password]').fill('admin123');
  await page.locator('button:has-text("登录")').last().click();
  await page.waitForURL((u) => !u.pathname.includes('/auth/login'), { timeout: 60_000 });
  await page.goto(`${BASE}/bagcode/bag-code`, { waitUntil: 'domcontentloaded' });
  await page.getByRole('button', { name: '导出二维码' }).first().waitFor({ timeout: 60_000 });
  await page.getByPlaceholder('请输入生成组编号').fill(UUID);
  const searchBtn = page.locator('button').filter({ hasText: /搜\s*ç´¢/ });
  await searchBtn.first().click();
  await page.waitForTimeout(3000);
  await page.getByRole('button', { name: '导出二维码' }).first().click();
  await page.locator('text=正在生成二维码').waitFor({ timeout: 90_000 });
  console.log('面板已出现');
  // ç‚¹éšè—
  const hideBtn = page.locator('button').filter({ hasText: /隐\s*藏/ });
  console.log('隐藏按钮数:', await hideBtn.count());
  await hideBtn.first().click();
  await page.waitForTimeout(1500);
  // æ‰¾å‡ºæ‰€æœ‰å«ã€ŒäºŒç»´ç å¯¼å‡ºã€çš„ div,打印层级与位置
  const cands = page.locator('div').filter({ hasText: /二维码导出/ });
  const n = await cands.count();
  console.log('\n含「二维码导出」的 div æ•°é‡:', n);
  for (let i = 0; i < n; i++) {
    const el = cands.nth(i);
    const info = await el.evaluate((node) => ({
      cls: node.className?.toString().slice(0, 70),
      text: (node.innerText || '').replace(/\s+/g, ' ').slice(0, 40),
      depth: (() => { let d = 0, p = node; while (p.parentElement) { d++; p = p.parentElement; } return d; })(),
    }));
    console.log(`  [${i}] depth=${info.depth} cls="${info.cls}" text="${info.text}"`);
  }
  const pill = cands.last();
  console.log('\n拟点击 last() å…ƒç´ ï¼Œå…¶ class:', await pill.evaluate((n) => n.className?.toString().slice(0, 70)));
  await pill.click();
  await page.waitForTimeout(2000);
  const bodyText = await page.locator('body').innerText();
  console.log('\n点击后:');
  console.log('  body å«ã€Œæ­£åœ¨ç”ŸæˆäºŒç»´ç ã€:', bodyText.includes('正在生成二维码'));
  console.log('  body å«ã€ŒäºŒç»´ç å¯¼å‡ºã€:', bodyText.includes('二维码导出'));
  console.log('  éšè—æŒ‰é’®æ˜¯å¦è¿˜åœ¨:', await page.locator('button').filter({ hasText: /隐\s*藏/ }).count());
  const m = bodyText.match(/正在生成(\d+\s*\/\s*\d+)/);
  console.log('  è¿›åº¦æ–‡æœ¬:', m ? m[0] : '(未找到)');
  await browser.close();
})();
scripts/split-hunks.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
"""从 git diff è¡¥ä¸ä¸­åªä¿ç•™æŒ‡å®šåºå·çš„ hunk,用于精确暂存(git add -p æ˜¯äº¤äº’式的,脚本里用不了)。
用法:
  python split-hunks.py <补丁文件> <保留的hunk序号,逗号分隔> [--list]
  --list  åªåˆ—出 hunk åºå·ä¸Žé¦–行,不输出补丁
"""
import sys
# å¿…须强制 UTF-8 ä¸”不做换行翻译:否则中文上下文行会被按 GBK å†™åã€\n è¢«å†™æˆ \r\n,
# ä¸¤è€…都会让 git apply åŒ¹é…ä¸ä¸Šï¼ˆçº¯ ASCII çš„补丁不会暴露这个问题,容易误判)
try:
    sys.stdout.reconfigure(encoding='utf-8', newline='')
except Exception:
    pass
path = sys.argv[1]
if len(sys.argv) < 3:
    print(__doc__)
    sys.exit(1)
list_only = '--list' in sys.argv
keep = set()
if not list_only:
    keep = {int(x) for x in sys.argv[2].split(',') if x.strip() != ''}
lines = open(path, encoding='utf-8', errors='surrogateescape').read().split('\n')
header, hunks, cur = [], [], None
for ln in lines:
    if ln.startswith('@@'):
        if cur is not None:
            hunks.append(cur)
        cur = [ln]
    elif cur is None:
        header.append(ln)
    else:
        cur.append(ln)
if cur is not None:
    hunks.append(cur)
if list_only:
    for i, h in enumerate(hunks):
        print(f'  [{i}] {h[0]}')
    sys.exit(0)
out = list(header)
for i, h in enumerate(hunks):
    if i in keep:
        out.extend(h)
# ç»“尾补回换行
sys.stdout.write('\n'.join(out))