labull-framework 0.1.1__tar.gz → 0.1.3__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 (48) hide show
  1. {labull_framework-0.1.1/labull_framework.egg-info → labull_framework-0.1.3}/PKG-INFO +1 -1
  2. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/admin.py +1 -1
  3. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/data.py +258 -3
  4. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/static/canvas.css +63 -16
  5. labull_framework-0.1.3/labull_framework/canvas/static/canvas.js +1902 -0
  6. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/static/page.html +53 -52
  7. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/devkit.py +12 -2
  8. {labull_framework-0.1.1 → labull_framework-0.1.3/labull_framework.egg-info}/PKG-INFO +1 -1
  9. {labull_framework-0.1.1 → labull_framework-0.1.3}/pyproject.toml +1 -1
  10. labull_framework-0.1.1/labull_framework/canvas/static/canvas.js +0 -1235
  11. {labull_framework-0.1.1 → labull_framework-0.1.3}/LICENSE +0 -0
  12. {labull_framework-0.1.1 → labull_framework-0.1.3}/MANIFEST.in +0 -0
  13. {labull_framework-0.1.1 → labull_framework-0.1.3}/README-pypi.md +0 -0
  14. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/__init__.py +0 -0
  15. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/apps.py +0 -0
  16. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/__init__.py +0 -0
  17. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/backend.py +0 -0
  18. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/bootstrap.py +0 -0
  19. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/sso.py +0 -0
  20. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/views.py +0 -0
  21. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/__init__.py +0 -0
  22. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/gitinfo.py +0 -0
  23. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/views.py +0 -0
  24. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/env.py +0 -0
  25. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/jsonio.py +0 -0
  26. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/__init__.py +0 -0
  27. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/deepcheck.py +0 -0
  28. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/dispatch.py +0 -0
  29. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/export.py +0 -0
  30. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/health.py +0 -0
  31. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/registry.py +0 -0
  32. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/sdk.py +0 -0
  33. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/service.py +0 -0
  34. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/validate.py +0 -0
  35. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/views.py +0 -0
  36. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/__init__.py +0 -0
  37. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/commands/__init__.py +0 -0
  38. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/commands/labull_contract.py +0 -0
  39. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/migrations/0001_initial.py +0 -0
  40. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/migrations/__init__.py +0 -0
  41. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/models.py +0 -0
  42. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/settings.py +0 -0
  43. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/urls.py +0 -0
  44. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/SOURCES.txt +0 -0
  45. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/dependency_links.txt +0 -0
  46. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/requires.txt +0 -0
  47. {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/top_level.txt +0 -0
  48. {labull_framework-0.1.1 → labull_framework-0.1.3}/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.3
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
@@ -253,6 +253,27 @@ def _verbose(model: Any) -> str:
253
253
  return str(getattr(model._meta, 'verbose_name', '') or '')
254
254
 
255
255
 
256
+ def m2m_through(field: Any) -> Any:
257
+ """多对多那条关系**中间表**是哪个模型。
258
+
259
+ 🔴 正反两侧拿中间表的方式**不一样**,只认一边就会炸(真踩过:`canvasdemo` 的演示表
260
+ `Course.students` 一加进来,`/canvas` 直接 500):
261
+ · **正向**(`ManyToManyField`):中间表挂在 `field.remote_field.through` 上;
262
+ · **反向**(`ManyToManyRel`,例如 `Student.courses`):`field.remote_field` 是**正向那个
263
+ field 本身**,而它在 Django 里**没有 `through` 属性**(不是 None,是 AttributeError),
264
+ 中间表挂在 `field.through` 上。
265
+ ⚠️ 老代码写的是 `getattr(field.remote_field, 'through', None)`:反向那一侧拿到 `None`,
266
+ 紧接着 `None._meta` 抛 `AttributeError` —— 而 `auth` 的那几张表恰好在 `SKIP_TABLES` 里、
267
+ 平时又没人写过普通多对多,所以这个坑一直没露出来。`getattr` 的默认值把 AttributeError
268
+ 一起吞了,连报错都指不到真正的原因。
269
+ """
270
+ for owner in (getattr(field, 'remote_field', None), field):
271
+ through = getattr(owner, 'through', None)
272
+ if through is not None:
273
+ return through
274
+ return None
275
+
276
+
256
277
  def shape() -> dict[str, list[dict[str, Any]]]:
257
278
  """当前的模型形状(进程内现算,不需要任何文件)。
258
279
 
@@ -270,14 +291,21 @@ def shape() -> dict[str, list[dict[str, Any]]]:
270
291
  for field in model._meta.get_fields():
271
292
  if getattr(field, 'many_to_many', False):
272
293
  target = field.related_model
273
- through = getattr(getattr(field, 'remote_field', None), 'through', None)
294
+ through = m2m_through(field)
295
+ # 🔴 "反向那一侧"必须标成 `readonly`:多对多的正反两条都走这个分支,
296
+ # 而前端是**按"两端表名排序"分组、每组只画正向那条**的。
297
+ # 老代码这里没给 `readonly` ⇒ 反向那条也进了 forward 组 ⇒ **同一个关系画了两条
298
+ # 方向相反的线、必然交叉**(用户报的「关联键故意交叉了」就是这个,
299
+ # 实测 `course -> student` 与 `student -> course` 各画了一条)。
300
+ # 判定:反向的多对多是一个 `ManyToManyRel`,`auto_created` 为真。
274
301
  relations.append(
275
302
  _relation_of(
276
303
  field,
277
304
  'manyToMany',
278
305
  '*',
279
306
  target,
280
- via=str(getattr(through._meta, 'db_table', '') or '') or None,
307
+ via=str(getattr(getattr(through, '_meta', None), 'db_table', '') or '') or None,
308
+ readonly=bool(getattr(field, 'auto_created', False)),
281
309
  )
282
310
  )
283
311
  continue
@@ -366,18 +394,74 @@ def payload() -> dict[str, Any]:
366
394
  current_tables = current['tables']
367
395
  baseline = gitinfo.baseline_tables()
368
396
  diff = _diff(baseline, current_tables)
397
+ relation_diff = _relation_diff(baseline, current_tables)
369
398
  return {
370
399
  'tables': current_tables,
371
400
  'deletedTables': _deleted_tables(baseline, diff['deleted']),
372
401
  'diff': diff,
373
402
  # 逐列的差异(`表名 → {added, modified, deleted}`):前端拿它给**整行**上色。
374
403
  'fieldDiff': _field_diff(baseline, current_tables),
404
+ # 逐条**关联**的差异(`表名 → {added, modified, deleted}`,键是关联名):前端拿它给**连线上色**。
405
+ 'relationDiff': relation_diff,
406
+ # 🔴 **被删掉的关联**那几条的原文(`表名 → [关联]`):现在这一份里已经没有了,
407
+ # 前端要照基线的这一份把红线补画出来 —— 否则关联一删就"少了一根线",
408
+ # 看不出是哪两张表之间断的(与 `deletedTables` 同一个道理)。
409
+ 'deletedRelations': _deleted_relations(baseline, relation_diff),
375
410
  'git': gitinfo.head(),
376
411
  'baseline': 'none' if baseline is None else 'present',
377
412
  'layout': read_layout(),
378
413
  }
379
414
 
380
415
 
416
+ def _deleted_relations(
417
+ baseline: list[dict[str, Any]] | None,
418
+ relation_diff: dict[str, dict[str, list[str]]],
419
+ ) -> dict[str, list[dict[str, Any]]]:
420
+ """基线里有、现在没有的那些关联(只取真的被删掉的那几个键)。
421
+
422
+ 🔴 **要把 `targetTable` 补齐**:老契约里的关联只写了 `target`(模型名),没有 `targetTable`
423
+ (表名)—— 而前端是**按表名**找卡片的。不补的话那条红线找不到对端,会被直接跳过,
424
+ 症状就是"关联删了但图上什么都没少"(与这一条功能想解决的问题正好相反)。
425
+ """
426
+ if not baseline or not relation_diff:
427
+ return {}
428
+ out: dict[str, list[dict[str, Any]]] = {}
429
+ for table in baseline:
430
+ name = str(table.get('table'))
431
+ removed = (relation_diff.get(name) or {}).get('deleted') or []
432
+ if not removed:
433
+ continue
434
+ old = _relations_of(table)
435
+ rows = []
436
+ for key in removed:
437
+ row = old.get(key)
438
+ if row is None:
439
+ continue
440
+ fixed = dict(row)
441
+ if not fixed.get('targetTable'):
442
+ fixed['targetTable'] = _table_name_of(fixed.get('target'))
443
+ rows.append(fixed)
444
+ if rows:
445
+ out[name] = rows
446
+ return out
447
+
448
+
449
+ def _table_name_of(model_name: Any) -> str:
450
+ """模型名 → **数据库表名**(补老契约缺的那一项)。查不到就回空串,由前端跳过这一条。"""
451
+ wanted = str(model_name or '')
452
+ if not wanted:
453
+ return ''
454
+ try:
455
+ from django.apps import apps
456
+
457
+ for model in apps.get_models():
458
+ if model.__name__ == wanted:
459
+ return str(model._meta.db_table)
460
+ except Exception: # noqa: BLE001 —— 补不上就空串,不影响别的
461
+ return ''
462
+ return ''
463
+
464
+
381
465
  def _deleted_tables(baseline: list[dict[str, Any]] | None, deleted: list[str]) -> list[dict[str, Any]]:
382
466
  """基线里有、现在没有的那些表(补上新契约才有的键,免得前端到处判空)。"""
383
467
  if not baseline or not deleted:
@@ -413,6 +497,10 @@ def _field_diff(baseline: list[dict[str, Any]] | None, current_tables: list[dict
413
497
  不需要再逐列标一遍。
414
498
  🔴 判据与 `_compare_key()` 同源:**列的类型 / 主键 / 可空 / 唯一变了才算改**,
415
499
  备注文案(`brief`)变了不算 —— 否则给某个字段补一句说明,整张表就满屏黄。
500
+ 🔴 **改名按"库里那条记录还在不在"判**(用户明确要求):
501
+ · 只改了名字、列还是原来那一列(类型 / 主键 / 可空 / 唯一 都没变)⇒ 算**修改**;
502
+ · 列被删掉、另外起了一列(形状不一样)⇒ 算**删一列 + 加一列**。
503
+ 配对只按**结构**配(见 `_renames()`):名字里带 `_id` 那种后缀差异不会影响判断。
416
504
  """
417
505
  if not baseline:
418
506
  return {}
@@ -431,11 +519,60 @@ def _field_diff(baseline: list[dict[str, Any]] | None, current_tables: list[dict
431
519
  for key, column in new.items()
432
520
  if key in old and _column_key(old[key]) != _column_key(column)
433
521
  ]
522
+ # 改名:把"删掉的"与"新加的"里形状一致的那些配成"修改"。
523
+ pairs = _renames(
524
+ [(key, _column_key(old[key])) for key in removed],
525
+ [(key, _column_key(new[key])) for key in added],
526
+ )
527
+ if pairs:
528
+ gone = {old_key for old_key, _new_key in pairs}
529
+ fresh = {new_key for _old_key, new_key in pairs}
530
+ removed = [key for key in removed if key not in gone]
531
+ added = [key for key in added if key not in fresh]
532
+ # 🔴 **两边都标上**:基线那一版的名字与现在这一版的名字各标一次 ——
533
+ # 前端只有"现在这一份",它按名字查标记,漏掉新名字那一份就整行不亮。
534
+ for old_key, new_key in pairs:
535
+ changed.extend([old_key, new_key])
434
536
  if added or removed or changed:
435
- out[name] = {'added': sorted(added), 'modified': sorted(changed), 'deleted': sorted(removed)}
537
+ out[name] = {
538
+ 'added': sorted(set(added)),
539
+ 'modified': sorted(set(changed)),
540
+ 'deleted': sorted(set(removed)),
541
+ }
436
542
  return out
437
543
 
438
544
 
545
+ def _renames(
546
+ removed: list[tuple[str, Any]],
547
+ added: list[tuple[str, Any]],
548
+ ) -> list[tuple[str, str]]:
549
+ """把"删掉的"与"新加的"里**形状一模一样**的配成"改名",回 `[(旧名, 新名)]`。
550
+
551
+ 🔴 这是"库里那条记录还在不在"的落地判据:形状(类型 / 主键 / 可空 / 唯一)没变
552
+ ⇒ 那就是**同一列换了个名字** ⇒ 算修改;形状对不上 ⇒ 真的是删一列 + 加一列。
553
+ 🔴 逐个 "吃" 掉(不是一整个签名一把抓):**同一张表里两根形状一样的列**(很少见但不是没有)
554
+ 才不会把同一个新列配给两个旧列。
555
+ ⚠️ 顺序取各自在契约里的出现顺序(契约是按库里的列序排的)—— 只影响"谁配谁",
556
+ 而配错的后果很轻(两个都标成"修改"而不是一红一绿)。
557
+ """
558
+ used: set[int] = set()
559
+ pairs: list[tuple[str, str]] = []
560
+ for old_key, old_shape in removed:
561
+ hit = None
562
+ for index, (new_key, new_shape) in enumerate(added):
563
+ if index in used or new_shape != old_shape:
564
+ continue
565
+ if new_key == old_key:
566
+ continue # 同名的不算改名(那种在别处已经处理过了)
567
+ hit = index
568
+ break
569
+ if hit is None:
570
+ continue
571
+ used.add(hit)
572
+ pairs.append((old_key, added[hit][0]))
573
+ return pairs
574
+
575
+
439
576
  def _columns_of(table: dict[str, Any]) -> dict[str, dict[str, Any]]:
440
577
  """一张表的列(老契约用 `fields`、新契约用 `columns`,两个都认)。"""
441
578
  columns = table.get('columns') if isinstance(table.get('columns'), list) else table.get('fields')
@@ -472,6 +609,124 @@ def _compare_key(table: dict[str, Any]) -> Any:
472
609
  return (table.get('table') or table.get('name'), shape)
473
610
 
474
611
 
612
+ def _relation_diff(
613
+ baseline: list[dict[str, Any]] | None,
614
+ current_tables: list[dict[str, Any]],
615
+ ) -> dict[str, dict[str, list[str]]]:
616
+ """每一张表的**关联**里哪几条是新增 / 改过 / 删掉的(键是关联名:外键列名或反向关联名)。
617
+
618
+ 🔴 为什么要它:用户要的是"连线也随增删改变色" —— 而"这一条关联动过没有"这件事
619
+ 前端算不出来(它手里只有现在这一份,没有基线)。判据与逐列那一份同源,只放这一处。
620
+ 🔴 判的是**结构**(指向谁 / 种类 / 基数 / 可空 / 唯一),不是文案:
621
+ `brief` 改一句话不该让整张图满屏黄 —— 与 `_compare_key()` 的理由完全一样。
622
+ 🔴 没有基线(`None`)时**一条都不报**:否则一张干净的图会被判成"全是新增"。
623
+ """
624
+ if not baseline:
625
+ return {}
626
+ before = {str(table.get('table')): _relations_of(table) for table in baseline}
627
+ out: dict[str, dict[str, list[str]]] = {}
628
+ for table in current_tables:
629
+ name = str(table.get('table'))
630
+ old = before.get(name)
631
+ if old is None:
632
+ continue # 新增的表整块标色,不必逐条关联再标一遍
633
+ new = _relations_of(table)
634
+ added = [key for key in new if key not in old]
635
+ removed = [key for key in old if key not in new]
636
+ changed = [key for key, one in new.items() if key in old and _relation_key(old[key]) != _relation_key(one)]
637
+ # 关联改名 → 修改,而不是"删一条 + 加一条":见 `_relation_renames()`。
638
+ pairs = _relation_renames(old, new, removed, added)
639
+ if pairs:
640
+ gone = {old_key for old_key, _new_key in pairs}
641
+ fresh = {new_key for _old_key, new_key in pairs}
642
+ removed = [key for key in removed if key not in gone]
643
+ added = [key for key in added if key not in fresh]
644
+ for old_key, new_key in pairs:
645
+ changed.extend([old_key, new_key])
646
+ if added or removed or changed:
647
+ out[name] = {
648
+ 'added': sorted(set(added)),
649
+ 'modified': sorted(set(changed)),
650
+ 'deleted': sorted(set(removed)),
651
+ }
652
+ return out
653
+
654
+
655
+ def _relation_renames(
656
+ old: dict[str, dict[str, Any]],
657
+ new: dict[str, dict[str, Any]],
658
+ removed: list[str],
659
+ added: list[str],
660
+ ) -> list[tuple[str, str]]:
661
+ """关联改名 ⇒ 配成"修改",回 `[(旧关联名, 新关联名)]`。
662
+
663
+ ⚠️ 配对**只看列名是否一模一样**:列名一样 ⇒ 字段名改了而列没动 ⇒ 改名;
664
+ 列名对不上 ⇒ 那是**另一列**,一律按删 + 加算(用户明确要求)。
665
+ "形状一致"**不做兜底**:`customer_row_id` → `buyer_id` 这种形状也一样,
666
+ 但它是换了一列,不是改名(真踩过:兜底会把"删了"说成"改了")。
667
+ """
668
+ if not removed or not added:
669
+ return []
670
+ pairs: list[tuple[str, str]] = []
671
+ used: set[str] = set()
672
+ for old_key in removed:
673
+ want = (old.get(old_key) or {}).get('column')
674
+ if not want:
675
+ continue # 没有列(反向 / 多对多)⇒ 没有"记录还在不在"可判,按删 + 加
676
+ for new_key in added:
677
+ if new_key in used:
678
+ continue
679
+ got = (new.get(new_key) or {}).get('column')
680
+ if got and str(got) == str(want):
681
+ used.add(new_key)
682
+ pairs.append((old_key, new_key))
683
+ break
684
+ return pairs
685
+
686
+
687
+ def _relation_shape(relation: dict[str, Any]) -> Any:
688
+ """关联的**结构**(不含名字):指向谁 / 什么种类 / 基数 / 可空 / 唯一。
689
+
690
+ ⚠️ 与 `_relation_key()` 是两件事:那一份用来判"同一条关联动过没有"(含指向),
691
+ 这一份留给"光看结构够不够判同一件事"的场合 —— 目前**没有**用它来配改名:
692
+ 改名只认"列名一样"(见 `_relation_renames()`),结构一样但列换了的属于删 + 加。
693
+ """
694
+ return (
695
+ str(relation.get('targetTable') or relation.get('target') or ''),
696
+ str(relation.get('kind') or ''),
697
+ str(relation.get('cardinality') or ''),
698
+ relation.get('null'),
699
+ relation.get('unique'),
700
+ )
701
+
702
+
703
+ def _relations_of(table: dict[str, Any]) -> dict[str, dict[str, Any]]:
704
+ """一张表的关联(键 = 关联名:外键列名 / 反向关联名 / 多对多那一侧的名字)。"""
705
+ rows = table.get('relations')
706
+ out: dict[str, dict[str, Any]] = {}
707
+ for row in rows or []:
708
+ if not isinstance(row, dict):
709
+ continue
710
+ key = str(row.get('field') or row.get('targetTable') or '')
711
+ if key:
712
+ out[key] = row
713
+ return out
714
+
715
+
716
+ def _relation_key(relation: dict[str, Any]) -> Any:
717
+ """一条关联**用来比差异的那部分**:指向谁 / 什么种类 / 基数 / 可空 / 唯一。
718
+
719
+ ⚠️ 不含 `brief` / `column` 这类展示与实现细节(理由见 `_relation_diff()`)。
720
+ """
721
+ return (
722
+ str(relation.get('targetTable') or relation.get('target') or ''),
723
+ str(relation.get('kind') or ''),
724
+ str(relation.get('cardinality') or ''),
725
+ relation.get('null'),
726
+ relation.get('unique'),
727
+ )
728
+
729
+
475
730
  def _diff(baseline: list[dict[str, Any]] | None, current: list[dict[str, Any]]) -> dict[str, list[str]]:
476
731
  """逐表逐列比出加 / 改 / 删。
477
732
 
@@ -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,30 @@ 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
  }
177
- .edge.violet { stroke: var(--tone-violet); }
181
+
182
+ /* 🔴 **按"关联种类"上的色已经全部去掉**(这里原来有一条 `.edge.violet { stroke: var(--tone-violet) }`)。
183
+ * 理由就是用户定的那条规矩:**整页默认全黑,只有"相对基线动过"才上色**。
184
+ * 多对多那条线原来默认是紫的(它排在 `.edge` 的黑色**后面**,所以盖掉了默认色)——
185
+ * 用户一眼就看出来了(「为什么是紫色?」)。
186
+ * ⚠️ 老判据只验了普通外键是黑的、没验多对多,所以这条一直漏着;现在 CSS 里不再有种类色。 */
178
187
  /* 反向(只读)那一侧画虚一点:库里没有这一列,不该看起来跟真外键一模一样 */
179
188
  .edge.is-readonly { stroke-dasharray: 5 4; }
189
+ /* 🔴 关联的增 / 改 / 删:颜色与卡片、字段行的三档完全一致(同一份 token)。
190
+ * ⚠️ 必须写在 `.edge.violet` / `.edge.is-readonly` **后面**:那几个是按"关联种类"上的色,
191
+ * 状态色优先级更高(一条新增的多对多线也该是绿的)。 */
192
+ .edge.is-added { stroke: var(--added); stroke-dasharray: none; }
193
+ .edge.is-modified { stroke: var(--modified); stroke-dasharray: none; }
194
+ .edge.is-deleted { stroke: var(--deleted); stroke-dasharray: 6 4; }
180
195
  /* 悬停卡片上那一行关联时,这条线加粗、并给它一圈白底(压在别的线上也看得清) */
181
196
  .edge.is-hot {
182
197
  stroke-width: 3;
@@ -190,44 +205,71 @@ body {
190
205
  .arrow { fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
191
206
  .arrow.green { stroke: var(--tone-green); }
192
207
  .arrow.violet { stroke: var(--tone-violet); }
193
- .arrow.muted { stroke: var(--muted); }
194
-
195
- /* 基数:**空心圆里一个 `1` / `*`**,落在方块边界附近(线的两端)。
196
- * 🔴 圆填白、描边走线的颜色:它压在白卡 / 绿卡 / 黄卡上都读得出来 ——
197
- * 卡片底色恰恰是"变了没有"的判据,标记不能靠底色来分辨。 */
208
+ .arrow.muted { stroke: var(--ink); }
209
+ /* 关联的增 / 改 / 删:箭头跟着线一起换色(marker 各自一份,见 `edgeDefs()`)。 */
210
+ .arrow.added { stroke: var(--added); }
211
+ .arrow.modified { stroke: var(--modified); }
212
+ .arrow.deleted { stroke: var(--deleted); }
213
+
214
+ /* 基数:**空心圆里一个 `1` / `*`**,落在方块边界内侧(线的两端)。
215
+ * 🔴 **默认是黑的**(用户明确要的:整页默认全黑,只有"相对基线动过"的才上色)——
216
+ * 和线一个口径:没动过的关联,线黑、两端的圈也黑;动了才换色(下面那三条)。
217
+ * ⚠️ 圆填卡片色(白底把线遮住),所以字上再加一圈卡片色描边才读得清。 */
198
218
  .cardinality { pointer-events: none; }
199
- .cardinality circle { fill: var(--card); stroke: var(--tone-green); stroke-width: 1.8; }
219
+ .cardinality circle { fill: var(--card); stroke: var(--ink); stroke-width: 1.8; }
200
220
  .cardinality text {
201
- fill: var(--tone-green);
221
+ fill: var(--ink);
202
222
  font-size: 10px;
203
223
  font-weight: 700;
204
224
  text-anchor: middle;
205
- dominant-baseline: middle;
225
+ /* 🔴 `central` 而不是 `middle`:`middle` 对齐的是**x-height 的中线**,数字会略微偏下;
226
+ * `central` 对齐的是**整个 em 框的中线**,`1` 与 `*` 都正好落在圆心上(用户要的"正中间")。
227
+ * ⚠️ 别再往 `y` 上加偏移补偿 —— 那样和这里叠起来就偏了(真踩过)。 */
228
+ dominant-baseline: central;
206
229
  font-family: ui-monospace, Consolas, monospace;
207
230
  paint-order: stroke;
208
231
  stroke: var(--card);
209
232
  stroke-width: 2.5px;
210
233
  stroke-linejoin: round;
211
234
  }
235
+ /* 只有"这条关联相对基线动过"时才上色 —— 与 `.edge.is-*` / 卡片 / 字段行同一套 token。 */
236
+ .cardinality.is-added circle { stroke: var(--added); }
237
+ .cardinality.is-added text { fill: var(--added); }
238
+ .cardinality.is-modified circle { stroke: var(--modified); }
239
+ .cardinality.is-modified text { fill: var(--modified); }
240
+ .cardinality.is-deleted circle { stroke: var(--deleted); }
241
+ .cardinality.is-deleted text { fill: var(--deleted); }
212
242
  /* `*` 那个字比 `1` 宽,给大一号才在同一个圆里好看。
213
243
  * ⚠️ 两个圆**都是实线**:一开始给 `*` 画了虚线想区分"多",用户明确要求实心圆,就去掉了
214
244
  * (`1` 与 `*` 的字形本身已经够区分;实线圆看起来也更干净)。 */
215
245
  .cardinality.is-many text { font-size: 16px; }
216
246
 
217
- /* 关联键的名字:标在**线的正中间**。
218
- * 🔴 `dominant-baseline: middle` 是必须的:`y` 默认是**基线**,配合上面那句旋转,
219
- * 不写它文字就会整体偏到线的一侧。`paint-order: stroke` 再描一圈卡片色,
220
- * 线从字底下穿过时也读得清。 */
247
+ /* 🔴 关联名:**一律水平、框高统一**(参考 Mendix 的关联标签)。
248
+ * · 字**不按线的角度转**了:斜框有两个毛病 —— 框高会随角度变(AABB 撑大),
249
+ * 而且斜着不像"钉在线上";
250
+ * · `dominant-baseline: middle` 仍然必须有:`y` 默认是**基线**,不写它字就偏到线的一侧;
251
+ * · 那圈 `paint-order: stroke` 的描边**去掉了** —— 现在有框的底色遮线,描边只会在框里多一圈边。
252
+ * 自关联那个圈上的名字没有框,描边只留给了它(见下面 `.edge-name--self`)。 */
221
253
  .edge-name {
222
254
  fill: var(--muted);
223
255
  font-size: 10.5px;
224
256
  font-family: ui-monospace, Consolas, monospace;
225
257
  text-anchor: middle;
226
258
  dominant-baseline: middle;
259
+ pointer-events: none;
260
+ }
261
+ /* 自关联那个圈上的名字**没有框**(它挂在圈顶,不在线上),所以这唯一一处保留描边好读。 */
262
+ .edge-name--self {
227
263
  paint-order: stroke;
228
264
  stroke: var(--card);
229
265
  stroke-width: 3.5px;
230
266
  stroke-linejoin: round;
267
+ }
268
+ /* 关联标签的黑框(参考 Mendix):细黑边 + 卡片色底 —— 底把线遮住,字就干净了。 */
269
+ .edge-name-box {
270
+ fill: var(--card);
271
+ stroke: var(--ink);
272
+ stroke-width: 1;
231
273
  pointer-events: none;
232
274
  }
233
275
  /* 有关联备注时才可点(点开看备注)—— 鼠标形状是唯一的提示,所以只给能点的那些加。 */
@@ -280,9 +322,14 @@ body {
280
322
  .head:hover { background: #e9eff6; }
281
323
 
282
324
  .title { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
283
- .title b { font-size: 14px; font-family: ui-monospace, Consolas, monospace; }
284
- .title .cn { color: var(--text); font-size: 12px; }
325
+ /* 🔴 主名字要**大**(用户明确要的):卡片上第一眼要能认出这是哪张表,14px 在缩到 40% 之后太小。 */
326
+ .title b { font-size: 16px; font-family: ui-monospace, Consolas, monospace; }
327
+ .title .cn { color: var(--text); font-size: 13px; }
285
328
  .title .mod { color: var(--muted); font-size: 11px; }
329
+ /* 🔴 备注模式下两个名字**对调**(见 canvas.js 里算 `primary` / `secondary` 那两行):
330
+ * 主位那时是**中文备注名** —— 中文不该走等宽;技术表名退到次位,它才是该等宽的那个。 */
331
+ #canvas.is-note-first .title b { font-family: inherit; }
332
+ #canvas.is-note-first .title .cn { font-family: ui-monospace, Consolas, monospace; }
286
333
 
287
334
  .caret { color: var(--muted); font-size: 11px; transition: transform .15s; }
288
335
  .table.is-open .caret { transform: rotate(180deg); }