cardbuddy 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -16,22 +16,24 @@ parse_meaxure.py — 解析 Sketch MeaXure 标注导出,视觉驱动生成「
16
16
  card.json 状态 / 交互 / 样式 / 列表 聚合信息
17
17
  states/<画板名>/
18
18
  artboard.png 整图(参考)
19
- card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x)
19
+ card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x;text_only 纯文本板不产,含兜底图)
20
20
  spec.json 视觉要素标注(该状态)
21
21
  slices/ 切图素材
22
22
  clusters.json 卡片聚类 + 交互维度(由 cluster_states.py 补全)
23
23
 
24
24
  用法:
25
25
  python parse_meaxure.py --list # 仅列画板
26
- # 多画板选择门禁:不带 --only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
27
- # 等用户回复序号/「全部」后再带 --indices <k-1> / --only / --scope-all 重跑(rules.md §十三)。
26
+ # 多画板选择门禁:不带 --pick/--only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
27
+ # 等用户回复序号/「全部」后照抄清单行尾的 --pick 重跑(rules.md §十三)。
28
28
  python parse_meaxure.py --sample 5 # 抽样代表性画板
29
29
  python parse_meaxure.py --only "未登录,有交易" # 按名称子串
30
- python parse_meaxure.py --indices 0,9,18 # 按序号
30
+ python parse_meaxure.py --pick 8.1 # 按清单序号(1 起,可带卡号)——推荐,免换算
31
+ python parse_meaxure.py --indices 0,9,18 # 按 0 基序号(老写法,与 --pick 等价)
31
32
  python parse_meaxure.py --src <导出目录> --out <UI> --build
32
33
  python parse_meaxure.py --build # 一步式(含 build_prompt)
33
34
  """
34
35
  import argparse
36
+ import difflib
35
37
  import hashlib
36
38
  import json
37
39
  import math
@@ -515,25 +517,60 @@ def _deduplicate_shell_elements(els):
515
517
  return output
516
518
 
517
519
 
518
- def focus_artboard(ab_raw, preview_path, cdir):
519
- """对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
520
+ def detect_artboard_cards(ab_raw):
521
+ """画板级几何分析(不读图、不裁切):检出卡片候选 → 逐块聚焦判定。
520
522
 
521
- 返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
523
+ 返回 dict:W / H / layers / overlay / sheet / conversationFlow /
524
+ cards [(x,y,w,h,els,shapes,focusMetrics)] / rejected [{rect,reason,metrics}] /
525
+ focusSkipReason("" | overlay_dialog_geometry | bottom_sheet_geometry)。
526
+
527
+ 这是**唯一**的卡片检出入口:focus_artboard(真跑,随后按卡片裁图)与门禁清单的
528
+ board_verdict(只想知道「这个画板有没有卡可建」)都走它,两处判定不会漂移。
522
529
  """
523
530
  W, H = ab_raw.get("width", 0), ab_raw.get("height", 0)
524
531
  raw_layers = ab_raw.get("layers") or []
525
- flattened_layers = vision._flatten_with_tree(raw_layers)
526
- overlay = vision.detect_overlay_dialog(vision._flatten_with_tree(raw_layers), W, H)
532
+ flattened = vision._flatten_with_tree(raw_layers)
533
+ overlay = vision.detect_overlay_dialog(flattened, W, H)
527
534
  bottom_sheet = vision._bottom_sheet_rect(raw_layers, W, H)
528
- conversation_flow = vision.detect_conversation_flow(flattened_layers, W, H)
535
+ conversation_flow = vision.detect_conversation_flow(flattened, W, H)
536
+ cards, rejected, focus_skip_reason = [], [], ""
529
537
  if overlay or bottom_sheet:
530
- cards = []
531
538
  focus_skip_reason = (
532
539
  "overlay_dialog_geometry" if overlay else "bottom_sheet_geometry"
533
540
  )
534
541
  else:
535
- cards = vision.detect_cards(raw_layers, W, H, conversation_flow)
536
- focus_skip_reason = ""
542
+ for x, y, w, h, els, shapes in vision.detect_cards(raw_layers, W, H, conversation_flow):
543
+ eligible, reason, focus_metrics = vision.classify_card_focus(
544
+ els, (x, y, w, h), W, H, conversation_flow)
545
+ if eligible:
546
+ cards.append((x, y, w, h, els, shapes, focus_metrics))
547
+ else:
548
+ rejected.append({
549
+ "rect": {"x": round(x, 2), "y": round(y, 2),
550
+ "width": round(w, 2), "height": round(h, 2)},
551
+ "reason": reason,
552
+ "metrics": focus_metrics,
553
+ })
554
+ return {
555
+ "W": W, "H": H, "layers": raw_layers,
556
+ "overlay": bool(overlay), "sheet": bool(bottom_sheet),
557
+ "conversationFlow": conversation_flow,
558
+ "cards": cards, "rejected": rejected,
559
+ "focusSkipReason": focus_skip_reason,
560
+ }
561
+
562
+
563
+ def focus_artboard(ab_raw, preview_path, cdir):
564
+ """对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
565
+
566
+ 返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
567
+ """
568
+ found = detect_artboard_cards(ab_raw)
569
+ W, H = found["W"], found["H"]
570
+ raw_layers = found["layers"]
571
+ overlay, bottom_sheet = found["overlay"], found["sheet"]
572
+ conversation_flow = found["conversationFlow"]
573
+ focus_skip_reason = found["focusSkipReason"]
537
574
 
