labull-framework 0.1.0__py3-none-any.whl

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 (40) hide show
  1. labull_framework/__init__.py +8 -0
  2. labull_framework/admin.py +63 -0
  3. labull_framework/apps.py +33 -0
  4. labull_framework/auth/__init__.py +4 -0
  5. labull_framework/auth/backend.py +144 -0
  6. labull_framework/auth/bootstrap.py +47 -0
  7. labull_framework/auth/sso.py +259 -0
  8. labull_framework/auth/views.py +184 -0
  9. labull_framework/canvas/__init__.py +19 -0
  10. labull_framework/canvas/data.py +523 -0
  11. labull_framework/canvas/gitinfo.py +155 -0
  12. labull_framework/canvas/static/canvas.css +487 -0
  13. labull_framework/canvas/static/canvas.js +1036 -0
  14. labull_framework/canvas/static/page.html +52 -0
  15. labull_framework/canvas/views.py +121 -0
  16. labull_framework/env.py +187 -0
  17. labull_framework/jsonio.py +57 -0
  18. labull_framework/magic_block/__init__.py +2 -0
  19. labull_framework/magic_block/devkit.py +428 -0
  20. labull_framework/magic_block/dispatch.py +314 -0
  21. labull_framework/magic_block/export.py +773 -0
  22. labull_framework/magic_block/health.py +220 -0
  23. labull_framework/magic_block/registry.py +157 -0
  24. labull_framework/magic_block/sdk.py +245 -0
  25. labull_framework/magic_block/service.py +405 -0
  26. labull_framework/magic_block/validate.py +558 -0
  27. labull_framework/magic_block/views.py +407 -0
  28. labull_framework/management/__init__.py +0 -0
  29. labull_framework/management/commands/__init__.py +0 -0
  30. labull_framework/management/commands/labull_contract.py +52 -0
  31. labull_framework/migrations/0001_initial.py +71 -0
  32. labull_framework/migrations/__init__.py +0 -0
  33. labull_framework/models.py +76 -0
  34. labull_framework/settings.py +223 -0
  35. labull_framework/urls.py +93 -0
  36. labull_framework-0.1.0.dist-info/METADATA +140 -0
  37. labull_framework-0.1.0.dist-info/RECORD +40 -0
  38. labull_framework-0.1.0.dist-info/WHEEL +5 -0
  39. labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
  40. labull_framework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,487 @@
