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.
- {labull_framework-0.1.1/labull_framework.egg-info → labull_framework-0.1.3}/PKG-INFO +1 -1
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/admin.py +1 -1
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/data.py +258 -3
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/static/canvas.css +63 -16
- labull_framework-0.1.3/labull_framework/canvas/static/canvas.js +1902 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/static/page.html +53 -52
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/devkit.py +12 -2
- {labull_framework-0.1.1 → labull_framework-0.1.3/labull_framework.egg-info}/PKG-INFO +1 -1
- {labull_framework-0.1.1 → labull_framework-0.1.3}/pyproject.toml +1 -1
- labull_framework-0.1.1/labull_framework/canvas/static/canvas.js +0 -1235
- {labull_framework-0.1.1 → labull_framework-0.1.3}/LICENSE +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/MANIFEST.in +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/README-pypi.md +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/apps.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/backend.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/bootstrap.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/sso.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/auth/views.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/gitinfo.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/canvas/views.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/env.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/jsonio.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/deepcheck.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/dispatch.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/export.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/health.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/registry.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/sdk.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/service.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/validate.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/magic_block/views.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/commands/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/management/commands/labull_contract.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/migrations/0001_initial.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/migrations/__init__.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/models.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/settings.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework/urls.py +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/SOURCES.txt +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/dependency_links.txt +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/requires.txt +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/labull_framework.egg-info/top_level.txt +0 -0
- {labull_framework-0.1.1 → labull_framework-0.1.3}/setup.cfg +0 -0
|
@@ -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
|
|
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 =
|
|
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
|
|
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] = {
|
|
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(--
|
|
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
|
-
|
|
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(--
|
|
194
|
-
|
|
195
|
-
|
|
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(--
|
|
219
|
+
.cardinality circle { fill: var(--card); stroke: var(--ink); stroke-width: 1.8; }
|
|
200
220
|
.cardinality text {
|
|
201
|
-
fill: var(--
|
|
221
|
+
fill: var(--ink);
|
|
202
222
|
font-size: 10px;
|
|
203
223
|
font-weight: 700;
|
|
204
224
|
text-anchor: middle;
|
|
205
|
-
|
|
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
|
-
*
|
|
219
|
-
*
|
|
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
|
-
|
|
284
|
-
.title
|
|
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); }
|