538
575
  for old_png in cdir.glob("card-*.png"):
539
576
  old_png.unlink()
@@ -544,21 +581,8 @@ def focus_artboard(ab_raw, preview_path, cdir):
544
581
  elements.append(serialize_element(ve, ve.raw))
545
582
 
546
583
  card_regions = []
547
- rejected_regions = []
548
- eligible_cards = []
549
- for x, y, w, h, els, shapes in cards:
550
- rect = (x, y, w, h)
551
- eligible, reject_reason, focus_metrics = vision.classify_card_focus(
552
- els, rect, W, H, conversation_flow)
553
- if not eligible:
554
- rejected_regions.append({
555
- "rect": {"x": round(x, 2), "y": round(y, 2),
556
- "width": round(w, 2), "height": round(h, 2)},
557
- "reason": reject_reason,
558
- "metrics": focus_metrics,
559
- })
560
- continue
561
- eligible_cards.append((x, y, w, h, els, shapes, focus_metrics))
584
+ rejected_regions = found["rejected"]
585
+ eligible_cards = found["cards"]
562
586
 
563
587
  from build_prompt import detect_list_in_card # 列表检测复用 build_prompt(无循环依赖)
564
588
  for i, (x, y, w, h, els, shapes, focus_metrics) in enumerate(eligible_cards, 1):
@@ -607,16 +631,29 @@ def focus_artboard(ab_raw, preview_path, cdir):
607
631
  region["detectedList"] = detect_list_in_card(region) or None
608
632
  card_regions.append(region)
609
633
 
634
+ # 纯文本板(对话/问答/提示)连兜底截图都不产:一张整屏裁切图叫 card-1.png,下游
635
+ # 会当成「这个板有卡」(spec_tree.mjs 就拿 card-1.png 当预览降级),正好抵消本次过滤。
636
+ # 判定输入与 classify_artboard 同源:候选全被纯文本类理由否掉 + 板上无卡片级大盘切图。
637
+ text_only = is_text_only_artboard(
638
+ {"cards": card_regions,
639
+ "rejectedCards": rejected_regions,
640
+ "focusSkipReason": focus_skip_reason or (
641
+ "no_structural_card_region" if not card_regions else ""),
642
+ "size": {"width": W, "height": H}},
643
+ raw_layers,
644
+ )
645
+
610
646
  # Fallback(2026-08-31):视觉检测 0 个独立卡片区域时,仍裁一张图 card-1.png,
611
647
  # 保证每个普通画板都有 card-* 截图(下游聚类/训练参考用)。不写入 card_regions,
612
648
  # 避免误触发下游卡片生成(spec.cards 仍空 → status 维持 skipped/generated)。
613
649
  # 仅对普通画板生效;overlay_dialog / bottom_sheet 是弹框,不应裁卡截图。
614
- # 会话流画板(气泡/输入坞/键盘)同样不产兜底图:整屏截图含对话气泡与纯文本,
650
+ # 纯文本板(text_only)与对话流画板(气泡/输入坞/键盘)都同样不产兜底图:
651
+ # 整屏截图含对话气泡与纯文本,易被误读为「把整屏做成了卡片」
615
652
  # 易被误读为「把整屏做成了卡片」,且 artboard.png 已可承担参考职责。
616
653
  # 裁切去掉系统 chrome(状态栏 / Home Indicator),保留应用 UI(顶部导航/底部 tab),
617
654
  # 对齐「仅卡片主体」约定。偏移按设计稿高度粗判(刘海屏状态栏 44、老屏 20;
618
655
  # Home Indicator 仅刘海屏 34),均为设计 px,crop_png_masked 内部乘 PREVIEW_SCALE。
