labull-framework 0.1.1__tar.gz → 0.1.2__tar.gz

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 (47) hide show
  1. {labull_framework-0.1.1/labull_framework.egg-info → labull_framework-0.1.2}/PKG-INFO +1 -1
  2. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/admin.py +1 -1
  3. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/data.py +228 -1
  4. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/static/canvas.css +17 -2
  5. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/static/canvas.js +206 -76
  6. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/devkit.py +12 -2
  7. {labull_framework-0.1.1 → labull_framework-0.1.2/labull_framework.egg-info}/PKG-INFO +1 -1
  8. {labull_framework-0.1.1 → labull_framework-0.1.2}/pyproject.toml +1 -1
  9. {labull_framework-0.1.1 → labull_framework-0.1.2}/LICENSE +0 -0
  10. {labull_framework-0.1.1 → labull_framework-0.1.2}/MANIFEST.in +0 -0
  11. {labull_framework-0.1.1 → labull_framework-0.1.2}/README-pypi.md +0 -0
  12. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/__init__.py +0 -0
  13. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/apps.py +0 -0
  14. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/auth/__init__.py +0 -0
  15. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/auth/backend.py +0 -0
  16. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/auth/bootstrap.py +0 -0
  17. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/auth/sso.py +0 -0
  18. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/auth/views.py +0 -0
  19. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/__init__.py +0 -0
  20. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/gitinfo.py +0 -0
  21. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/static/page.html +0 -0
  22. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/canvas/views.py +0 -0
  23. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/env.py +0 -0
  24. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/jsonio.py +0 -0
  25. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/__init__.py +0 -0
  26. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/deepcheck.py +0 -0
  27. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/dispatch.py +0 -0
  28. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/export.py +0 -0
  29. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/health.py +0 -0
  30. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/registry.py +0 -0
  31. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/sdk.py +0 -0
  32. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/service.py +0 -0
  33. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/validate.py +0 -0
  34. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/magic_block/views.py +0 -0
  35. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/management/__init__.py +0 -0
  36. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/management/commands/__init__.py +0 -0
  37. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/management/commands/labull_contract.py +0 -0
  38. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/migrations/0001_initial.py +0 -0
  39. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/migrations/__init__.py +0 -0
  40. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/models.py +0 -0
  41. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/settings.py +0 -0
  42. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework/urls.py +0 -0
  43. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework.egg-info/SOURCES.txt +0 -0
  44. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework.egg-info/dependency_links.txt +0 -0
  45. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework.egg-info/requires.txt +0 -0
  46. {labull_framework-0.1.1 → labull_framework-0.1.2}/labull_framework.egg-info/top_level.txt +0 -0
  47. {labull_framework-0.1.1 → labull_framework-0.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -59,5 +59,5 @@ class MagicBlockAdmin(admin.ModelAdmin):
59
59
  return None
60
60
 
61
61
  def has_add_permission(self, request) -> bool:
62
- # 块的 uuid 由开发者在前端写死(`<MagicBlockHost uuid="…">`),admin 里"新增"没有意义。
62
+ # 块的 uuid 由开发者在前端路由表里写死(`MagicBlockPage` 的 `props.uuid`),admin 里"新增"没有意义。
63
63
  return False
@@ -366,18 +366,74 @@ def payload() -> dict[str, Any]:
366
366
  current_tables = current['tables']
367
367
  baseline = gitinfo.baseline_tables()
368
368
  diff = _diff(baseline, current_tables)
369
+ relation_diff = _relation_diff(baseline, current_tables)
369
370
  return {
370
371
  'tables': current_tables,
371
372
  'deletedTables': _deleted_tables(baseline, diff['deleted']),
372
373
  'diff': diff,
373
374
  # 逐列的差异(`表名 → {added, modified, deleted}`):前端拿它给**整行**上色。
374
375
  'fieldDiff': _field_diff(baseline, current_tables),
376
+ # 逐条**关联**的差异(`表名 → {added, modified, deleted}`,键是关联名):前端拿它给**连线上色**。
377
+ 'relationDiff': relation_diff,
378
+ # 🔴 **被删掉的关联**那几条的原文(`表名 → [关联]`):现在这一份里已经没有了,
379
+ # 前端要照基线的这一份把红线补画出来 —— 否则关联一删就"少了一根线",
380
+ # 看不出是哪两张表之间断的(与 `deletedTables` 同一个道理)。
381
+ 'deletedRelations': _deleted_relations(baseline, relation_diff),
375
382
  'git': gitinfo.head(),
376
383
  'baseline': 'none' if baseline is None else 'present',
377
384
  'layout': read_layout(),
378
385
  }
379
386
 
380
387
 
388
+ def _deleted_relations(
389
+ baseline: list[dict[str, Any]] | None,
390
+ relation_diff: dict[str, dict[str, list[str]]],
391
+ ) -> dict[str, list[dict[str, Any]]]:
392
+ """基线里有、现在没有的那些关联(只取真的被删掉的那几个键)。
393
+
394
+ 🔴 **要把 `targetTable` 补齐**:老契约里的关联只写了 `target`(模型名),没有 `targetTable`
395
+ (表名)—— 而前端是**按表名**找卡片的。不补的话那条红线找不到对端,会被直接跳过,
396
+ 症状就是"关联删了但图上什么都没少"(与这一条功能想解决的问题正好相反)。
397
+ """
398
+ if not baseline or not relation_diff:
399
+ return {}
400
+ out: dict[str, list[dict[str, Any]]] = {}
401
+ for table in baseline:
402
+ name = str(table.get('table'))
403
+ removed = (relation_diff.get(name) or {}).get('deleted') or []
404
+ if not removed:
405
+ continue
406
+ old = _relations_of(table)
407
+ rows = []
408
+ for key in removed:
409
+ row = old.get(key)
410
+ if row is None:
411
+ continue
412
+ fixed = dict(row)
413
+ if not fixed.get('targetTable'):
414
+ fixed['targetTable'] = _table_name_of(fixed.get('target'))
415
+ rows.append(fixed)
416
+ if rows:
417
+ out[name] = rows
418
+ return out
419
+
420
+
421
+ def _table_name_of(model_name: Any) -> str:
422
+ """模型名 → **数据库表名**(补老契约缺的那一项)。查不到就回空串,由前端跳过这一条。"""
423
+ wanted = str(model_name or '')
424
+ if not wanted:
425
+ return ''
426
+ try:
427
+ from django.apps import apps
428
+
429
+ for model in apps.get_models():
430
+ if model.__name__ == wanted:
431
+ return str(model._meta.db_table)
432
+ except Exception: # noqa: BLE001 —— 补不上就空串,不影响别的
433
+ return ''
434
+ return ''
435
+
436
+
381
437
  def _deleted_tables(baseline: list[dict[str, Any]] | None, deleted: list[str]) -> list[dict[str, Any]]:
382
438
  """基线里有、现在没有的那些表(补上新契约才有的键,免得前端到处判空)。"""
383
439
  if not baseline or not deleted:
@@ -413,6 +469,10 @@ def _field_diff(baseline: list[dict[str, Any]] | None, current_tables: list[dict
413
469
  不需要再逐列标一遍。
414
470
  🔴 判据与 `_compare_key()` 同源:**列的类型 / 主键 / 可空 / 唯一变了才算改**,
415
471
  备注文案(`brief`)变了不算 —— 否则给某个字段补一句说明,整张表就满屏黄。
472
+ 🔴 **改名按"库里那条记录还在不在"判**(用户明确要求):
473
+ · 只改了名字、列还是原来那一列(类型 / 主键 / 可空 / 唯一 都没变)⇒ 算**修改**;
474
+ · 列被删掉、另外起了一列(形状不一样)⇒ 算**删一列 + 加一列**。
475
+ 配对只按**结构**配(见 `_renames()`):名字里带 `_id` 那种后缀差异不会影响判断。
416
476
  """
417
477
  if not baseline:
418
478
  return {}
@@ -431,11 +491,60 @@ def _field_diff(baseline: list[dict[str, Any]] | None, current_tables: list[dict
431
491
  for key, column in new.items()
432
492
  if key in old and _column_key(old[key]) != _column_key(column)
433
493
  ]
494
+ # 改名:把"删掉的"与"新加的"里形状一致的那些配成"修改"。
495
+ pairs = _renames(
496
+ [(key, _column_key(old[key])) for key in removed],
497
+ [(key, _column_key(new[key])) for key in added],
498
+ )
499
+ if pairs:
500
+ gone = {old_key for old_key, _new_key in pairs}
501
+ fresh = {new_key for _old_key, new_key in pairs}
502
+ removed = [key for key in removed if key not in gone]
503
+ added = [key for key in added if key not in fresh]
504
+ # 🔴 **两边都标上**:基线那一版的名字与现在这一版的名字各标一次 ——
505
+ # 前端只有"现在这一份",它按名字查标记,漏掉新名字那一份就整行不亮。
506
+ for old_key, new_key in pairs:
507
+ changed.extend([old_key, new_key])
434
508
  if added or removed or changed:
435
- out[name] = {'added': sorted(added), 'modified': sorted(changed), 'deleted': sorted(removed)}
509
+ out[name] = {
510
+ 'added': sorted(set(added)),
511
+ 'modified': sorted(set(changed)),
512
+ 'deleted': sorted(set(removed)),
513
+ }
436
514
  return out
437
515
 
438
516
 
517
+ def _renames(
518
+ removed: list[tuple[str, Any]],
519
+ added: list[tuple[str, Any]],
520
+ ) -> list[tuple[str, str]]:
521
+ """把"删掉的"与"新加的"里**形状一模一样**的配成"改名",回 `[(旧名, 新名)]`。
522
+
523
+ 🔴 这是"库里那条记录还在不在"的落地判据:形状(类型 / 主键 / 可空 / 唯一)没变
524
+ ⇒ 那就是**同一列换了个名字** ⇒ 算修改;形状对不上 ⇒ 真的是删一列 + 加一列。
525
+ 🔴 逐个 "吃" 掉(不是一整个签名一把抓):**同一张表里两根形状一样的列**(很少见但不是没有)
526
+ 才不会把同一个新列配给两个旧列。
527
+ ⚠️ 顺序取各自在契约里的出现顺序(契约是按库里的列序排的)—— 只影响"谁配谁",
528
+ 而配错的后果很轻(两个都标成"修改"而不是一红一绿)。
529
+ """
530
+ used: set[int] = set()
531
+ pairs: list[tuple[str, str]] = []
532
+ for old_key, old_shape in removed:
533
+ hit = None
534
+ for index, (new_key, new_shape) in enumerate(added):
535
+ if index in used or new_shape != old_shape:
536
+ continue
537
+ if new_key == old_key:
538
+ continue # 同名的不算改名(那种在别处已经处理过了)
539
+ hit = index
540
+ break
541
+ if hit is None:
542
+ continue
543
+ used.add(hit)
544
+ pairs.append((old_key, added[hit][0]))
545
+ return pairs
546
+
547
+
439
548
  def _columns_of(table: dict[str, Any]) -> dict[str, dict[str, Any]]:
440
549
  """一张表的列(老契约用 `fields`、新契约用 `columns`,两个都认)。"""
441
550
  columns = table.get('columns') if isinstance(table.get('columns'), list) else table.get('fields')
@@ -472,6 +581,124 @@ def _compare_key(table: dict[str, Any]) -> Any:
472
581
  return (table.get('table') or table.get('name'), shape)
473
582
 
474
583
 
584
+ def _relation_diff(
585
+ baseline: list[dict[str, Any]] | None,
586
+ current_tables: list[dict[str, Any]],
587
+ ) -> dict[str, dict[str, list[str]]]:
588
+ """每一张表的**关联**里哪几条是新增 / 改过 / 删掉的(键是关联名:外键列名或反向关联名)。
589
+
590
+ 🔴 为什么要它:用户要的是"连线也随增删改变色" —— 而"这一条关联动过没有"这件事
591
+ 前端算不出来(它手里只有现在这一份,没有基线)。判据与逐列那一份同源,只放这一处。
592
+ 🔴 判的是**结构**(指向谁 / 种类 / 基数 / 可空 / 唯一),不是文案:
593
+ `brief` 改一句话不该让整张图满屏黄 —— 与 `_compare_key()` 的理由完全一样。
594
+ 🔴 没有基线(`None`)时**一条都不报**:否则一张干净的图会被判成"全是新增"。
595
+ """
596
+ if not baseline:
597
+ return {}
598
+ before = {str(table.get('table')): _relations_of(table) for table in baseline}
599
+ out: dict[str, dict[str, list[str]]] = {}
600
+ for table in current_tables:
601
+ name = str(table.get('table'))
602
+ old = before.get(name)
603
+ if old is None:
604
+ continue # 新增的表整块标色,不必逐条关联再标一遍
605
+ new = _relations_of(table)
606
+ added = [key for key in new if key not in old]
607
+ removed = [key for key in old if key not in new]
608
+ changed = [key for key, one in new.items() if key in old and _relation_key(old[key]) != _relation_key(one)]
609
+ # 关联改名 → 修改,而不是"删一条 + 加一条":见 `_relation_renames()`。
610
+ pairs = _relation_renames(old, new, removed, added)
611
+ if pairs:
612
+ gone = {old_key for old_key, _new_key in pairs}
613
+ fresh = {new_key for _old_key, new_key in pairs}
614
+ removed = [key for key in removed if key not in gone]
615
+ added = [key for key in added if key not in fresh]
616
+ for old_key, new_key in pairs:
617
+ changed.extend([old_key, new_key])
618
+ if added or removed or changed:
619
+ out[name] = {
620
+ 'added': sorted(set(added)),
621
+ 'modified': sorted(set(changed)),
622
+ 'deleted': sorted(set(removed)),
623
+ }
624
+ return out
625
+
626
+
627
+ def _relation_renames(
628
+ old: dict[str, dict[str, Any]],
629
+ new: dict[str, dict[str, Any]],
630
+ removed: list[str],
631
+ added: list[str],
632
+ ) -> list[tuple[str, str]]:
633
+ """关联改名 ⇒ 配成"修改",回 `[(旧关联名, 新关联名)]`。
634
+
635
+ ⚠️ 配对**只看列名是否一模一样**:列名一样 ⇒ 字段名改了而列没动 ⇒ 改名;
636
+ 列名对不上 ⇒ 那是**另一列**,一律按删 + 加算(用户明确要求)。
637
+ "形状一致"**不做兜底**:`customer_row_id` → `buyer_id` 这种形状也一样,
638
+ 但它是换了一列,不是改名(真踩过:兜底会把"删了"说成"改了")。
639
+ """
640
+ if not removed or not added:
641
+ return []
642
+ pairs: list[tuple[str, str]] = []
643
+ used: set[str] = set()
644
+ for old_key in removed:
645
+ want = (old.get(old_key) or {}).get('column')
646
+ if not want:
647
+ continue # 没有列(反向 / 多对多)⇒ 没有"记录还在不在"可判,按删 + 加
648
+ for new_key in added:
649
+ if new_key in used:
650
+ continue
651
+ got = (new.get(new_key) or {}).get('column')
652
+ if got and str(got) == str(want):
653
+ used.add(new_key)
654
+ pairs.append((old_key, new_key))
655
+ break
656
+ return pairs
657
+
658
+
659
+ def _relation_shape(relation: dict[str, Any]) -> Any:
660
+ """关联的**结构**(不含名字):指向谁 / 什么种类 / 基数 / 可空 / 唯一。
661
+
662
+ ⚠️ 与 `_relation_key()` 是两件事:那一份用来判"同一条关联动过没有"(含指向),
663
+ 这一份留给"光看结构够不够判同一件事"的场合 —— 目前**没有**用它来配改名:
664
+ 改名只认"列名一样"(见 `_relation_renames()`),结构一样但列换了的属于删 + 加。
665
+ """
666
+ return (
667
+ str(relation.get('targetTable') or relation.get('target') or ''),
668
+ str(relation.get('kind') or ''),
669
+ str(relation.get('cardinality') or ''),
670
+ relation.get('null'),
671
+ relation.get('unique'),
672
+ )
673
+
674
+
675
+ def _relations_of(table: dict[str, Any]) -> dict[str, dict[str, Any]]:
676
+ """一张表的关联(键 = 关联名:外键列名 / 反向关联名 / 多对多那一侧的名字)。"""
677
+ rows = table.get('relations')
678
+ out: dict[str, dict[str, Any]] = {}
679
+ for row in rows or []:
680
+ if not isinstance(row, dict):
681
+ continue
682
+ key = str(row.get('field') or row.get('targetTable') or '')
683
+ if key:
684
+ out[key] = row
685
+ return out
686
+
687
+
688
+ def _relation_key(relation: dict[str, Any]) -> Any:
689
+ """一条关联**用来比差异的那部分**:指向谁 / 什么种类 / 基数 / 可空 / 唯一。
690
+
691
+ ⚠️ 不含 `brief` / `column` 这类展示与实现细节(理由见 `_relation_diff()`)。
692
+ """
693
+ return (
694
+ str(relation.get('targetTable') or relation.get('target') or ''),
695
+ str(relation.get('kind') or ''),
696
+ str(relation.get('cardinality') or ''),
697
+ relation.get('null'),
698
+ relation.get('unique'),
699
+ )
700
+
701
+
475
702
  def _diff(baseline: list[dict[str, Any]] | None, current: list[dict[str, Any]]) -> dict[str, list[str]]:
476
703
  """逐表逐列比出加 / 改 / 删。
477
704
 
@@ -18,6 +18,8 @@
18
18
  --modified: #b45309;
19
19
  --deleted: #b91c1c;
20
20
  --accent: #2563eb;
21
+ /* 连线 / 箭头的**默认色**:黑。只有"相对基线动过"的关联才换成上面那三档状态色。 */
22
+ --ink: #1f2933;
21
23
  /* 三态的底色(🔴 整块 / 整行染色,而不是只描一圈边 —— 一屏几十列时边线看不出是哪一行变了) */
22
24
  --tint-added: #e8f6ec;
23
25
  --tint-added-strong: #d5efdd;
@@ -166,17 +168,26 @@ body {
166
168
  z-index: 2;
167
169
  }
168
170
 
171
+ /* 🔴 **默认是黑的**(用户明确要求):连线只有在"这条关联相对基线动过"时才上色 ——
172
+ * 没动过的线不该抢眼,颜色留给"变了的东西"(与卡片、字段行那三档同一套 token)。 */
169
173
  .edge {
170
174
  fill: none;
171
- stroke: var(--tone-green);
175
+ stroke: var(--ink);
172
176
  stroke-width: 1.6;
173
177
  stroke-linejoin: round;
174
178
  pointer-events: stroke;
175
179
  transition: stroke-width .12s;
176
180
  }
181
+
177
182
  .edge.violet { stroke: var(--tone-violet); }
178
183
  /* 反向(只读)那一侧画虚一点:库里没有这一列,不该看起来跟真外键一模一样 */
179
184
  .edge.is-readonly { stroke-dasharray: 5 4; }
185
+ /* 🔴 关联的增 / 改 / 删:颜色与卡片、字段行的三档完全一致(同一份 token)。
186
+ * ⚠️ 必须写在 `.edge.violet` / `.edge.is-readonly` **后面**:那几个是按"关联种类"上的色,
187
+ * 状态色优先级更高(一条新增的多对多线也该是绿的)。 */
188
+ .edge.is-added { stroke: var(--added); stroke-dasharray: none; }
189
+ .edge.is-modified { stroke: var(--modified); stroke-dasharray: none; }
190
+ .edge.is-deleted { stroke: var(--deleted); stroke-dasharray: 6 4; }
180
191
  /* 悬停卡片上那一行关联时,这条线加粗、并给它一圈白底(压在别的线上也看得清) */
181
192
  .edge.is-hot {
182
193
  stroke-width: 3;
@@ -190,7 +201,11 @@ body {
190
201
  .arrow { fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
191
202
  .arrow.green { stroke: var(--tone-green); }
192
203
  .arrow.violet { stroke: var(--tone-violet); }
193
- .arrow.muted { stroke: var(--muted); }
204
+ .arrow.muted { stroke: var(--ink); }
205
+ /* 关联的增 / 改 / 删:箭头跟着线一起换色(marker 各自一份,见 `edgeDefs()`)。 */
206
+ .arrow.added { stroke: var(--added); }
207
+ .arrow.modified { stroke: var(--modified); }
208
+ .arrow.deleted { stroke: var(--deleted); }
194
209
 
195
210
  /* 基数:**空心圆里一个 `1` / `*`**,落在方块边界附近(线的两端)。
196
211
  * 🔴 圆填白、描边走线的颜色:它压在白卡 / 绿卡 / 黄卡上都读得出来 ——
@@ -49,6 +49,14 @@ const MARKER_R = 12;
49
49
  * 圆骑着线并坐在边界上,三者就对上了。 */
50
50
  const MARKER_BACK = MARKER_R;
51
51
 
52
+ /** 同一张卡片同一条边上,相邻两个连线端口至少隔多远。
53
+ * 🔴 必须**大于基数圆的直径**(`MARKER_R` × 2 = 24):不然两个 `1`/`*` 还是会叠在一起 ——
54
+ * 而"每个 1 / * 都看得见"正是这个值存在的理由(45 张表的项目里一张表被七八条线指向)。 */
55
+ const PORT_GAP = 26;
56
+ /** 最后那道"两条线挨太近就推开"的阈值。🔴 **别调大**:端口已经锚在卡片边界上,
57
+ * 推得太狠会把端点从边界推走,于是又变成"线没贴紧边缘"(原来取 14 就踩过这个)。 */
58
+ const DE_OVERLAP_GAP = 8;
59
+
52
60
  /* ── 自关联那个圈(见 `selfLoops`) ──
53
61
  * 🔴 半径要**明显大于**基数圆圈的半径(`MARKER_R` = 12):圈上要放两枚基数记号,
54
62
  * 圈太小的话那两个圆会盖住圈本身、看着像两个点而不是"一条绕回来的关联"。
@@ -165,6 +173,48 @@ function stateOf(table) {
165
173
  return '';
166
174
  }
167
175
 
176
+ /**
177
+ * 这张表**要画的关联**:现在这一份逐条带上 `mark`(`added` / `modified` / 空),
178
+ * 后面再接上**基线里有、现在没了**的那几条(`mark = 'deleted'`),它们画成红线。
179
+ *
180
+ * 🔴 为什么连"删掉的关联"也要画:用户要的是"连线随增删改变色" —— 关联被删掉时,
181
+ * 那条线如果直接消失,人只会看到"少了一根线",看不出**是哪一对表之间断的**。
182
+ * 与 `deletedTables`(被删的表整块呈现)是同一个道理。
183
+ * 🔴 `relationDiff` 由后端算(前端没有基线,算不出"动过没有"):见 `data._relation_diff()`。
184
+ * **后端没给(老数据 / 同源没升级)时一律按"没动过"处理**,也就是默认黑色 —— 不猜。
185
+ * ⚠️ 自关联那几张大卡片上还列着关联行,所以这里**不能改 `item.table.relations` 本身**:
186
+ * 只给 `edges()` 一份"要画的",卡片拿到的还是原来那份完整的。
187
+ */
188
+ function relationsOf(table) {
189
+ // 🔴 页面上那份 `state.relationDiff` 是**后端算的**(前端没有基线)。
190
+ // `window.__LABULL_RELATION_DIFF__` 是给验收脚本用的**替身**(真项目里用不到):
191
+ // 有它就用它,没有就用后端那份 —— 后者才是正常路径。
192
+ const injected = typeof window !== 'undefined' ? window.__LABULL_RELATION_DIFF__ : null;
193
+ const perTable = injected || state.relationDiff || {};
194
+ const diff = perTable[table.table] || {};
195
+ const added = new Set(diff.added || []);
196
+ const modified = new Set(diff.modified || []);
197
+ const rows = [];
198
+ for (const link of table.relations || []) {
199
+ const key = link.field || link.targetTable || '';
200
+ let mark = '';
201
+ if (added.has(key)) mark = 'added';
202
+ else if (modified.has(key)) mark = 'modified';
203
+ rows.push({ ...link, mark });
204
+ }
205
+ const deleted = new Set(diff.deleted || []);
206
+ if (deleted.size) {
207
+ const injectedBase = typeof window !== 'undefined' ? window.__LABULL_DELETED_RELATIONS__ : null;
208
+ const baseline = ((injectedBase || state.deletedRelations || {})[table.table]) || [];
209
+ for (const old of baseline) {
210
+ const key = old.field || old.targetTable || '';
211
+ if (!deleted.has(key)) continue;
212
+ rows.push({ ...old, mark: 'deleted' });
213
+ }
214
+ }
215
+ return rows;
216
+ }
217
+
168
218
  /* ── 渲染 ──────────────────────────────────────────────────────────────── */
169
219
 
170
220
  function render() {
@@ -208,13 +258,13 @@ function render() {
208
258
  return;
209
259
  }
210
260
  place(cards, layer);
211
- edges(cards, layer, paths, labels);
261
+ edges(cards, paths, labels);
212
262
  // 🔴 把"重画一次"暴露给拖拽:**拖动期间每一帧都要重画**(见 `dragify`)。
213
263
  // 原来唯一的重画入口是这一层上的 `click`(下面那一行),而拖动是"按住 + 移动":
214
264
  // 按住的那几十帧里线一直停在旧坐标 —— 线是 `z-index:2` 盖在卡片上的,于是那条不动的线
215
265
  // 横穿被拖走的卡片、两端的 `1`/`*` 落在已经无关的卡片旁,看起来就是"关联乱掉了"(真踩过)。
216
266
  // 拖出卡片再松手更糟:那种松手浏览器**不合成 click**,线就永久停在旧位置。
217
- redrawEdges = () => edges(cards, layer, paths, labels);
267
+ redrawEdges = () => edges(cards, paths, labels);
218
268
  applyView();
219
269
  // 🔴 **第一次打开时自动适应一次**:按列自动排出来的图常常比视口宽(表一多、或者表很长),
220
270
  // 不缩一下的话最右边那几张表就在屏幕外,看着像"少画了"(真踩过)。
@@ -224,7 +274,7 @@ function render() {
224
274
  requestAnimationFrame(() => requestAnimationFrame(fitView));
225
275
  }
226
276
  // 展开 / 收起会改变卡片高度 ⇒ 线要重画(否则箭头会指到卡片外面去)。
227
- layer.addEventListener('click', () => requestAnimationFrame(() => edges(cards, layer, paths, labels)));
277
+ layer.addEventListener('click', () => requestAnimationFrame(() => edges(cards, paths, labels)));
228
278
  if (diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0 && state.baseline === 'none') {
229
279
  notify('没有可比基线:这里只画当前的数据模型。想看到"改了什么",导出模型形状并提交一次。');
230
280
  }
@@ -238,11 +288,13 @@ function svgEl(tag, attrs = {}) {
238
288
  return el;
239
289
  }
240
290
 
241
- /** 箭头那一个小三角(颜色三档,与线的颜色一一对应)。 */
291
+ /** 箭头那一个小三角(颜色按"线的状态"分档,与线本身一一对应)。 */
242
292
  function edgeDefs() {
243
293
  const defs = svgEl('defs');
244
- // ⚠️ 一个 marker 只能有一种颜色 ⇒ 三种颜色各做一个(用 `context-stroke` 的写法浏览器支持不一)。
245
- for (const tone of ['muted', 'green', 'violet']) {
294
+ // ⚠️ 一个 marker 只能有一种颜色 ⇒ 每种颜色各做一个(用 `context-stroke` 的写法浏览器支持不一)。
295
+ // 🔴 除了"外键 / 多对多"那两档,还要给**新增 / 修改 / 删除**各做一份:
296
+ // 线按 diff 上色时箭头的颜色得跟着变,否则会出现"绿线配绿箭、红线配绿箭"。
297
+ for (const tone of ['muted', 'green', 'violet', 'added', 'modified', 'deleted']) {
246
298
  const marker = svgEl('marker', {
247
299
  id: `arrow-${tone}`,
248
300
  viewBox: '0 0 10 10',
@@ -279,7 +331,7 @@ function edgeDefs() {
279
331
  * 没有正向(说明是纯反向关系,例如 M2M 的另一侧)才画反向。
280
332
  * 🔴 一对表之间真有多条时按垂直方向**扇形排开**:重叠的两条线看起来就是一条,等于丢了一半信息。
281
333
  */
282
- function edges(cards, layer, paths, labels) {
334
+ function edges(cards, paths, labels) {
283
335
  paths.innerHTML = '';
284
336
  labels.innerHTML = '';
285
337
  // ⚠️ 自关联(见 `selfLoops`)也要画,所以"只有一张卡片"时不能直接返回。
@@ -287,12 +339,34 @@ function edges(cards, layer, paths, labels) {
287
339
  const byTable = new Map();
288
340
  for (const item of cards) byTable.set(item.el.dataset.name, item.el);
289
341
 
290
- const layerBox = layer.getBoundingClientRect();
342
+ /* 🔴 量出来的卡片盒必须是**图层坐标**(= SVG 的用户坐标),**不能**把 `getBoundingClientRect()`
343
+ * 直接当它用:那个值已经**过了 `.surface` 的 `scale()`**,是视口坐标。100% 时两者恰好相等,
344
+ * 所以这个坑平时藏着 —— 缩到 40% 时量到的全是真值的 0.4 倍,而写进 `d` 的端点又要被 transform
345
+ * 缩一次 ⇒ 屏幕上只剩 0.16 倍:所有线朝图层原点塌陷、基数圆缩成小点、自关联的圈脱离卡片角。
346
+ * (用户报的就是这个:「页面缩放的时候拖动表,关联马上就坏掉」。)
347
+ * ⚠️ 为什么偏偏**拖动**才炸:缩放本身**不重画连线**(`applyView()` 只改 `transform`),首次渲染
348
+ * 又是在 scale = 1 下算的 ⇒ 缩放之后线看着还是贴着的;而拖动走 `scheduleRedraw()` 重画,
349
+ * 第一帧就把错位的坐标写死了。两个单变量验收(只缩放 / 只拖动)因此都漏掉了它 ——
350
+ * 交叉场景另有一条 `tools/zoom-drag-check-canvas.mjs` 盯着。
351
+ * 🎯 换算走 SVG 自己的 `getScreenCTM()` 求逆:它与"缩放是谁施加的、`transform-origin` 在哪"
352
+ * 都无关,将来画布换成旋转 / 加 `viewBox` 也仍然成立。两个验收脚本判"端点贴不贴卡片边界"
353
+ * 用的正是它的正向 —— 两边同一个变换,不会各算各的。
354
+ */
355
+ const ctm = paths.getScreenCTM();
356
+ const inv = ctm ? ctm.inverse() : null;
357
+ // 画布没渲染时 `getScreenCTM()` 会给 null(理论上到不了):原样放行,别把这一页炸掉。
358
+ const toLayer = (x, y) => (inv
359
+ ? { x: inv.a * x + inv.c * y + inv.e, y: inv.b * x + inv.d * y + inv.f }
360
+ : { x, y });
291
361
  const boxes = new Map();
292
362
  const box = (el) => {
293
363
  if (boxes.has(el)) return boxes.get(el);
294
364
  const rect = el.getBoundingClientRect();
295
- const value = { x: rect.left - layerBox.left, y: rect.top - layerBox.top, w: rect.width, h: rect.height };
365
+ // 🔴 两个角都换算一次再相减(别只换算左上角、宽高照抄 `rect.width`):后者假定变换是纯平移,
366
+ // 缩放一旦存在,宽高就还是视口值 —— 那正是这个 bug 的另一半。
367
+ const topLeft = toLayer(rect.left, rect.top);
368
+ const bottomRight = toLayer(rect.right, rect.bottom);
369
+ const value = { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
296
370
  boxes.set(el, value);
297
371
  return value;
298
372
  };
@@ -307,7 +381,7 @@ function edges(cards, layer, paths, labels) {
307
381
  // "卡片右上角画一个圈"的路(见 `selfLoop()`)。
308
382
  const groups = new Map();
309
383
  for (const item of cards) {
310
- for (const link of item.table.relations || []) {
384
+ for (const link of relationsOf(item.table)) {
311
385
  if (!byTable.has(link.targetTable) || link.targetTable === item.el.dataset.name) continue;
312
386
  const key = [item.el.dataset.name, link.targetTable].sort().join('|');
313
387
  if (!groups.has(key)) groups.set(key, { forward: [], reverse: [] });
@@ -315,85 +389,141 @@ function edges(cards, layer, paths, labels) {
315
389
  }
316
390
  }
317
391
 
318
- // ② 有正向用正向,没有才用反向。
392
+ // ② 有正向用正向,没有才用反向 —— **但状态标记要合并过来**。
393
+ // 🔴 同一对表的两侧是**同一条关联**(`order_item.order_row` 与 `orders.order_item_set`),
394
+ // 而"动过没有"可能只挂在**被丢掉的那一侧**上(反向关联被改 / 被删时就是如此)。
395
+ // 不合并的话:那条线照旧是黑的 —— 用户要的"关联变化要变色"就静默失效了(真踩过)。
396
+ // 合并口径:只要两侧里**任意一侧**有标记,这条线就带上它;两边都有时按 删 > 改 > 增 取一个
397
+ // (删掉最该显眼;同一对表两侧真的同时变动时,人先要知道"这条没了")。
398
+ const MARK_ORDER = { deleted: 3, modified: 2, added: 1 };
319
399
  const edgesToDraw = [];
320
- for (const group of groups.values()) edgesToDraw.push(...(group.forward.length ? group.forward : group.reverse));
321
-
322
- // ③ 逐对排开画线。同一个表对内的多条才需要扇形偏移。
323
- const pairs = new Map();
324
- for (const entry of edgesToDraw) {
325
- const key = [entry.item.el.dataset.name, entry.link.targetTable].sort().join('|');
326
- if (!pairs.has(key)) pairs.set(key, []);
327
- pairs.get(key).push(entry);
400
+ for (const group of groups.values()) {
401
+ const picked = group.forward.length ? group.forward : group.reverse;
402
+ const marks = new Set([...group.forward, ...group.reverse].map((entry) => entry.link.mark || ''));
403
+ marks.delete('');
404
+ const mark = marks.size
405
+ ? [...marks].sort((one, two) => (MARK_ORDER[two] || 0) - (MARK_ORDER[one] || 0))[0]
406
+ : '';
407
+ for (const entry of picked) {
408
+ edgesToDraw.push(mark ? { ...entry, link: { ...entry.link, mark } } : entry);
409
+ }
410
+ }
411
+
412
+ // ③ 先算出每条线的**原始端口**(`port()` 给的那两点,就在卡片边界上)。
413
+ const raw = [];
414
+ for (const { item, link } of edgesToDraw) {
415
+ const target = byTable.get(link.targetTable);
416
+ const a = port(box(item.el), box(target));
417
+ const b = port(box(target), box(item.el));
418
+ raw.push({ item, link, target, a, b });
419
+ }
420
+ // ④ 🔴 **把同一个"卡片 + 哪条边"上的端口沿那条边铺开**(这是"别全挤到一点"的关键)。
421
+ //
422
+ // 为什么不能只靠"垂直于连线"那种扇形偏移:那种偏移是**按表对**算的,而真正挤在一起的是
423
+ // **一张表被很多张表引用**的场合(45 张表的项目里 `fams_spec` 被七八张表指向)——
424
+ // 那些线来自不同表对、每对只有一条 ⇒ 偏移量全是 0,于是七八条线、七八个 `1`/`*`
425
+ // 全叠在卡片边上的同一个点(用户报的就是这个:圈叠成一团、边看着像"没接上")。
426
+ //
427
+ // 判据是"端口属于哪张卡片的哪条边":同一条边上的端口排序之后**等距铺开**,
428
+ // 间距取 `PORT_GAP`(比基数圆的直径大一点,圆才不会互相盖住)。
429
+ // 排序键取**另一端的坐标**:这样线是顺序扇开的,不会互相交叉成一团乱麻。
430
+ const slots = new Map();
431
+ for (const entry of raw) {
432
+ const push = (card, side, slot) => {
433
+ const key = `${card.dataset.name}|${side}`;
434
+ if (!slots.has(key)) slots.set(key, { card, side, items: [] });
435
+ slots.get(key).items.push(slot);
436
+ };
437
+ push(entry.item.el, entry.a.side, { port: entry.a, other: entry.b });
438
+ push(entry.target, entry.b.side, { port: entry.b, other: entry.a });
439
+ }
440
+
441
+ // 逐条边:排序 → 等距分配 → 得到每个端口的新坐标(用端口对象身份当键)。
442
+ const fanned = new Map();
443
+ for (const { card, side, items } of slots.values()) {
444
+ const vertical = side === 'left' || side === 'right'; // 竖边 ⇒ 沿 y 铺开
445
+ const cardBox = box(card);
446
+ const fixed = vertical ? items[0].port.x : items[0].port.y;
447
+ // 🔴 铺开的**中心取原来那个端口**(`port()` 算出来的、在卡片边中点附近),不是卡片边的起点。
448
+ // 取起点的话:端口会被排到"边的最上端往下数"的位置,最后几个落出卡片之外 ——
449
+ // 线的端点于是悬在卡片外面(真踩过:`hub` 明明在 y=382..480,端点却落在 y=258)。
450
+ const anchor = vertical ? items[0].port.y : items[0].port.x;
451
+ // 可用长度:那一条边去掉两端各留一个圆的位置(不让圆被边角切掉)。
452
+ const span = (vertical ? cardBox.h : cardBox.w) - MARKER_R * 2;
453
+ const ordered = [...items].sort((one, two) => (vertical ? one.other.y - two.other.y : one.other.x - two.other.x));
454
+ const gap = ordered.length > 1 ? Math.min(PORT_GAP, span / (ordered.length - 1)) : 0;
455
+ const first = anchor - (gap * (ordered.length - 1)) / 2;
456
+ ordered.forEach((slot, index) => {
457
+ const at = ordered.length > 1 ? first + gap * index : anchor;
458
+ fanned.set(slot.port, vertical ? { x: fixed, y: at } : { x: at, y: fixed });
459
+ });
328
460
  }
329
461
 
462
+ // ⑤ 逐条画线:两端用**铺开之后**的端口,线**正好落在卡片边界上**。
330
463
  const drawn = [];
331
- for (const group of pairs.values()) {
332
- const total = group.length;
333
- group.forEach(({ item, link }, index) => {
334
- const target = byTable.get(link.targetTable);
335
- const from = box(item.el);
336
- const to = box(target);
337
- const a = port(from, to);
338
- const b = port(to, from);
339
- // 垂直方向上的扇形偏移:只有一条时偏移 0(正中间),多条时对称铺开。
340
- const spread = total > 1 ? (index - (total - 1) / 2) * 26 : 0;
341
- const dx = b.x - a.x;
342
- const dy = b.y - a.y;
343
- const length = Math.hypot(dx, dy) || 1;
344
- const ux = dx / length;
345
- const uy = dy / length;
346
- const ox = (-dy / length) * spread;
347
- const oy = (dx / length) * spread;
348
- // 🔴 `portA` / `portB` 是**卡片边界上的那两点**(`port()` 给的),线最终就画到这两点 ——
349
- // `start` / `end` 是**扇形铺开 / 挤开**之后的端点,用来定箭头与基数圆的位置。
350
- // 两者要分开存:混成一个的话"线贴不贴边缘"就只能靠巧合。
351
- const portA = { x: a.x, y: a.y };
352
- const portB = { x: b.x, y: b.y };
353
- const start = { x: a.x + ox, y: a.y + oy };
354
- const end = { x: b.x + ox, y: b.y + oy };
355
- const tone = link.kind === 'manyToMany' ? 'violet' : 'green';
356
- // 箭头落在**哪一头**:从"1"出发、指向"多"。
357
- // 🔴 `oneAtTarget` 同时决定两件事,必须一起算:
358
- // ① 箭头方向("多"那一端出箭头);
359
- // ② 两枚基数怎么标 —— 见下面那两行的说明。
360
- const selfCard = link.cardinality || '1';
361
- const otherCard = reciprocalOf(target, link, item.el.dataset.name);
362
- const oneAtTarget = selfCard === '*' && otherCard !== '*'; // 对面那一头是"1"
363
- const path = svgEl('path', {
364
- d: `M ${start.x} ${start.y} L ${end.x} ${end.y}`,
365
- class: `edge ${tone}${link.readonly ? ' is-readonly' : ''}`,
366
- // 对面是 "1" ⇒ 箭头在**起点**("多"这一端)朝外;否则箭头在终点。
367
- ...(oneAtTarget
368
- ? { 'marker-start': `url(#arrow-${tone})` }
369
- : { 'marker-end': `url(#arrow-${tone})` }),
370
- 'data-from': item.el.dataset.name,
371
- 'data-to': link.targetTable,
372
- // 这几项挂到 DOM 上纯粹为了**排查**:出问题时探针能直接读出"画的时候算的是哪一端",
373
- // 不用再靠推理猜(这一处来回改错过三次,就是缺这个)。
374
- 'data-self-card': selfCard,
375
- 'data-other-card': otherCard,
376
- 'data-one-at-target': String(oneAtTarget),
377
- });
378
- path.append(titleEl(link.brief || `${item.table.table}.${link.field} → ${link.targetTable}`));
379
- paths.append(path);
380
- // `ux` / `uy` 与 `ox` / `oy` 都记下来:后面要沿线的方向挪标记、沿垂直方向错开名字。
381
- drawn.push({ item, link, target, start, end, portA, portB, dx, dy, length, ux, uy, ox, oy, path, selfCard, otherCard, oneAtTarget });
464
+ for (const entry of raw) {
465
+ const { item, link, target } = entry;
466
+ const portA = fanned.get(entry.a) || { x: entry.a.x, y: entry.a.y };
467
+ const portB = fanned.get(entry.b) || { x: entry.b.x, y: entry.b.y };
468
+ const start = { x: portA.x, y: portA.y };
469
+ const end = { x: portB.x, y: portB.y };
470
+ const dx = end.x - start.x;
471
+ const dy = end.y - start.y;
472
+ const length = Math.hypot(dx, dy) || 1;
473
+ const ux = dx / length;
474
+ const uy = dy / length;
475
+ const ox = 0;
476
+ const oy = 0;
477
+ const tone = link.kind === 'manyToMany' ? 'violet' : 'green';
478
+ // 🔴 **连线按"这条关联动过没有"上色**(用户明确要的):没动过是**黑的**(默认色调,
479
+ // `tone` 只在没挂 diff 标记时才用它区分"外键 / 多对多");
480
+ // 新增绿、改过黄、删掉红 —— 与卡片、字段行那三档配色**同一套 token**,一眼能对上。
481
+ // ⚠️ 箭头也要跟着换:marker 是 `<defs>` 里按颜色各做一份的(SVG 的 marker 不吃 currentColor)。
482
+ const mark = link.mark || '';
483
+ const edgeClass = ['edge', tone, mark ? `is-${mark}` : '', link.readonly ? 'is-readonly' : '']
484
+ .filter(Boolean).join(' ');
485
+ // 箭头落在**哪一头**:从"1"出发、指向"多"。
486
+ // 🔴 `oneAtTarget` 同时决定两件事,必须一起算:
487
+ // ① 箭头方向("多"那一端出箭头);
488
+ // ② 两枚基数怎么标 —— 见下面那两行的说明。
489
+ const selfCard = link.cardinality || '1';
490
+ const otherCard = reciprocalOf(target, link, item.el.dataset.name);
491
+ const oneAtTarget = selfCard === '*' && otherCard !== '*'; // 对面那一头是"1"
492
+ const path = svgEl('path', {
493
+ d: `M ${start.x} ${start.y} L ${end.x} ${end.y}`,
494
+ class: edgeClass,
495
+ // 对面是 "1" ⇒ 箭头在**起点**("多"这一端)朝外;否则箭头在终点。
496
+ // 🔴 箭头的颜色跟着线的状态走(mark 为空时仍按 kind 那份)。
497
+ ...(oneAtTarget
498
+ ? { 'marker-start': `url(#arrow-${mark || tone})` }
499
+ : { 'marker-end': `url(#arrow-${mark || tone})` }),
500
+ 'data-from': item.el.dataset.name,
501
+ 'data-to': link.targetTable,
502
+ // 这几项挂到 DOM 上纯粹为了**排查**:出问题时探针能直接读出"画的时候算的是哪一端",
503
+ // 不用再靠推理猜(这一处来回改错过三次,就是缺这个)。
504
+ 'data-self-card': selfCard,
505
+ 'data-other-card': otherCard,
506
+ 'data-one-at-target': String(oneAtTarget),
382
507
  });
508
+ path.append(titleEl(link.brief || `${item.table.table}.${link.field} → ${link.targetTable}`));
509
+ paths.append(path);
510
+ // `ux` / `uy` 与 `ox` / `oy` 都记下来:后面要沿线的方向挪标记、沿垂直方向错开名字。
511
+ drawn.push({ item, link, target, start, end, portA, portB, dx, dy, length, ux, uy, ox, oy, path, selfCard, otherCard, oneAtTarget });
383
512
  }
384
513
 
385
- // 🔴 重叠的那两条看起来就是一条线(等于丢了一半信息),所以最后再挤一遍:
386
- // 两条线的**垂直距离**不够大就各自往两边挪。为什么不只靠上面那个扇形偏移:
387
- // 偏移是沿"垂直于连线"的方向加的,而连线本身是斜的 —— 两条斜线换个基准点之后
388
- // 垂直间距会被压缩(实测:端点差 26px 的两条线,到另一头只差 13px)。
514
+ // 🔴 重叠的那两条看起来就是一条线(等于丢了一半信息),所以再挤一遍:
515
+ // 上面那一步已经按"卡片的那条边"把端口铺开了,这里只处理**剩下的**近重叠
516
+ // (两条线方向几乎一致、又恰好挨在一起)。阈值取 `DE_OVERLAP_GAP`:
517
+ // 🔴 **不能取太大**(原来是 14)——端口已经是锚在卡片边界上的,推得太狠会把端点从
518
+ // 边界上推走,于是又变成"线没贴紧边缘"(真踩过)。只够把两条线分开一点点就行。
389
519
  for (let i = 0; i < drawn.length; i += 1) {
390
520
  for (let j = i + 1; j < drawn.length; j += 1) {
391
521
  const one = drawn[i];
392
522
  const two = drawn[j];
393
523
  if (Math.abs(one.length - two.length) > 1) continue // 方向差太多:本来就不会重叠
394
524
  const gap = Math.abs((two.start.y - one.start.y) * (one.dx / one.length) - (two.start.x - one.start.x) * (one.dy / one.length));
395
- if (gap >= 14) continue;
396
- const push = (14 - gap) / 2 + 1;
525
+ if (gap >= DE_OVERLAP_GAP) continue;
526
+ const push = (DE_OVERLAP_GAP - gap) / 2 + 0.5;
397
527
  for (const [edge, sign] of [[one, 1], [two, -1]]) {
398
528
  const nx = (-edge.dy / edge.length) * push * sign;
399
529
  const ny = (edge.dx / edge.length) * push * sign;
@@ -373,11 +373,21 @@ def dev_guide() -> str:
373
373
  '\n'
374
374
  '```js\n'
375
375
  'const me = await magicBlock.getMe() // 当前用户(认人只认它)\n'
376
- "await magicBlock.navigateTab('/orders') // 跳一个页面(可跳哪些见 pages.lock.ts)\n"
376
+ "await magicBlock.navigateTab('/orders') // 跳标签(可跳哪些见 pages.lock.ts)\n"
377
+ 'const tab = await magicBlock.context() // 这条标签的链接参数:tab.path / tab.params / tab.query\n'
378
+ "await magicBlock.setTabTitle('订单 SO-42') // 给所属那条标签起名(只有块自己知道是哪一单)\n"
377
379
  'const rows = await magicBackendApi.Order.list({}) // 调这个块自己的后端\n'
378
380
  '```\n'
379
381
  '\n'
380
- '- 这三样是宿主**注入**的,运行期就有;别去 import 任何东西(块没有构建步骤,就是普通 HTML + JS)。\n'
382
+ '- 这几样是宿主**注入**的,运行期就有;别去 import 任何东西(块没有构建步骤,就是普通 HTML + JS)。\n'
383
+ '- **跳页 = 打开那个页面的标签**:已经开着就切过去,没开就新开一条(带参数就写全 `/orders/42?orderId=7`)。\n'
384
+ '- 块自己**读不到地址栏**(它跑在 srcdoc 沙箱 iframe 里,地址是 `about:srcdoc`):链接参数只能问宿主,\n'
385
+ ' 也就是上面那句 `magicBlock.context()`。\n'
386
+ '- 🔴 块**一律占满它所属的那一条标签页**:高度由宿主按"这一页还剩多少地方"给,滚的是标签页那一层。\n'
387
+ ' **块自己不许出滚动条** —— 别写 `html, body { height: 100% }` 或 `overflow: auto` 跟宿主打架\n'
388
+ ' (宿主已经注入了一条 `html, body { overflow: hidden }`,你写了也不生效)。\n'
389
+ '- 宿主那边怎么把它挂上标签:**一条路由 = 一个块**(`component: MagicBlockPage, props: { uuid }`)。\n'
390
+ ' **块外面一个字的页面代码都没有** —— 标题 / 按钮 / 参数展示都写在这个块的 `' + BLOCK_ENTRY + '` 里。\n'
381
391
  '- **样式别自己写**:用这个应用正在用的 labull 类名(`p-button` / `p-inputtext` / `p-tag` / `p-checkbox` /\n'
382
392
  f' `labull-panel` / `labull-subject-title` / `pi pi-*`,以及 `--sp-*` 那几个 token)。**什么都不用引** ——\n'
383
393
  ' 宿主会把主题注入每个块的 iframe(`MagicBlockHost` 的 `theme` prop,默认开),直接写类名就生效。\n'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"