1
+ /* schema canvas 的外观。
2
+ *
3
+ * 🔴 这一页**不引任何前端主题**(它要在"只装了后端"的项目里也能看)——
4
+ * 所以颜色在这里自成一套,只用 CSS 变量收口,方便改。
5
+ * 🔴 版式是 ER 图那一套:**稀疏画布 + 绝对定位的卡片**(不是等宽网格),
6
+ * 表头一行带状态色,字段一行四栏(名字 / 种类 / 库类型 / 备注)。
7
+ */
8
+
9
+ :root {
10
+ --bg: #eef1f5;
11
+ --card: #fff;
12
+ --line: #d8dee6;
13
+ --line-soft: #eef1f5;
14
+ --head: #f4f7fa;
15
+ --text: #1f2933;
16
+ --muted: #6b7280;
17
+ --added: #15803d;
18
+ --modified: #b45309;
19
+ --deleted: #b91c1c;
20
+ --accent: #2563eb;
21
+ /* 三态的底色(🔴 整块 / 整行染色,而不是只描一圈边 —— 一屏几十列时边线看不出是哪一行变了) */
22
+ --tint-added: #e8f6ec;
23
+ --tint-added-strong: #d5efdd;
24
+ --tint-modified: #fdf3e0;
25
+ --tint-modified-strong: #fbe8c8;
26
+ --tint-deleted: #fdeaea;
27
+ --tint-deleted-strong: #f9d9d9;
28
+ /* 字段种类的颜色(与 canvas.js 里 KIND 的 tone 一一对应)*/
29
+ --tone-slate: #64748b;
30
+ --tone-violet: #7c3aed;
31
+ --tone-blue: #2563eb;
32
+ --tone-green: #15803d;
33
+ --tone-amber: #b45309;
34
+ --tone-teal: #0f766e;
35
+ }
36
+
37
+ * { box-sizing: border-box; }
38
+
39
+ html, body { height: 100%; }
40
+
41
+ body {
42
+ margin: 0;
43
+ display: flex;
44
+ flex-direction: column;
45
+ background: var(--bg);
46
+ color: var(--text);
47
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
48
+ -webkit-font-smoothing: antialiased;
49
+ }
50
+
51
+ /* ── 顶部两条 ─────────────────────────────────────────────────────────── */
52
+
53
+ .bar {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 12px;
57
+ padding: 9px 16px;
58
+ background: #1f2937;
59
+ color: #fff;
60
+ }
61
+
62
+ .brand { font-weight: 600; letter-spacing: .3px; }
63
+ .git { color: #9ca3af; font-size: 13px; }
64
+ .git b { color: #fde68a; font-family: ui-monospace, Consolas, monospace; }
65
+ .stat { color: #9ca3af; font-size: 13px; }
66
+ .grow { flex: 1; }
67
+
68
+ .toolbar {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 12px;
72
+ padding: 8px 16px;
73
+ background: var(--card);
74
+ border-bottom: 1px solid var(--line);
75
+ }
76
+
77
+ .toolbar button, .toolbar select {
78
+ font: inherit;
79
+ padding: 4px 12px;
80
+ border: 1px solid var(--line);
81
+ border-radius: 6px;
82
+ background: var(--card);
83
+ color: var(--text);
84
+ cursor: pointer;
85
+ }
86
+ .toolbar button:hover, .toolbar select:hover { border-color: var(--accent); color: var(--accent); }
87
+
88
+ .legend { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 13px; }
89
+ .sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
90
+ .sw.added { background: var(--added); }
91
+ .sw.modified { background: var(--modified); }
92
+ .sw.deleted { background: var(--deleted); }
93
+
94
+ .alert {
95
+ margin: 10px 16px 0;
96
+ padding: 8px 12px;
97
+ border: 1px solid var(--modified);
98
+ border-left-width: 3px;
99
+ border-radius: 6px;
100
+ background: #fffbeb;
101
+ color: var(--modified);
102
+ }
103
+ .alert.is-error { border-color: var(--deleted); background: #fef2f2; color: var(--deleted); }
104
+
105
+ /* ── 画布 ─────────────────────────────────────────────────────────────── */
106
+
107
+ #viewport { position: relative; flex: 1; min-height: 0; overflow: hidden; }
108
+
109
+ #canvas { position: absolute; inset: 0; overflow: hidden; cursor: grab; }
110
+ #canvas.is-panning { cursor: grabbing; }
111
+
112
+ /* 🔴 缩放 / 平移作用在 `.surface` 上,卡片与连线都在它里面 ——
113
+ * 分两个容器各缩一次的话,线与卡片很快就错位,而这一页的价值就是"线和卡片对得上"。 */
114
+ .surface { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
115
+ /* 卡片绝对定位的参照层。
116
+ * 🔴 **宽高必须自己撑开**:里面全是绝对定位的卡片(还有绝对定位的 SVG),
117
+ * 普通块级盒会塌成 0 宽 —— 而 SVG 是 `width: 100%`,宽度 0 的 SVG 里一个字都画不出来,
118
+ * 症状是"线全都消失、卡片还在"(真踩过)。`fit-content` + 高度由 canvas.js 设。 */
119
+ .layer {
120
+ position: relative;
121
+ width: fit-content;
122
+ min-width: 100%;
123
+ min-height: 100%;
124
+ }
125
+
126
+ .zoom-group { display: inline-flex; align-items: center; gap: 4px; }
127
+ .zoom-value {
128
+ min-width: 44px;
129
+ color: var(--muted);
130
+ font-size: 12px;
131
+ font-family: ui-monospace, Consolas, monospace;
132
+ text-align: center;
133
+ user-select: none;
134
+ }
135
+
136
+ /* 点关联键名 / 字段提示时那个看备注的小浮层(`position: fixed`,位置由 canvas.js 夹在窗口内) */
137
+ .edge-note {
138
+ position: fixed;
139
+ z-index: 200;
140
+ max-width: 22rem;
141
+ padding: 8px 10px;
142
+ border: 1px solid var(--line);
143
+ border-radius: 8px;
144
+ background: var(--card);
145
+ color: var(--text);
146
+ box-shadow: 0 8px 24px rgb(15 23 42 / 18%);
147
+ }
148
+ .edge-note h4 { margin: 0 0 4px; font-size: 12px; font-family: ui-monospace, Consolas, monospace; }
149
+ .edge-note p { margin: 0 0 6px; font-size: 12.5px; line-height: 1.6; }
150
+ .edge-note small { color: var(--muted); font-size: 11px; }
151
+
152
+ /* ── 表与表之间的连线 ─────────────────────────────────────────────────── */
153
+ /* 🔴 线要画在卡片**上面**(`z-index: 2`,卡片是 1):卡片里有 `<table>`,单元格各自会做
154
+ * 层叠上下文 —— 线画在下面时,端点那两枚基数圆会被字段行**盖住**(真踩过:
155
+ * 量出来 `document.elementFromPoint(圆心)` 是 `TD.name`,圆明明画了却看不见)。
156
+ * 🔴 `pointer-events: none` 是必须的:SVG 铺满整个画布,吃事件的话卡片就点不动、拖不动了;
157
+ * 线自己那点可点区域由 `.edge { pointer-events: stroke }` 单独放开。 */
158
+ .edges {
159
+ position: absolute;
160
+ left: 0;
161
+ top: 0;
162
+ width: 100%;
163
+ height: 100%;
164
+ overflow: visible;
165
+ pointer-events: none;
166
+ z-index: 2;
167
+ }
168
+
169
+ .edge {
170
+ fill: none;
171
+ stroke: var(--tone-green);
172
+ stroke-width: 1.6;
173
+ stroke-linejoin: round;
174
+ pointer-events: stroke;
175
+ transition: stroke-width .12s;
176
+ }
177
+ .edge.violet { stroke: var(--tone-violet); }
178
+ /* 反向(只读)那一侧画虚一点:库里没有这一列,不该看起来跟真外键一模一样 */
179
+ .edge.is-readonly { stroke-dasharray: 5 4; }
180
+ /* 悬停卡片上那一行关联时,这条线加粗、并给它一圈白底(压在别的线上也看得清) */
181
+ .edge.is-hot {
182
+ stroke-width: 3;
183
+ filter: drop-shadow(0 0 2px rgb(255 255 255 / 90%));
184
+ }
185
+
186
+ /* 箭头是**开口的 `>`**(不是实心三角):两条线靠得近时实心块会把线头糊成一团,
187
+ * 开口箭仍看得出方向、也不遮住后面的东西。所以这里是描边、不是填充。
188
+ * 🔴 比线本身**粗一点**:这个大小下(线 1.6 / 箭头 2.2)开口的夹角才看得出来是 `>`,
189
+ * 与线同粗细时它会糊成一个小实心疙瘩(真踩过)。 */
190
+ .arrow { fill: none; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
191
+ .arrow.green { stroke: var(--tone-green); }
192
+ .arrow.violet { stroke: var(--tone-violet); }
193
+ .arrow.muted { stroke: var(--muted); }
194
+
195
+ /* 基数:**空心圆里一个 `1` / `*`**,落在方块边界附近(线的两端)。
196
+ * 🔴 圆填白、描边走线的颜色:它压在白卡 / 绿卡 / 黄卡上都读得出来 ——
197
+ * 卡片底色恰恰是"变了没有"的判据,标记不能靠底色来分辨。 */
198
+ .cardinality { pointer-events: none; }
199
+ .cardinality circle { fill: var(--card); stroke: var(--tone-green); stroke-width: 1.8; }
200
+ .cardinality text {
201
+ fill: var(--tone-green);
202
+ font-size: 10px;
203
+ font-weight: 700;
204
+ text-anchor: middle;
205
+ dominant-baseline: middle;
206
+ font-family: ui-monospace, Consolas, monospace;
207
+ paint-order: stroke;
208
+ stroke: var(--card);
209
+ stroke-width: 2.5px;
210
+ stroke-linejoin: round;
211
+ }
212
+ /* `*` 那个字比 `1` 宽,给大一号才在同一个圆里好看。
213
+ * ⚠️ 两个圆**都是实线**:一开始给 `*` 画了虚线想区分"多",用户明确要求实心圆,就去掉了
214
+ * (`1` 与 `*` 的字形本身已经够区分;实线圆看起来也更干净)。 */
215
+ .cardinality.is-many text { font-size: 16px; }
216
+
217
+ /* 关联键的名字:标在**线的正中间**。
218
+ * 🔴 `dominant-baseline: middle` 是必须的:`y` 默认是**基线**,配合上面那句旋转,
219
+ * 不写它文字就会整体偏到线的一侧。`paint-order: stroke` 再描一圈卡片色,
220
+ * 线从字底下穿过时也读得清。 */
221
+ .edge-name {
222
+ fill: var(--muted);
223
+ font-size: 10.5px;
224
+ font-family: ui-monospace, Consolas, monospace;
225
+ text-anchor: middle;
226
+ dominant-baseline: middle;
227
+ paint-order: stroke;
228
+ stroke: var(--card);
229
+ stroke-width: 3.5px;
230
+ stroke-linejoin: round;
231
+ pointer-events: none;
232
+ }
233
+ /* 有关联备注时才可点(点开看备注)—— 鼠标形状是唯一的提示,所以只给能点的那些加。 */
234
+ .edge-name.has-note { pointer-events: auto; cursor: pointer; }
235
+ .edge-name.has-note:hover { fill: var(--accent); }
236
+
237
+ .empty { margin: 40px; color: var(--muted); }
238
+
239
+ /* ── 一张表的卡片 ─────────────────────────────────────────────────────── */
240
+
241
+ .table {
242
+ position: absolute;
243
+ width: 320px;
244
+ background: var(--card);
245
+ border: 1px solid var(--line);
246
+ border-radius: 10px;
247
+ box-shadow: 0 1px 2px rgb(15 23 42 / 6%), 0 8px 24px rgb(15 23 42 / 4%);
248
+ overflow: hidden;
249
+ transition: box-shadow .15s, border-color .15s;
250
+ }
251
+ .table:hover { box-shadow: 0 2px 4px rgb(15 23 42 / 8%), 0 12px 32px rgb(15 23 42 / 8%); }
252
+
253
+ /* 三态:**整块染底色** + 表头加深 + 左侧一道色条。
254
+ * 🔴 底色是主判据、色条是辅助:一眼扫过去先看到"哪几张表变了",再看左条确认是加还是删。 */
255
+ .table.added { border-color: color-mix(in srgb, var(--added) 45%, var(--line)); background: var(--tint-added); }
256
+ .table.modified { border-color: color-mix(in srgb, var(--modified) 45%, var(--line)); background: var(--tint-modified); }
257
+ .table.deleted { border-color: color-mix(in srgb, var(--deleted) 40%, var(--line)); background: var(--tint-deleted); opacity: .78; }
258
+ .table.added > .head { background: var(--tint-added-strong); box-shadow: inset 3px 0 0 var(--added); }
259
+ .table.modified > .head { background: var(--tint-modified-strong); box-shadow: inset 3px 0 0 var(--modified); }
260
+ .table.deleted > .head { background: var(--tint-deleted-strong); box-shadow: inset 3px 0 0 var(--deleted); }
261
+ /* 没变过的表保持素白(否则"变了"这件事就不显眼了) */
262
+ .table:not(.added):not(.modified):not(.deleted) > .head { background: var(--head); }
263
+
264
+ .table.is-flash { animation: flash 1.2s ease; }
265
+ @keyframes flash {
266
+ 0%, 100% { box-shadow: 0 1px 2px rgb(15 23 42 / 6%), 0 8px 24px rgb(15 23 42 / 4%); }
267
+ 30% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent), 0 12px 32px rgb(15 23 42 / 12%); }
268
+ }
269
+
270
+ .head {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 9px 12px;
275
+ background: var(--head);
276
+ border-bottom: 1px solid var(--line);
277
+ cursor: pointer;
278
+ user-select: none;
279
+ }
280
+ .head:hover { background: #e9eff6; }
281
+
282
+ .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; }
285
+ .title .mod { color: var(--muted); font-size: 11px; }
286
+
287
+ .caret { color: var(--muted); font-size: 11px; transition: transform .15s; }
288
+ .table.is-open .caret { transform: rotate(180deg); }
289
+
290
+ /* 🔴 收起 ≠ 只剩标题:**字段表照常显示**(ER 图不该点开才看得到列)。
291
+ * 收起只是把"备注全文 / 字段详情 / 关联"收起来,并且把那行短备注截成一行。 */
292
+ .table:not(.is-open) .note,
293
+ .table:not(.is-open) .rels,
294
+ .table:not(.is-open) tr.detail { display: none; }
295
+ .table:not(.is-open) .brief {
296
+ overflow: hidden;
297
+ text-overflow: ellipsis;
298
+ white-space: nowrap;
299
+ }
300
+ .table.is-open .brief { white-space: normal; }
301
+
302
+ .brief {
303
+ margin: 0;
304
+ padding: 6px 12px;
305
+ color: var(--muted);
306
+ font-size: 12px;
307
+ border-bottom: 1px dashed var(--line);
308
+ }
309
+
310
+ /* ── 展开之后的内容 ───────────────────────────────────────────────────── */
311
+
312
+ .note { padding: 10px 12px 4px; border-bottom: 1px solid var(--line-soft); }
313
+ .note h4, .rels h4 {
314
+ margin: 0 0 4px;
315
+ font-size: 12px;
316
+ font-weight: 600;
317
+ color: var(--muted);
318
+ text-transform: none;
319
+ }
320
+ .note p { margin: 0 0 6px; font-size: 13px; }
321
+
322
+ /* ── 字段行 ──────────────────────────────────────────────────────────────
323
+ * 🔴 这里用 **CSS grid** 而不是 `<table>` 的自动布局:字段行要支持"列序翻转"
324
+ * (字段名优先 ⇄ 备注优先),而 CSS 的 `order` 对 `<td>` **不生效**
325
+ * (真踩过:按钮文案变了、表格一个格子都没动)。grid 里 `order` 是正经生效的。
326
+ * `<table>` / `<tr>` / `<td>` 只是保留下来当语义与 DOM 结构,显示全部走 grid。
327
+ * 🔴 每个轨道都要写**最小可用宽**(`minmax(min-content, …)`)而不是 `minmax(0, …)`:
328
+ * 后者允许把列压到 0 宽,几列就叠在一起了(真踩过:备注优先时 `created_at` 与"时间"重合)。
329
+ * 挤压时宁可横向溢出,也不能把两列画在同一个位置。
330
+ */
331
+ table.cols { display: grid; width: 100%; }
332
+ table.cols tbody { display: grid; }
333
+ table.cols tr.field {
334
+ display: grid;
335
+ grid-template-columns:
336
+ minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto) minmax(0, 1fr);
337
+ align-items: stretch;
338
+ }
339
+ table.cols tr.detail { display: none; grid-template-columns: 1fr; }
340
+ table.cols tr.field.is-open + tr.detail { display: grid; }
341
+ table.cols tr.detail > td { grid-column: 1 / -1; }
342
+
343
+ table.cols td {
344
+ padding: 4px 10px;
345
+ border-top: 1px solid var(--line-soft);
346
+ font-size: 12.5px;
347
+ overflow-wrap: anywhere;
348
+ }
349
+ table.cols tr.field:first-child td { border-top: 0; }
350
+
351
+ /* 「备注优先」:**备注 | 字段名 | 种类 | 库类型**。
352
+ * 只翻 `order` 不够 —— 列宽也得换,否则备注列还是那条窄缝(等于没翻)。
353
+ * 🔴 备注列给**固定宽**,不给 `fr`:它是这张网格里**唯一**的弹性轨道,
354
+ * `0.5fr` 和 `9fr` 算出来一样宽(都吃掉全部剩余)—— 真踩过,改了半天没反应。
355
+ * 固定 128px 之后,字段名那一列自然贴到左边,右侧多出来的留白(收窄卡片或拖宽都行)。 */
356
+ #canvas.is-note-first table.cols tr.field {
357
+ grid-template-columns:
358
+ 116px minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);
359
+ justify-content: start;
360
+ }
361
+ #canvas.is-note-first td.brief { order: -1; }
362
+ /* 备注优先时把卡片放宽一点:四列里有一列是整句说明,320px 是字段名优先那套的宽度。 */
363
+ #canvas.is-note-first .table { width: 380px; }
364
+
365
+ /* 一格备注都没有时那个破折号:压暗,别让人以为"这里有内容" */
366
+ td.brief.empty { color: var(--line); }
367
+
368
+ /* 🔴 逐列差异:**整行**染底色(不是只给列名那个格子)——
369
+ * 人扫一眼是"哪一行"在变,不是"哪一列",只染一格反而更难找。 */
370
+ tr.field.is-added > td { background: var(--tint-added-strong); }
371
+ tr.field.is-modified > td { background: var(--tint-modified-strong); }
372
+ tr.field.is-deleted > td { background: var(--tint-deleted-strong); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--deleted) 55%, transparent); }
373
+ /* 展开的详情那一行跟着它上面那一行的颜色,别在中间裂一道白 */
374
+ tr.field.is-added + tr.detail > td { background: var(--tint-added); }
375
+ tr.field.is-modified + tr.detail > td { background: var(--tint-modified); }
376
+ tr.field.is-deleted + tr.detail > td { background: var(--tint-deleted); }
377
+ /* 悬停时压深一点,但不要盖掉"变了"这件事 */
378
+ tr.field.has-detail:hover > td { filter: brightness(.97); }
379
+
380
+ td.name { font-family: ui-monospace, Consolas, monospace; white-space: nowrap; }
381
+ td.name .tag {
382
+ margin-left: 5px;
383
+ padding: 0 4px;
384
+ border-radius: 4px;
385
+ font-size: 10px;
386
+ font-family: inherit;
387
+ vertical-align: 1px;
388
+ }
389
+ .tag.pk { background: #fef3c7; color: #92400e; }
390
+ .tag.fk { background: #dcfce7; color: #166534; }
391
+ .tag.uq { background: #e0e7ff; color: #3730a3; }
392
+
393
+ td.kind { white-space: nowrap; color: var(--muted); }
394
+ td.kind .mark {
395
+ display: inline-block;
396
+ width: 16px;
397
+ text-align: center;
398
+ margin-right: 4px;
399
+ font-style: normal;
400
+ font-family: ui-monospace, Consolas, monospace;
401
+ }
402
+ .mark.slate { color: var(--tone-slate); }
403
+ .mark.violet { color: var(--tone-violet); }
404
+ .mark.blue { color: var(--tone-blue); }
405
+ .mark.green { color: var(--tone-green); }
406
+ .mark.amber { color: var(--tone-amber); }
407
+ .mark.teal { color: var(--tone-teal); }
408
+
409
+ td.type {
410
+ color: var(--muted);
411
+ text-align: right;
412
+ white-space: nowrap;
413
+ font-family: ui-monospace, Consolas, monospace;
414
+ font-size: 11.5px;
415
+ }
416
+
417
+ td.brief { color: var(--muted); }
418
+ td.brief.muted { opacity: .75; }
419
+
420
+ /* 可点开的字段行 */
421
+ tr.field.has-detail { cursor: pointer; }
422
+ tr.field.has-detail::after { content: ""; }
423
+ /* 展开箭头:用负边距把它塞进名字格**左边那点空隙**里(不额外占宽)。
424
+ * 🔴 备注优先时名字格跑到第二列,那个负边距会落进行中间 ⇒ 得给名字格让出左边距,
425
+ * 箭头才贴着行首(真踩过:箭头飘在"客户名 | ▸ name"中间)。 */
426
+ tr.field.has-detail td.name::before {
427
+ content: "▸";
428
+ display: inline-block;
429
+ width: 10px;
430
+ margin-left: -10px;
431
+ color: var(--muted);
432
+ font-size: 10px;
433
+ }
434
+ tr.field.is-open td.name::before { content: "▾"; }
435
+ #canvas.is-note-first tr.field td.name { padding-left: 22px; }
436
+ #canvas.is-note-first tr.field.has-detail td.name::before { margin-left: -12px; }
437
+
438
+ tr.detail td {
439
+ padding: 6px 10px 10px 22px;
440
+ background: #f8fafc;
441
+ border-top: 1px solid var(--line-soft);
442
+ color: var(--text);
443
+ font-size: 12.5px;
444
+ }
445
+ tr.detail p { margin: 0 0 4px; }
446
+ tr.detail code {
447
+ padding: 0 4px;
448
+ border-radius: 4px;
449
+ background: #eef2f7;
450
+ font-family: ui-monospace, Consolas, monospace;
451
+ font-size: 11.5px;
452
+ }
453
+ tr.detail .muted { color: var(--muted); }
454
+ tr.detail .note-text { color: var(--muted); }
455
+
456
+ /* ── 关联 ─────────────────────────────────────────────────────────────── */
457
+
458
+ .rels { padding: 8px 12px 10px; border-top: 1px solid var(--line-soft); }
459
+ .rel {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: 6px;
463
+ width: 100%;
464
+ padding: 3px 6px;
465
+ border: 0;
466
+ border-radius: 6px;
467
+ background: none;
468
+ color: var(--text);
469
+ font: inherit;
470
+ font-size: 12.5px;
471
+ text-align: left;
472
+ cursor: pointer;
473
+ }
474
+ .rel:hover { background: #f1f5f9; }
475
+ .rel .rel-name { font-family: ui-monospace, Consolas, monospace; }
476
+ .rel .rel-to { margin-left: auto; color: var(--accent); font-size: 12px; }
477
+
478
+ /* ── 底部提示 ─────────────────────────────────────────────────────────── */
479
+
480
+ .hint {
481
+ margin: 0;
482
+ padding: 6px 16px;
483
+ color: var(--muted);
484
+ font-size: 12px;
485
+ background: var(--card);
486
+ border-top: 1px solid var(--line);
487
+ }