619
- if (not card_regions and not overlay and not bottom_sheet
656
+ if (not text_only and not card_regions and not overlay and not bottom_sheet
620
657
  and not conversation_flow
621
658
  and preview_path and preview_path.exists()):
622
659
  try:
@@ -1198,7 +1235,7 @@ def mark_missing_slice_elements(spec, preview_path, asset_index, asset_root=None
1198
1235
  element.pop("annotationDefect", None)
1199
1236
  element.pop("visualIdentity", None)
1200
1237
  spec.pop("annotationDefects", None)
1201
- image_width, _, _, _ = png_utils.read_png(preview_path)
1238
+ image_width, _, _ = png_utils.read_png_size(preview_path)
1202
1239
  design_width = float((spec.get("size") or {}).get("width") or 375)
1203
1240
  scale = image_width / design_width if design_width else PREVIEW_SCALE
1204
1241
 
@@ -1412,6 +1449,25 @@ def copy_slices(src_assets, dst_slices, matched_rel):
1412
1449
  d.parent.mkdir(parents=True, exist_ok=True)
1413
1450
  shutil.copy2(s, d)
1414
1451
  return list(matched_rel or [])
1452
+
1453
+
1454
+ def require_out_dir(target: Path, what: str) -> None:
1455
+ """落盘前确认输出目录还在。
1456
+
1457
+ 目录是本脚本自己建的(main 里 mkdir(parents=True, exist_ok=True)),但**解析途中**可能被
1458
+ 外部清理掉:另一次 `npm run drill` / `cardbuddy init` / `rm -rf` 撞上同一个 UI/ 就会这样。
1459
+ 此时 write_text 抛的是一句裸 FileNotFoundError: '…/spec.json',看的人只会以为是脚本不会建
1460
+ 目录,排查方向整个跑偏(真实事故:一次 drill 把正在跑的回合连同 UI/ 一起清空了)。
1461
+ 这里显式判一次,把「是谁把目录删了」写进结论里。
1462
+ """
1463
+ if not target.is_dir():
1464
+ raise SystemExit(
1465
+ f"[FAIL] {what}的输出目录在解析途中消失了:{target}\n"
1466
+ f" 脚本开头已建过该目录,多半是另一个进程在清理同一个 UI/ ——\n"
1467
+ f" 比如 npm run drill / cardbuddy init / rm -rf 撞上了本次运行。\n"
1468
+ f" 重跑本命令即可;若反复出现,先确认没有别的清理任务在同时跑。"
1469
+ )
1470
+
1415
1471
  # ---------- 画板选择(与业务无关,纯通用关键字) ----------
1416
1472
  PRIORITY_KEYWORDS = [
1417
1473
  "未登录", "无数据", "无记录", "无交易", "空", "成功", "失败", "错误",
@@ -1420,10 +1476,76 @@ PRIORITY_KEYWORDS = [
1420
1476
  ]
1421
1477
 
1422
1478
 
1479
+ def _split_pick_token(t, total):
1480
+ """单个 --pick token → (板号, 卡号或 None),越界/非数字就在这里报错。"""
1481
+ board_part, _, card_part = t.partition(".")
1482
+ if not board_part.isdigit():
1483
+ raise SystemExit(f"[ERR] --pick 的画板序号必须是数字(1 起,如 8 或 8.1):{t}")
1484
+ board = int(board_part)
1485
+ if board < 1 or board > total:
1486
+ raise SystemExit(f"[ERR] --pick {t} 超范围:本导出共 {total} 个画板,序号 1..{total}")
1487
+ if card_part and not (card_part.isdigit() and int(card_part) >= 1):
1488
+ raise SystemExit(f"[ERR] --pick 的卡号必须是 1 起的数字(如 8.1):{t}")
1489
+ return board, (int(card_part) if card_part else None)
1490
+
1491
+
1492
+ def parse_pick(spec, total):
1493
+ """解析 --pick:画板序号 **1 起**(与清单同口径),可带 `.卡号`(1 起),逗号分隔多个。
1494
+
1495
+ 存在意义:清单上是 1 起的序号、--indices 是 0 起,差一位靠脑补——实测模型先用
1496
+ --indices 8 建了错的画板(34s 白跑 + 一步 LLM),自己发现后才重跑 --indices 7。
1497
+ 用户说「8.1」时,模型只需把用户原话搬进 --pick,不换算。
1498
+
1499
+ 也认清单行尾那种区间写法:`7~9`(连板)、`8.1~8.3`(同板连卡,一板多卡的批量建卡就靠它)。
1500
+ 清单提示与解析必须是同一套语法——提示打得出来、解析器却不认,等于让模型照抄一句必错的命令。
1501
+
1502
+ 返回 (indices, cards):indices 为 0 基画板索引(给 select_canvases 用,去重保序);
1503
+ cards 为 [(板号, 卡号)],卡号供下游 spec_audit --card 用。
1504
+ """
1505
+ idxs, cards = [], []
1506
+
1507
+ def add_board(b):
1508
+ if b - 1 not in idxs:
1509
+ idxs.append(b - 1)
1510
+
1511
+ for token in str(spec).split(","):
1512
+ t = token.strip()
1513
+ if not t:
1514
+ continue
1515
+ if "~" not in t:
1516
+ board, card = _split_pick_token(t, total)
1517
+ add_board(board)
1518
+ if card:
1519
+ cards.append((board, card))
1520
+ continue
1521
+ lo, _, hi = t.partition("~")
1522
+ b1, c1 = _split_pick_token(lo.strip(), total)
1523
+ b2, c2 = _split_pick_token(hi.strip(), total)
1524
+ if c1 is None and c2 is None:
1525
+ if b2 < b1:
1526
+ raise SystemExit(f"[ERR] --pick 区间写反了(起点应不大于终点):{t}")
1527
+ for b in range(b1, b2 + 1):
1528
+ add_board(b)
1529
+ elif b1 == b2 and c1 and c2:
1530
+ if c2 < c1:
1531
+ raise SystemExit(f"[ERR] --pick 卡号区间写反了(起点应不大于终点):{t}")
1532
+ add_board(b1)
1533
+ for c in range(c1, c2 + 1):
1534
+ cards.append((b1, c))
1535
+ else:
1536
+ raise SystemExit(
1537
+ f"[ERR] --pick 的区间两端要同型(都带卡号或都不带,如 7~9、8.1~8.3):{t}")
1538
+ if not idxs:
1539
+ raise SystemExit("[ERR] --pick 至少要给一个画板序号")
1540
+ return idxs, cards
1541
+
1542
+
1423
1543
  def select_canvases(all_names, args):
1424
1544
  n = len(all_names)
1425
1545
  if args.list:
1426
1546
  return None
1547
+ if args.pick:
1548
+ return parse_pick(args.pick, n)[0]
1427
1549
  if args.only:
1428
1550
  kws = [k.strip() for k in args.only.split(",") if k.strip()]
1429
1551
  return [i for i, nm in enumerate(all_names) if any(k in nm for k in kws)]
@@ -1509,6 +1631,50 @@ def detect_modal_layers(raw_layers, W=375, H=812):
1509
1631
  return has_scrim, has_bottom_sheet
1510
1632
 
1511
1633
 
1634
+ # 判定「这块区域不是卡片」的拒绝理由全集:纯文本 / 散文段落 / 对话气泡 / 输入坞 / 空区域。
1635
+ # 落在这组里说明区域「本来就不是卡片」,而不是「是卡片但结构弱或结构读错了」——
1636
+ # 后者(single_button_or_bar / repeated_control_bar 之类)一律不在此列:汇率牌价那种宽扁卡
1637
+ # 会被读成「按钮条」,3 宫格选项卡会被读成「控件条」,它们明明是卡,不能当纯文本滤掉。
1638
+ TEXT_ONLY_REJECT_REASONS = {
1639
+ "text_only_or_low_structure_region",
1640
+ "prose_paragraph_region",
1641
+ "conversation_bubble_region",
1642
+ "stacked_conversation_bubbles_region",
1643
+ "conversation_inputDock_region",
1644
+ "conversation_keyboard_region",
1645
+ "empty_region",
1646
+ }
1647
+
1648
+
1649
+ def is_text_only_artboard(spec, raw_layers=None):
1650
+ """画板级「无卡可建」判定:整屏都是文字的对话 / 问答 / 提示页一律不产卡。
1651
+
1652
+ 这类画板(AI 应答气泡 + 输入坞、知识问答、纯文字提示)里没有任何值得落地的卡片结构,
1653
+ 留在产卡池里只会让选板清单变噪音、让下游白跑一轮——用户 2026-09-20 规则。
1654
+
1655
+ 注意「无卡」≠「纯文本」,收口条件要卡死,否则会把「对话里嵌着一张卡」的画板一起丢掉:
1656
+ · 弹框 / 底部抽屉同样 0 卡,但那是 alertDialog / alertCard 的形态,不在这里判;
1657
+ · 候选区域被否的理由只要有一个不属于「本来就不是卡」(例如 single_button_or_bar,
1658
+ 那是「读起来像卡但规则没认」的窄卡)就一律保留——汇率牌价这类宽扁卡就靠这条保住;
1659
+ · 板上有卡片级大盘切图(卡片被整体导成一张图)也保留。
1660
+ 只有「所有候选都被纯文本类理由否掉、且找不到任何卡片级结构」才判纯文本。
1661
+ 其余「0 卡但结构丰富」的画板(骨架屏、全屏状态页等)保持现状,交给下游裁决。
1662
+ """
1663
+ if spec.get("cards"):
1664
+ return False
1665
+ if spec.get("focusSkipReason") in ("overlay_dialog_geometry", "bottom_sheet_geometry"):
1666
+ return False
1667
+ # 卡片被设计稿整体导成一张切图时(画板里只剩那张大图),检测器看不到结构,但板上
1668
+ # 明明有卡——这类一律保留,否则「对话 + 内嵌卡片」的状态会被当成纯文本丢掉。
1669
+ if raw_layers:
1670
+ size = spec.get("size") or {}
1671
+ if vision.has_card_like_visual(vision._flatten_with_tree(raw_layers),
1672
+ size.get("width") or 375, size.get("height") or 812):
1673
+ return False
1674
+ reasons = {r.get("reason") for r in (spec.get("rejectedCards") or [])}
1675
+ return bool(reasons) and reasons <= TEXT_ONLY_REJECT_REASONS
1676
+
1677
+
1512
1678
  def classify_artboard(name, spec, raw_layers, H=812):
1513
1679
  """返回 (artboardType, interaction)。视觉信号优先,名称仅兜底(保守)。
1514
1680
  判定顺序:
@@ -1527,7 +1693,10 @@ def classify_artboard(name, spec, raw_layers, H=812):
1527
1693
  return "subcard", "alertCard 子卡(视觉信号:底部抽屉面板,由父卡 alertCard 打开)"
1528
1694
  if has_scrim:
1529
1695
  return "overlay_dialog", "alertDialog 系统弹框(视觉信号:全屏遮罩)"
1530
- # 2) 名称兜底:通用 UI 容器类型词(非业务实体名)
1696
+ # 2) 画板级:纯文本(对话/问答/提示)整板无卡 → text_only,下游不得为其建卡
1697
+ if is_text_only_artboard(spec, raw_layers):
1698
+ return "text_only", "无卡可建(纯文本对话/问答/提示,未检出卡片区域)"
1699
+ # 3) 名称兜底:通用 UI 容器类型词(非业务实体名)
1531
1700
  nm = normalize_name(name)
1532
1701
  if any(rx.search(nm) for rx in _SUBCARD_RE):
1533
1702
  return "subcard", "alertCard 子卡(名称兜底:通用容器类型词)"
@@ -1606,10 +1775,46 @@ def preview_image_for(src, base, ab, name):
1606
1775
  return None
1607
1776
 
1608
1777
 
1609
- def print_board_line(i, nm, ab, src, base):
1778
+ def board_tag(verdict):
1779
+ """清单行尾的体检标记:一眼看出这个画板能不能建卡。"""
1780
+ if not verdict:
1781
+ return ""
1782
+ if verdict["kind"] == "text_only":
1783
+ return " [纯文本,无卡可建 · 已过滤]"
1784
+ if verdict["kind"] == "subcard":
1785
+ return " [子卡 alertCard]"
1786
+ if verdict["kind"] == "overlay_dialog":
1787
+ return " [弹框 alertDialog]"
1788
+ if verdict.get("cards"):
1789
+ return f" [{verdict['cards']} 卡]"
1790
+ return " [0 卡 · 结构丰富,按整图参考建卡]"
1791
+
1792
+
1793
+ def board_verdict(name, ab):
1794
+ """画板体检(纯几何,不读图):这个画板进不进「可建卡选项池」,以及它是什么类型。
1795
+
1796
+ 返回 {kind, cards, skip, interaction}。kind 取 classify_artboard 的口径(card /
1797
+ subcard / overlay_dialog / text_only),所以清单上写的话与真跑产物里的 artboardType
1798
+ 永远一致——两处都走 detect_artboard_cards + classify_artboard,没有第二套判定。
1799
+ """
1800
+ ab = ab or {}
1801
+ found = detect_artboard_cards(ab)
1802
+ probe = {
1803
+ "cards": found["cards"],
1804
+ "rejectedCards": found["rejected"],
1805
+ "conversationFlow": found["conversationFlow"],
1806
+ "focusSkipReason": found["focusSkipReason"],
1807
+ "size": {"width": found["W"], "height": found["H"]},
1808
+ }
1809
+ at, interaction = classify_artboard(name, probe, found["layers"], found["H"])
1810
+ return {"kind": at, "cards": len(found["cards"]), "interaction": interaction,
1811
+ "skip": at == "text_only"}
1812
+
1813
+
1814
+ def print_board_line(i, nm, ab, src, base, verdict=None):
1610
1815
  """门禁/清单统一行格式:序号行 + 下一行该画板自己的预览图(一一对应;缺图标缺失)。"""
1611
1816
  sz = f'{ab.get("width")}x{ab.get("height")}' if ab else "?"
1612
- print(f" {i + 1:>2}. {nm} ({sz})")
1817
+ print(f" {i + 1:>2}. {nm} ({sz}){board_tag(verdict)}{pick_hint(i, verdict)}")
1613
1818
  img = preview_image_for(src, base, ab, nm)
1614
1819
  if img:
1615
1820
  print(f" 图: {img}")
@@ -1617,15 +1822,184 @@ def print_board_line(i, nm, ab, src, base):
1617
1822
  print(" 图: (预览图缺失:preview/<slug>.png 与 UI 产物 artboard.png 均不存在)")
1618
1823
 
1619
1824
 
1825
+ def pick_hint(i, verdict):
1826
+ """清单行尾的「照抄就能用」参数:板号 1 起、卡号 1 起,直接喂 --pick。
1827
+
1828
+ 0 基的 --indices 与用户嘴里的「第 8 个画板第 1 张卡」差一位,实测模型会用错、
1829
+ 发现后重跑一整次构建(34s + 一步 LLM)。把可复制的 --pick 打在它自己那行上,
1830
+ 模型只需要搬字,不需要换算。
1831
+ """
1832
+ if verdict and verdict.get("kind") == "text_only":
1833
+ return ""
1834
+ cards = (verdict or {}).get("cards") or 0
1835
+ if cards > 1:
1836
+ return f" --pick {i + 1}.1~{i + 1}.{cards}"
1837
+ if cards == 1:
1838
+ return f" --pick {i + 1}.1"
1839
+ return f" --pick {i + 1}"
1840
+
1841
+
1842
+ def print_board_pool(names, artboards, src, base, all_label):
1843
+ """清单分两段打:可建卡选项池(带序号 + 各自预览图)与已过滤(纯文本,无卡可建)。
1844
+
1845
+ 过滤只针对**整屏纯文本**的画板(is_text_only_artboard):它们放在选项池里只会让用户
1846
+ 选到没有卡可建的板子。序号保持全量 1..n 不变(回复 k → --indices k-1 仍对得上),
1847
+ 被过滤的板子只列名字、不配图,不制造「这是个选项」的错觉。
1848
+ 返回 (可建卡序号列表, 已过滤序号列表)。
1849
+ """
1850
+ verdicts = [board_verdict(names[i], artboards[i] if i < len(artboards) else None)
1851
+ for i in range(len(names))]
1852
+ pool = [i for i, v in enumerate(verdicts) if not v["skip"]]
1853
+ filtered = [i for i, v in enumerate(verdicts) if v["skip"]]
1854
+ for i in pool:
1855
+ print_board_line(i, names[i], artboards[i] if i < len(artboards) else None,
1856
+ src, base, verdicts[i])
1857
+ print(all_label)
1858
+ if filtered:
1859
+ print(f" —— 另有 {len(filtered)} 个纯文本画板(对话/问答/提示)无卡可建,已过滤不进选项池"
1860
+ f"(序号仍按全量 1..n 编,回复其序号也不会产卡):")
1861
+ for i in filtered:
1862
+ print(f" {i + 1}. {names[i]}")
1863
+ return pool, filtered
1864
+
1865
+
1866
+ def _candidate_export_dirs(roots, max_depth=3, limit=600):
1867
+ """在若干根目录下做**有限广度**扫描,收集「含 index.html 的目录」(Sketch MeaXure 导出根的判据)。
1868
+
1869
+ - 深度 3 是实测出来的:真实导出根常住在 `~/Downloads/<资料包>/<已确认的>/<稿名>/`,
1870
+ 只扫 2 层会正好漏掉它(本次自测就是这么漏的)。
1871
+ - 命中导出根就不再往下钻(它内部还有 preview/ assets/,再钻只会灌满噪音)。
1872
+ - 按 **realpath** 去重:`/tmp` 与 `/private/tmp` 是同一份目录的两种写法,
1873
+ 不去重会让「唯一高分候选」变成两个并列项,把下面的自动修正判据直接顶掉。
1874
+ """
1875
+ found, seen, seen_real = [], set(), set()
1876
+ for root in roots:
1877
+ root = Path(root).expanduser()
1878
+ if not root.is_dir():
1879
+ continue
1880
+ stack = [(root, 0)]
1881
+ while stack:
1882
+ d, depth = stack.pop()
1883
+ key = str(d)
1884
+ if key in seen:
1885
+ continue
1886
+ seen.add(key)
1887
+ # 探测整体兜住 OSError:本机 python 是 3.9,`Path.is_file()` 在无权限目录上**会抛
1888
+ # PermissionError**(3.12 起才改成返回 False)。扫到 /private/var/root 这类目录时不兜,
1889
+ # 整个脚本就崩在探测里——比原来「直接报错」还糟。
1890
+ try:
1891
+ if (d / "index.html").is_file():
1892
+ real = str(d.resolve())
1893
+ if real not in seen_real:
1894
+ seen_real.add(real)
1895
+ found.append(d)
1896
+ continue
1897
+ if depth >= max_depth:
1898
+ continue
1899
+ children = sorted(d.iterdir())
1900
+ except (PermissionError, OSError):
1901
+ continue
1902
+ for child in children:
1903
+ if child.name.startswith("."):
1904
+ continue
1905
+ # 同上的 3.9 坑:`is_dir()` 在受保护目录(如 /private/var/db/DifferentialPrivacy)
1906
+ # 上会抛 PermissionError,逐个子项兜
1907
+ try:
1908
+ if child.is_dir():
1909
+ stack.append((child, depth + 1))
1910
+ except (PermissionError, OSError):
1911
+ continue
1912
+ if len(found) > limit:
1913
+ return found
1914
+ return found
1915
+
1916
+
1917
+ def _path_affinity(src, cand):
1918
+ """候选与请求路径的「同侧程度」= 公共父级目录层数。
1919
+
1920
+ 同一份稿常被拷到 Downloads 与 Desktop 两处(同名同分)。此时该选离调用方给的路径更近的那个,
1921
+ 而不是让两个同分候选把自动修正判据顶掉。
1922
+ """
1923
+ n = 0
1924
+ for x, y in zip(src.parent.parts, cand.parent.parts):
1925
+ if x != y:
1926
+ break
1927
+ n += 1
1928
+ return n
1929
+
1930
+
1931
+ def _dir_mtime(d):
1932
+ """目录最近改动时间(探测失败取 0):没给 --src 时用它把「刚拖进来的那份稿」排前面。"""
1933
+ try:
1934
+ return d.stat().st_mtime
1935
+ except (PermissionError, OSError):
1936
+ return 0.0
1937
+
1938
+
1939
+ def resolve_src(raw_src, explicit=True):
1940
+ """把 --src 解析成真实导出根:能唯一定位就自动修正,否则回一份候选清单。
1941
+
1942
+ 实测两次白烧(2026-09-21 建卡回合各烧掉一步工具调用):
1943
+ ① 没带 --src ⇒ 默认 cwd(工程根)没有 index.html,直接 [ERR] 退出;
1944
+ ② 目录名拼重复(黑马大赛-黑马大赛积分助手ui设计稿)⇒ 路径不存在,同样直接退出。
1945
+ 两次都「差一点点就对」。这里把它们变成一次自动修正,或一份可直接照抄的候选清单。
1946
+
1947
+ 自动修正只在高置信时发生(名字高度相似且明显强于第二名),且必定打 [WARN] 说明——
1948
+ 宁可让调用方多看一眼,也不要在多稿目录里默默建错稿。
1949
+
1950
+ @return (src_or_None, ranked) —— ranked 为 [(相似度, Path), ...],供失败时打印候选。
1951
+ """
1952
+ src = Path(raw_src).expanduser()
1953
+ if (src / "index.html").is_file():
1954
+ return src, []
1955
+
1956
+ roots = []
1957
+ for r in (Path.cwd(), Path.cwd().parent, Path.home() / "Downloads", Path.home() / "Desktop"):
1958
+ if r not in roots:
1959
+ roots.append(r)
1960
+ # 调用方给的路径本身是条线索:它的父目录最值得先扫
1961
+ if src.parent.is_dir() and src.parent not in roots:
1962
+ roots.insert(0, src.parent)
1963
+
1964
+ cands = _candidate_export_dirs(roots)
1965
+ if not cands:
1966
+ return None, []
1967
+ want = src.name or ""
1968
+ ranked = sorted(
1969
+ ((difflib.SequenceMatcher(None, want, c.name).ratio(), _path_affinity(src, c), c) for c in cands),
1970
+ key=lambda x: (-x[0], -x[1]),
1971
+ )
1972
+ # ① 没显式给 --src:全库只有一个导出根 ⇒ 就是它;否则按「最近改动」排候选
1973
+ # (刚拖进来的那份稿排最前,比按名字乱序有用)
1974
+ if not explicit:
1975
+ if len(cands) == 1:
1976
+ return cands[0], ranked
1977
+ recent = sorted(cands, key=_dir_mtime, reverse=True)
1978
+ return None, [(0.0, 0, c) for c in recent]
1979
+ best_ratio, _aff, best = ranked[0]
1980
+ # ② 给了但拼错:名字高度相似、且明显强于**名字不同的**第二名,才敢自动修正。
1981
+ # 同名候选(同一份稿拷到 Downloads 与 Desktop)是同一个答案、不是竞争答案,
1982
+ # 不排除它会让「唯一定位」永远不成立。
1983
+ rival = next((r for r, _a, c in ranked if c.name != best.name), None)
1984
+ if best_ratio >= 0.75 and (rival is None or rival < best_ratio - 0.05):
1985
+ return best, ranked
1986
+ return None, ranked
1987
+
1988
+
1620
1989
  def main():
1621
1990
  ap = argparse.ArgumentParser(description="Sketch MeaXure 标注 → 卡片要素包(视觉驱动)")
1622
- ap.add_argument("--src", default=str(DEFAULT_SRC))
1991
+ ap.add_argument("--src", default=None,
1992
+ help="Sketch MeaXure 导出根(含 index.html)。缺省或用错时自动探测:能唯一定位就自动修正,"
1993
+ "否则打印候选清单(照抄其一作为 --src)")
1623
1994
  ap.add_argument("--out", default=str(DEFAULT_OUT))
1624
1995
  ap.add_argument("--list", action="store_true")
1625
1996
  ap.add_argument("--scope-all", action="store_true",
1626
1997
  help="显式授权跑全部画板:仅当用户回复「全部」后才可加(无人值守批量训练可直接加或设 SKETCH_SPEC_SCOPE_ALL=1)")
1627
1998
  ap.add_argument("--only", default=None)
1628
1999
  ap.add_argument("--indices", default=None)
2000
+ ap.add_argument("--pick", default=None,
2001
+ help="按清单序号选板(1 起,可带卡号:8 或 8.1,逗号分隔多个)——"
2002
+ "清单行尾的 --pick 值照抄即用,不必换算 0 基的 --indices")
1629
2003
  ap.add_argument("--sample", type=int, default=0)
1630
2004
  ap.add_argument("--build", action="store_true")
1631
2005
  ap.add_argument("--artboard-max-side", type=int, default=640,
@@ -1634,13 +2008,26 @@ def main():
1634
2008
  help="按稿分片(可选):画板落盘到 <out>/<draft>/<canvas>/,并写 <out>/<draft>/manifest.json;缺省平铺(兼容旧布局)")
1635
2009
  args = ap.parse_args()
1636
2010
 
1637
- src = Path(args.src).expanduser()
2011
+ explicit_src = args.src is not None
2012
+ raw_src = args.src or str(DEFAULT_SRC)
2013
+ src, cands = resolve_src(raw_src, explicit=explicit_src)
1638
2014
  out = Path(args.out).expanduser().resolve()
1639
2015
  # 按稿分片:指定 --draft 时,所有画板与 manifest 落在 <out>/<draft>/ 下(UI 多稿治理,item 19)
1640
2016
  base = out / args.draft if args.draft else out
1641
- if not (src / "index.html").exists():
1642
- print(f"[ERR] 未找到 {src / 'index.html'},请用 --src 指定导出目录", file=sys.stderr)
2017
+ if src is None:
2018
+ wanted = Path(raw_src).expanduser() / "index.html"
2019
+ if cands:
2020
+ print(f"[ERR] {wanted} 不存在——它不是 Sketch MeaXure 导出根(导出根必须直接含 index.html)", file=sys.stderr)
2021
+ print("[ERR] 已发现这些导出根,照抄其一作为 --src 重跑:", file=sys.stderr)
2022
+ for _ratio, _aff, c in cands[:8]:
2023
+ print(f" --src {c}", file=sys.stderr)
2024
+ else:
2025
+ print(f"[ERR] 未找到 {wanted},且常见位置(cwd / 上级 / ~/Downloads / ~/Desktop)也没扫到导出根。", file=sys.stderr)
2026
+ print("[ERR] 请用 --src 指向 Sketch MeaXure 导出目录(该目录下应直接有 index.html)。", file=sys.stderr)
1643
2027
  sys.exit(1)
2028
+ if src != Path(raw_src).expanduser():
2029
+ why = "未显式指定 --src" if not explicit_src else f"--src={Path(raw_src).expanduser()} 下没有 index.html"
2030
+ print(f"[WARN] {why}:已自动选用 {src}(含 index.html)。若不对,请显式 --src 指定。", file=sys.stderr)
1644
2031
 
1645
2032
  data = extract_data(src / "index.html")
1646
2033
  artboards = data.get("artboards", [])
@@ -1651,33 +2038,46 @@ def main():
1651
2038
  if args.list:
1652
2039
  # 展示统一 1 起(与多画板门禁清单同口径);--indices 仍为 0 起,用户序号 k → k-1
1653
2040
  # 每个画板行下面紧跟它自己那张预览图(一一对应;缺图显式标缺失),禁止把图堆在末尾
1654
- for i, nm in enumerate(names):
1655
- print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1656
- print(" 全部. 全部")
2041
+ print_board_pool(names, artboards, src, base, " 全部. 全部")
1657
2042
  return
1658
2043
 
1659
2044
  # ── 多画板选择门禁(rules.md §十三 / SKILL.md §2 步骤 2,2026-09-17 机械兜底)──
1660
2045
  # 未指定范围的生成运行必须先让用户从 1..n + 全部 里选;「全部」是用户的回答,不是 Agent 的默认值。
1661
2046
  # 这里做机械卡点:打印清单后 exit 4,拿到用户回答再用 --indices / --only / --scope-all 重跑。
1662
- has_scope = bool(args.only or args.indices or args.sample)
2047
+ has_scope = bool(args.only or args.indices or args.pick or args.sample)
1663
2048
  scope_all_env = os.environ.get("SKETCH_SPEC_SCOPE_ALL", "") in ("1", "true", "yes")
1664
2049
  if not has_scope and not args.scope_all and not scope_all_env:
1665
2050
  print(f"[GATE] 多画板选择门禁(rules.md §十三):本导出共 {total} 个画板,本次运行未指定范围"
1666
2051
  f"(--only/--indices/--sample/--scope-all 均未给)。")
1667
2052
  print("[GATE] 按铁律必须先把下面的清单(1..n + 全部)交给用户并**停止输出等回复**,"
1668
2053
  "不得自行跑全量(「生成产物 / 前处理」请求同样适用):")
1669
- for i, nm in enumerate(names):
1670
- print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1671
- print(" 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权)")
1672
- print("[GATE] 拿到回复后重跑:回复序号 k → --indices <k-1>(0 起,多个用逗号);"
1673
- "回复「全部」→ --scope-all;无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
2054
+ print("[GATE] 清单已按体检结果分段:纯文本画板(对话/问答/提示)无卡可建,已过滤出选项池。")
2055
+ print_board_pool(names, artboards, src, base,
2056
+ " 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权"
2057
+ "——纯文本画板只出参考图,不出卡)")
2058
+ print("[GATE] 拿到回复后重跑:**直接照抄清单行尾的 --pick**(板号 1 起、卡号 1 起,"
2059
+ "如用户说「8.1」就 --pick 8.1;多张逗号分隔 --pick 8.1,9.2);"
2060
+ "等价的老写法是 --indices <k-1>(0 起)。回复「全部」→ --scope-all;"
2061
+ "无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
1674
2062
  sys.exit(4)
1675
2063
 
1676
2064
  sel = select_canvases(names, args)
1677
2065
  if not sel:
1678
2066
  print("[WARN] 没有匹配到任何画布。", file=sys.stderr)
1679
2067
  sys.exit(0)
2068
+ # 显式点名纯文本画板时不拦(用户可能只想拿参考图),但必须说清楚不会出卡
2069
+ for i in sel:
2070
+ v = board_verdict(names[i], artboards[i] if i < len(artboards) else None)
2071
+ if v["skip"]:
2072
+ print(f"[WARN] {names[i]}:纯文本画板(对话/问答/提示),无卡可建——"
2073
+ f"本次只会产出整图参考与判定依据,不会出卡。", file=sys.stderr)
1680
2074
  print(f"[INFO] 本次生成 {len(sel)} 个画布:{', '.join(names[i] for i in sel)}")
2075
+ if args.pick:
2076
+ _, picked_cards = parse_pick(args.pick, total)
2077
+ if picked_cards:
2078
+ print("[PICK] 卡号提示:" + " / ".join(
2079
+ f"画板 {b} 第 {c} 张卡 → 下游 spec_audit.mjs 用 --card {c}" for b, c in picked_cards))
2080
+ print(f"[PICK] 等价写法 --indices {','.join(str(i) for i in sel)}(0 起)")
1681
2081
 
1682
2082
  assets_dir = src / "assets"
1683
2083
  asset_index = build_asset_index(assets_dir)
@@ -1763,7 +2163,9 @@ def main():
1763
2163
  # JSAPI 交互控件检测(密码/验证码/数字键盘/toast/二次确认)
1764
2164
  spec["jsapiInteractions"] = detect_jsapi_interactions(spec, ab.get("layers") or [])
1765
2165
  entry["artboardType"] = at
1766
- entry["trainingEligible"] = at == "card"
2166
+ # 训练资格:卡片类型 + 真检出卡片区域。0 卡的画板(纯文本对话/问答/提示)没有
2167
+ # 可训练对象,标了 true 会一路污染训练集,这里按「有没有卡」收口。
2168
+ entry["trainingEligible"] = at == "card" and bool(spec["cards"])
1767
2169
 
1768
2170
  # 切图:逐元素建立导出映射,未命中素材的标注降级为整图采样纯色。
1769
2171
  slice_names = spec.pop("slices", [])
@@ -1782,10 +2184,18 @@ def main():
1782
2184
  # 画板是否无卡(0 卡 → 标记跳过,保留整图供参考)
1783
2185
  # 仅当既无独立卡片、又元素极少(<3)才视为真正空画板;
1784
2186
  # 元素丰富但无卡的情况可能是底部弹窗/全屏覆盖层,保留 status 供下游决策。
1785
- if not spec["cards"] and len(spec.get("elements", [])) < 3:
2187
+ if at == "text_only":
2188
+ # 整屏纯文本(对话/问答/提示):没有任何可建卡结构,直接跳过不产卡。
2189
+ # 选板清单里也不再作为候选出现(见 print_board_line / board_verdict)。
2190
+ entry["status"] = "skipped"
2191
+ spec["skippedReason"] = (
2192
+ "纯文本画板(对话/问答/提示):未检出任何可建卡区域,不产卡"
2193
+ )
2194
+ elif not spec["cards"] and len(spec.get("elements", [])) < 3:
1786
2195
  entry["status"] = "skipped"
1787
2196
  spec["skippedReason"] = "视觉检测无独立卡片元素(纯对话/提示/无卡片画板)"
1788
2197
 
2198
+ require_out_dir(cdir, f"画板 {name} 的要素包")
1789
2199
  (cdir / "spec.json").write_text(
1790
2200
  json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
1791
2201
 
@@ -1796,6 +2206,7 @@ def main():
1796
2206
 
1797
2207
  manifest["canvases"].append(entry)
1798
2208
 
2209
+ require_out_dir(base, "画板清单")
1799
2210
  (base / "manifest.json").write_text(
1800
2211
  json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
1801
2212
  print(f"[DONE] manifest.json 已写入 {base / 'manifest.json'}")