ek-ui 0.9.46

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
# DESIGN —— ek-ui(S1b 常驻观测界面)

Frozen ref: docs/ek-ui-station-plan.md(DV 表 + S1b 段验收)。本档只记**结构决定**与
执行期钉。形式沿用 ek-dm/ek-station 的 BD-U1.. 编号加序。

## 结构

- `state.rs`:`UiShared`(总线面写 / 窗口面读)+ `LogRing`(cap 512,挤旧 +
  spilled 单调计数)+ `BusSnap`(svctl.list 行 + 逐插件服务 id 目录)。
  锁粒度:每次操作 lock/clone,不跨帧持锁;`snapshot()` 返回 `Arc<BusSnap>` 零拷贝。
- `plugin.rs`:`UiPlugin`("ui"):routes = `eLog`/`ePlugUp`/`ePlugDown`/`eTimer` +
  `eTick.`(行情带唯一源)+ `eContract.`/`eContractFinish`(查询合约窗数据流,
  ek-ctp 登录后自动查询,UI 只收不催)。
  - eLog:payload `LogData`,`is_warning_or_above` 打 WARN 标签(+UCH 转储环形);
  - ePlugUp/Down:payload `PlugNotice` → 单行 host 日志 + rev;
  - eTick./eContract.:payload 直落 `tick_store`/`contract_store`(键 EX.symbol);
  - eTimer:`ask(svctl.list, 2s)` + 逐插件 `ctx.services_of` → `set_snapshot`;
    ask 失败进日志面,不堆 panic。
- `render.rs`:`render_body(ctx, &UiShared)` 唯一渲染入口(L2;v5 面板化,
  BD-UI-27/29):TopBottomPanel(菜单)→ TopBottomPanel(行情带,resizable)→
  SidePanel(下单面板 + 盘口,resizable)→ CentralPanel(2×2 监控格,响应式)→
  弹窗面(K线/运维/CTP/查询合约,egui::Window,面板之后)。
- `runtime.rs`:
  - `ui_kinds(&Arc<UiShared>)` = station 缺省表 + ui 工厂;工厂闭包绑定**同一个**
    shared → ui 插件被 svctl 摘除/再注,窗口观测态不丢(状态在 shared 不在插件体);
  - `spawn_host_watcher`:`sleep 120ms` 轮询 stop 位 → `engine.close_all(3s)`;
    close 顺序(逆 start 序)仍由宿主引擎决定,观察线程只送信号;
  - `run_station_ui`:boot(不 start 由 boot 内部? 否—— boot 只装配)→ `engine.start()`
    → 观察线程 → eframe `run_native`;关窗 `on_close_event → request_stop` →
    join 观察线程 → 返回。启动失败(start Err)以 `Err(String)` 上抛(供 S1c
    launcher 的失败信号,不在窗口内吞错)。

## 执行期钉

- **BD-UI-9(UI 即插件 / 双线程形貌)**:总线 worker 写、eframe 主线程读,以
  `Arc<UiShared>` 相连;不加自制通道、不退回向量共享消息队列。
- **BD-UI-10(L2 单渲染入口)**:`render_body(ctx, &UiShared)` 被真实 App 与
  kittest 同调;面板组合在 render_body 内(BD-UI-29),测试走
  `Harness::builder().build(|ctx| …)`(ctx 级,不再包 CentralPanel)。
- **BD-UI-7(四件套版本锁)**:eframe = egui = egui_kittest = `0.33.3`;
  egui-charts 0.2.0 进入点定为 S4(K 线页),进入时与四件套重验一版。
- **BD-UI-11(关窗协议)**:`App::on_close_event` 只置 stop 位;close_all 由
  观察线程完成 —— 窗口永不直接持有 `MainEngine::close_all` 调用链。
- **BD-UI-12(kittest 用法纪律,0.3.2 改口径)**:判据四类 —— ① AccessKit 语义树
  (节点在不在、标签是什么、`Node::rect()` 几何);② 状态层(`UiShared` 字段/访问器,纯单测);
  ③ 源码机械扫描(色值只在 `tokens`、显式开关、格式只走 `src/format.rs`);
  ④ **本帧绘制指令**(`egui::FullOutput.shapes` 的实心矩形 / 线段 / 文字形状 + `clip_rect`)。
  **图像快照与像素扫描作废**:`harness.render()` / `harness.snapshot()` /
  `UPDATE_SNAPSHOTS` 一律不再用(本机没有截图也没有读图能力,详见本文件末「0.3.2 口径变更」
  与交接报告 §17)。
- **BD-UI-13(纯观测零服务)**:ui 插件 `services()` 返回空;管制面唯一在 svctl
  —— 界面无特权路径。动态 add 的 ask 窗口期(`on_init` 后)与 headless 同一契约。
- **BD-UI-14(启动器协议,与 ek-station bin `--from/--ready-file` 同口径)**:
  清单 = 行式文本(`#` 注释;空行跳过;行内空白拒);就绪标记 = `ready\n`;
  spawn 分离式(stdin/stdout 静默 + Windows `CREATE_NO_WINDOW` + stderr 管道,
  **stderr 生存期 ≤ 64KB** —— 子进程不朝 stderr 泼流,违约即协议故障);
  等待 = 50ms 轮询标记 + 存活检查,30s 上限(超时 kill);失败分类 `Exited{code,
  stderr_tail}` / `NoReadySignal{timeout}`;成功路径 **drop Handle = 分离脱管**。
- **BD-UI-15(清单解析地面)**:pick.rs 与 ek-station main.rs 的 `--from` 解析
  S1c 各自落地(等语义);S3 toml-lite 重构时统一抽到共享模块(单点 PR),避免
  现在跨 crate 发版拖 III 制订的节奏。
- **BD-UI-16(pick 窗两线程形貌)**:UI 线程只画与翻位;spawn+wait 在工作线程
  (30s 不冻 UI);结果回槽 `RunShared`;App 见果即关窗,
  收场归 `run_pick_launcher`(join + 读槽)。ui 插件自动补入 pick 后配置,
  其余勾选即装配面(与 boot 同表)。
- **BD-UI-17(隔离红标的闭环面)**:`ePlugQDown.<名>` 是定向广播(未知名无法
  预订阅 —— 严格 exact-type 路由),UI 侧红标走快照行状态扫描
  (`is_quarantined`,1s 节拍内落实);隔离行仍可重注入(svctl.start 对隔离行
  被 Rejected(重名)拒;既定语义链 = 先 remove 再 start)。红标卡件 trace:
  `ek-core::lifecycle::EVENT_PLUG_DOWN_PREFIX("ePlugQDown.")` 常量名与后缀型
  一致,但仅作 reference,不预订阅。
- **BD-UI-18(操作指令的唯一通道)**:窗口面动作只 `push_cmd(UiCmd)`,
  NOT 直 ask —— `PluginContext` 只在 ui 插件 worker 可用;eTimer 拍先辞行
  指令再拉快照;移除走 `pending_confirm` 单槽二步(Armed→Fired;Switched =
  目标迁移),取消钮可擦;重注入为单步(加性动作);拒绝类回执
  (`Rejected/NoSuchName`)进日志面板,不在 UI 抛错。
- **BD-UI-19(配置面 → 2026-10-17 用户定盘撤销,零配置口径)**:原 S3 落地过
  toml-lite + `setting/global.toml` 融入读法 + 状态条 + 脱敏(cfg.rs/cfg 测试
  9 例/s7 kittest 1 例)。**现行口径:ek-ui 零配置、宿主零配置** —— 不读任何
  配置文件,界面本身无设置;插件结构只由 `--from` 清单或 demo 缺省决定。
  需要配置的插件(唯一 = ek-ctp 虚拟账号),配置面长在插件自身,激活时在插件
  内完成(见「配置面撤销记录」);宿主/界面不代读不转发。cfg.rs 整块删除;
  脱敏纪律(redact)随配置面一同撤销(无配置输入即无秘密暴露面;插件自带
  配置面若需展示账号,脱敏规则届时由该插件自持)。
  **2026-10-25 第三版修订(BD-CFG,用户定盘「软集中」)**:口径升级为「零配置
  **文件**」——宿主/界面**不代读、不代转发、不代解释**配置值,插件配置权威自持
  不变;新增持久化托底:插件经 `Configurable` 契约(ek-core additive)由 ek-cfg
  库 / `cfg` 插件统一存取,落盘位置 `${config_dir}/ek/cfg/`(本仓库外),
  明面写文件、秘值(密码等)进系统凭据库——文件里只有 `secret://` 引用串,
  静态文件零明文密码。BD-CFG-1..20 钉清单全文单源在 ek-station DESIGN。
  界面侧变化最小:ui_kinds 多一行 `cfg` 登记;运维窗多「配置托底」组;
  CTP 表单密码格语义不动(留空 = 用已存)。
- **BD-UI-20(S4 行情面口径)**:主题面(数据)与身体面(交互)分离——
  `MarketStore`持直播线上屏(eBar./eBarUpdate.),(symbol,interval) 格缓存
  4096 丢头保尾,帧/更新/回灌三计数 = 「刷新不丢帧统计可见」的三处钉面;
  K线 chart 实例**每帧重建**(v1 不保 pan/缩放;首帧快照入库即 review 约定
  —— BD-UI-8);图表层引用在本 crate 集中(`render::render_kline_cell`
  一点换器,egui_charts→egui_plot 退守路线只动此处,BD-UI-4)。
- **BD-UI-21(S4 直播面工厂纪律)**:UI 宿主的 dm 工厂在 `ui_kinds` 内换建
  (real_time=true),ek-station headless 的 rt-off 工厂**不动** ——
  宿主差异收在各自工厂表,不回写 ek-station、不新增 svctl 控制面;
  dm 工厂 real_time 门为装配参数(缺省 true;UI 观测面要实时门 —— 非配置文件
  输入,零配置口径,BD-UI-19 现行版)。
- **BD-UI-15 兑现记录(S3)**:清单地面统一抽世到 `ek-station 0.1.1::pickfile`
  (已发布;`ek-ui::pick` 变薄转发,launcher/bin/测试共一处,双实现消灭)。
- **BD-UI-22(S5 纸面网关契约纪律)**:sim 前三口直接复用 ek-ctp 的
  Req/Reply 类型(SubscribeReq/SendOrderReq/CancelOrderReq 及回包)——
  同构 = 同一类型面,接线换 `sim.` 即插真网关;第四口 `query_rate` 为 sim
  自定(ek-ctp 0.2.0 无此口,CTP 流控语义镜像)。撮合规则钉:限价成交于
  委托价、市价成交于 close/last、bar.volume = 本拍成交预算、挂单 FIFO ——
  手数排队由此可断言。撮合器 `SimMatcher` 纯逻辑零总线(金样本直断),
  插件壳只做路由/锁/上总线(锁内撮合锁外 put,与 DmPlugin 同形)。
  sim 不引 ek-oms:成交经 eOrder./eTrade./ePosition. 总线入账由 OmsPlugin
  被动完成(插件不直调铁律不破)。
- **BD-UI-23(S5 监控页读数纪律)**:监控页读数 = ui 插件 eTimer 拍 ask
  `oms.get_all_*` 全量查询帧(与 headless 同源契约;oms 不在 → 空帧不抛错);
  下单/撤单/流控三口走 UiCmd → ask `sim.*`(窗口面零直调)。撤单钮只挂
  ACTIVE_STATUSES 行(与 ek-data 同口径)。kittest 点击纪律:**`click_accesskit`
  取代坐标 `click`** —— 坐标点击对折叠线下(clip 外)节点无效,accesskit
  动作可达不可见控件;`get_all_by_label` 精确匹配、`query_by_label_contains`
  子串匹配且多命中即炸,撞名列按语义选面。
- **BD-UI-24(S6 CTP 手工配置面,2026-10-17 集成测试战役)**:「插件需要配置 →
  配置面在插件自身」的正向界面落地 —— CTP 段 9 输入位全部**人敲**(界面与宿主
  不读文件;密码/AuthCode `password(true)` 掩码不回显;〔当时含 md 用户 / md 密码两格,
  **九期 BD-UI-62 起撤出表单 → 现 8 输入位**,md 凭据留空由插件侧回落 td 侧〕),`连接 CTP` →
  `UiCmd::CtpConfigure(CtpForm)` → ask `ctp.configure`(ek-ctp 0.3.0
  D-CONFIGURE:校验/守卫复位/发起归插件,md 账号回落也在插件侧裁决 —— 表单
  `to_configure_req` 不预处理);`订阅行情` → ask `ctp.subscribe`(订阅位
  逗号/空白解析,空位红字不入列)。`UiCmd` 携 `CtpForm`(全 String 可 PartialEq;
  ek-ctp `ConfigureReq` 无 PartialEq,抬线在 ui 插件出队点一次转换,装版不动)。
  资金小节 = `oms.get_all_accounts` 帧(可用 = balance−frozen 由 AccountData
  自算)。native 经 feature `ctp-native` 透传(默认关;实连战役开,工厂
  `with_native` 在 ek-station 侧)。装配序纪律:要观察插件 on_start 的日志,
  ui 须在清单里先于该插件(观察序 = 装配序,c0 钉)。
- **BD-UI-25(票据网关选择子,2026-10-17 战役)**:票据新增「网关」位(空 =
  `sim` 纸面;填 `ctp` = ask `ctp.send_order`/`ctp.cancel_order`);`UiCmd`
  随单携 `gateway`;行级撤单钮跟随委托来路 `gateway_name`(空兜底 sim)。
- **BD-UI-26(CJK 字体回退,2026-10-18 乱码根因)**:epaint 0.33 缺省字体组
  (Hack/Ubuntu-Light/NotoEmoji/emoji-icon)**无 CJK 字形** → 中文豆腐块。
  `fonts::apply_cjk_fonts` 启动时从系统字体候选表装载一枚 CJK 字体追加
  Proportional/Monospace 两族末位(**只收纯 .ttf/.otf** —— epaint 走 ab_glyph,
  不吃 .ttc 集合且解析失败即 panic;VF 按缺省实例解析,排位靠后);两处
  run_native 取帧闭包接线(runtime + pick 窗)。验收三钉:
  f0 字形 API(22 常用汉字 has_glyph)、s16 汉字密集页快照 baseline、实窗
  stderr 证据行 `[ek-ui] CJK 回退字体已装载: <path>`。界面文案保持中文单语
  (用户定盘:要的是不乱码,不是双语切换)。
- **BD-UI-27(v4 带框架 + 订阅两入口,2026-10-18 布局重做定稿)**:
  ① **带框架(Python 主界面等价)**:菜单栏(运维|网关|查询合约|帮助)→ 行情带
  (全宽,tick 唯一源,交易所二级 tab,双击行弹 K线窗)→ 交易带(左下单面板
  9 字段 + 委托/全撤 + 右 2×2 监控格「委托|未成交」「成交」「持仓」「资金|日志」);
  回灌/票据界面面退役(总线面 UiCmd::Reload 保留);默认窗 1280×860。
  ② **订阅两入口(用户裁定「订阅不在网关窗定死」)**:下单面板代码框回车 =
  CtpSubscribe(bare symbol,Python returnPressed 等价);菜单「查询合约」弹窗
  (Python ContractManager 等价:查询框留空 = 全部 + 勾选 + 订阅选中)。CTP 网关窗
  无订阅字段。UI 对合约流只收不催(无催查询总线口)。
  ③ **egui 0.33 帧内 sizing 退让钉(实窗截图审出)**:帧内 Grid 的 sizing 预估遇
  ComboBox 高行会退让,网格后挂的面板内容会叠回网格 → 下单表单 = 网格(按钮/
  提示语都是网格行,网格后不挂内容),盘口独立帧,交易带整体包 ScrollArea 保小窗
  可达(D7 口径)。④ **kittest 钉**:建构面统一 `harness_for()` 1280×860(实窗
  同源);点击帧断言用 `harness.step()` 单帧(`run()` 空闲帧会撤走单帧红字);
  撞名 label(委托钮/tab/表题)用 `get_all_by_label` 按渲染序取。
- **BD-UI-28(第五轮审计,2026-10-18 复审修正)**:
  ① **回车订阅真缺陷(实码修复)**:egui 单行 TextEdit 遇 Enter 即
  `surrender_focus`,`has_focus()` 在检查时已 false —— 原 `has_focus +
  key_pressed` 口径下回车订阅**实窗从不触发**;改规范口径 `lost_focus() +
  key_pressed(Enter)`(s20 钉实测)。回车输入带 `EX.` 前缀则剥成裸码并回写
  表单(与查询合约窗同口径,ctp.subscribe 收裸码)。
  ② **悬空面清除**:`selected_symbol` 四处写零读(注释声称「联动下单面板」
  未兑现)→ 字段/访问器删除;行情带行**单击 = 带入下单面板**(方案「行单击
  = 选中合约」落地,s8 钉);委托行/持仓行单击无动作(Python 同口径)。
  ③ **委托行双击 = 撤单**(Python OrderMonitor `itemDoubleClicked→
  cancel_order` 等价,活动行限定,s11 钉;原实现双击弹 K线为偏离,已撤 ——
  K线入口唯一在行情带双击)。④ **表列补全(Python 蓝图逐值抄录)**:委托表
  13→17 列(+名称/交易所/类型/来源=reference)、成交表 10→12(+名称/交易所)、
  持仓表 11→16(+名称/交易所/在途/市值=market_value/成本=price);数据面
  OrderData/TradeData/PositionData 字段全在场,零新增依赖。⑤ **tick_of
  交易所感知**:交易所非空 = EX.symbol 精确匹配(跨所同码不串),空 = 仅按
  代码。⑥ **kittest 双击口径**:`step()` 每事件一帧且缺省 step_dt=0.25s →
  两击释放间隔 0.5s > egui `max_double_click_delay` 0.3s → 双击钉用
  `with_step_dt(0.1)` 建构(s11);同帧四连击不可用(每事件一帧)。Node 借用
  harness 树,取 rect/id 后再 step(借用检查);`Role::TextInput` 非 TextBox。
  ⑦ **Frame 布局泄漏钉(s21 满表快照显形)**:`Frame::show` 继承父级
  `ui.horizontal` 的横向布局 → 2×2 格内 tab 行/表题/宽网格横流挤同一行
  (空表期隐形,满表现形)→ `group_frame` 内容强制 `ui.vertical`(行情带同款
  口径:短行成行,宽网格末位溢出走横向滚动);下单面板帧不动(其内容 = 网格 +
  罕见 err label,横流反而护住 BD-UI-27 叠行钉)。委托表表题撤除(tab
  「委托|未成交」即 Python 表身份,三重撞名降为两面,s12 口径同步)。
  满表视觉钉 s21(委托 17/成交 12/持仓 16 列入 baseline)。
- **BD-UI-29(面板化重构,布局 v5;实窗演示暴露 + 官方文档核对)**:实窗
  1280 演示暴露三症:① 交易带定宽 250+140+480+480+间距 ≈ 1438 > 1280 →
  右列裁切且带级 ScrollArea 只开纵向 → 裁掉内容不可达(违 BD-UI-27 可达
  口径);② 2×2 格定高 250 实际只渲染 ~85px → 底部死区 ~300px;③ 菜单/两带
  全挤单 CentralPanel 手工嵌套,无 Python splitter 语义。
  - **机制实锤(dbg 探针)**:`[row] avail=[9984, 18]` —— egui horizontal 行
    成员的可用高度初始只有一行高(interact_size),**高度分配不沿横向向下
    传播**,行按内容收缩 → 格内 `ScrollArea.max_height` 被钳到最小滚动尺寸,
    定高失效(②的根因;①为定宽算术,1438>1280 可复现)。
  - **官方口径核对**(egui 0.33.3 源码模块文档 = docs.rs 同源;crates.io 包
    不含 examples/,egui.rs 书为 JS 渲染页):panel.rs「面板是放控件的正规
    区域;顶层面板不得互相嵌套;CentralPanel 必须最后加;Window 挂面板之后」;
    `TopBottomPanel::resizable/default_height`、`SidePanel::resizable/
    default_width` = QSplitter 官方等价;resizable 前提 = 内容吃满可用空间
    (ScrollArea auto_shrink(false) 满足)。
  - **落地**:render_body 改收 `&egui::Context` 自开四面板(菜单 → 行情带
    240 可拖 → 下单侧栏 250 可拖[下单面板 + 盘口纵排,Python TradingWidget
    内序] → CentralPanel 最后 → 弹窗);2×2 格响应式 `allocate_exact_size`
    预留 (可用宽-间距)/2 × (可用高-间距)/2 矩形 + `new_child` 在预留矩形内
    开 Frame+ScrollArea(高度才吃满;`add_sized` 收 impl Widget 不适用闭包
    内容,已试撤);`group_frame` 保留 `ui.vertical` 泄漏钉(BD-UI-28⑦ 延续)。
  - **BD-UI-28⑦「下单面板帧不动」被本钉取代**:面板化后下单侧栏内容天然
    纵向,err label 后置改由 ScrollArea 吸收 sizing 退让(BD-UI-27 口径不变,
    护垫从「横流侥幸」换成「官方吸收面」)。
  - **钉**:s3 加结构钉(右列存在贴边不裁切/底行沉底无死区/侧栏在左/两列
    等宽随窗分配,先于字体让位执行);快照 s3/s10/s16/s21 重基线;行为钉
    s0–s20 全数不动全绿;kittest 建构面 `build_ui` → `build`(ctx 级)。

## 与 ek-station 的边界

ek-ui 依赖 `ek-station = "0.1.7"` / `ek-ctp = "0.5.0"`(装版;禁 path —— 同纪律)。ek-station
本 bin 保持 headless 不引 ek-ui;UI 总装配在 ek-ui(`ui_kinds` 复用 `default_kinds`
+ ui 工厂),launcher(S1c)也在 ek-ui 侧。

## 审计修正记录(2026-10-17 全面复审)

- **BD-UI-22 补钉(撮合两守卫)**:① **tick/bar 双重计价防护** —— tick 成交
  挂账 `tick_filled`,bar 收口预算 = bar.volume − 本段 tick 已成(同一笔市场
  量只吃一次;u5 钉);② **bar 单调守卫** —— 同 symbol `bar.datetime` 非严格
  递增的帧(重复/重放)直接忽略,不重复撮合(u6 钉);③ ask 臂锁面开块
  (match 暂存守卫会跨臂存活 —— 锁内造单锁外上总线,与 on_mail 同形)。
- **ek-station 0.1.2(pickfile BOM 剥离)**:Notepad 系 UTF-8 BOM 使首行注释
  识别失效 → 整表 BadLine 拒启(活跑实测撞到);read 剥 BOM,p3 钉。
- 复审确认未动面:ek-station headless 工厂表(rt-off)未波及;已发布六仓仅
  rustfmt 1.8.0 纯格式对齐(无语义变);setting/global.toml 只读;
  ek-ctp/ek-oms 契约类型只引用未改。

## 配置面撤销记录(2026-10-17 用户定盘)

- 用户口径:**界面零配置 + 宿主零配置** —— ek-ui 不读任何配置文件;station
  侧也不预读配置转发。插件结构只由 `--from` 清单或 demo 缺省决定。
- **配置归插件自身(定盘)**:激活列表(pick 窗勾选 / svctl start 重注入)时,
  哪个插件需要配置,配置面就在该插件内部完成(插件自带配置面/配置服务);
  宿主与界面都不代读、不转发、不集中。暂时不需要任何多配置机制。
- 全生态唯一需要配置的插件 = ek-ctp(虚拟账号)。其配置面归 ek-ctp 插件自身
  (S6 接线时按此定盘落地:激活 ctp 时配置在插件内完成,不落共享配置树)。
- 落地:cfg.rs(toml-lite/UiConfig/locate_setting/redact)+ CfgStatus 状态条 +
  `--config` 形态 + 发现链整块删除;cfg 测试 9 例 + s7 kittest 1 例随撤;
  测试 51 → 41。**setting/ 目录已整树清理(2026-10-17 定盘)**:global.toml +
  backtest/common/live/test 分层样例(含凭据档)全部删除——插件配置自持,
  工作区不留共享配置文件;Python 侧 c:\dev\enkeep_trade_setting 原档不在
  清理范围(只读纪律,原样未动)。

## 配置托底定盘(2026-10-25,BD-CFG;用户裁定「软集中」)

BD-UI-19 第三版兑现面。BD-CFG-1..20 钉清单全文**单源**在 ek-station DESIGN,
本节只记 ek-ui 侧职责,不复制钉文:

- **ui_kinds 登记 `cfg` 一行**(与 dm/oms/ctp 同构;CfgPlugin 来自 ek-cfg);
- **运维窗**新「配置托底」组:`cfg.list` 目录+每秘面键「已存/未存」徽标
  (永不回显值)+ `cfg.delete` 单键/清 ns(二步确认,沿 pending_confirm 手法);
  三口全部走 ask,界面不直调库(安全视图执行点在 cfg 实例);
- **CTP 表单**九期口径不动:密码等敏感格 `password(true)` 掩码不回显,
  留空 = 用已存;boot 自动连成功时用户无需输入;
- **状态列**接网关 `credential_state` 分项(只呈「已存/未存/待手配」,
  ek-ctp 0.10.0 供给);
- 界面自身**无** `Configurable`(宿主零配置口径延续)。

## BD-UI-30(0.1.1 薄下单者,OMS 中枢链,2026-10-19)

- **裁定**:下单/撤单直发 `{gw}.send_order`/`{gw}.cancel_order` 的旁路口
  **删除**——UI 是薄下单者,管制面收敛到 oms 两服务:
  `UiCmd::SendOrder{req, gateway, mode}` → `oms.submit_order`(五段管线:
  入口校验/拆单槽位/风控槽位/发送段/记账段;parent 聚合 SENT|PARTIAL|REJECTED
  逐腿显形进日志);`UiCmd::CancelOrder{orderid, gateway}` → `oms.cancel_order`
  (守卫 4000 不可绕过;symbol/exchange 不再随令携带,oms 订单缓存解;
  全撤 = 界面侧逐个扇出到同一守卫口)。BD-UI-23/25 的「ask sim.*/ctp.*」
  口径自此被取代(历史记录保留)。
- **「自动开平」勾选(OrderForm.auto_split,默认开)**:拆单四态的 UI 映射 =
  Auto(勾选)/None(取消,原样旁路);风控与守卫不可旁,拆单可逐请求旁
  (DESIGN ek-oms §6.2 口径的 UI 面)。s22 钉勾选双向 → mode 逐位。
- **装配口径统一**:ui_kinds 复用 station 0.1.7 `default_kinds`——oms 已装配
  ek-risk 三闸(standard_gates 缺省限额),面板与 headless 同一台 oms。
- **fixture 退役**:ui_converter_chain 从「offset 夹具进链」升格真管线链
  (拆单器入驻 oms 管线,夹具失去存在意义;dev-dep ek-converter 删除)。
  顺带实锤忠实初始化口径:无成交史的首轮 `ePositionDone.` 只记时刻等下一轮
  (原稿 1268-1288/1282-1285),第二轮距首轮 ≥3s 才置位——链测补第二轮。
- **实连钉**:ui_ctp_live 第 13 步(有仓则 CLOSE 经管线 = CLOSETODAY 实连
  证据;7×24 快照市无成交 = OBSERVE 如实记档)。13 步 110s 全收场。
- **依赖**:ek-oms 0.2.1→0.3.1、ek-station 0.1.5→0.1.7(强制 bump,防同构建
  两份 oms)。

## BD-UI-31(0.2.0 界面体验轮,2026-10-19;用户四点裁定)

- **R1 菜单一键直达**:「运维/网关/查询合约」menu_button→下拉→入口钮的
  两层结构废除,菜单钮即入口,单击直接弹窗(「帮助」保留菜单——多行内容)。
  破坏性:三个入口钮 pub 常量删除(标签 = 菜单名)。
- **R2 成交表方向上色**:方向列空绿/多红,补齐与委托/持仓表的一致口径
  (此前成交表方向为裸文本 = 遗漏,用户实看指出)。
- **R3 表格行选中 + 悬停跳色**(`UiShared::row_selected/select_row` 新口 +
  `paint_row_bg` 渲染工具):委托/未成交/成交/持仓/查询合约窗行首格单击 =
  选中行(蓝调半透明跳底,每表一行,换行即换选);行情带选中行 = 下单面板
  当前合约(带入即跳色,不另存态);全表悬停 = 淡灰底。行首格既有双击动作
  (撤单/带入/K线)全保留——egui 双击先经 clicked,选中顺带发生,无冲突。
  叠底为半透明(行文字先画,叠后仍可读)。
- **R4 标题文字撤除**:「行情带(唯一源…)」「下单面板」「盘口(合约)」三处
  题字整行删(带位/侧栏即身份;Python TickMonitor/TradingWidget 无题同口径;
  盘口括号合约代码一并删——选中合约由下单面板代码格与行情带跳色行指认)。
- **不动面(用户裁定)**:K 线窗标题保留合约;运维窗维持浮动弹窗形态
  (O1 内嵌页方案否决);「帮助」菜单保留。
- **钉**:s23 行选中(委托换选 + 成交独立槽);s17 改单击直达;快照重基线。
- **0.2.1 续(动作归行,用户裁定)**:行内任何一格单击/双击都触发该行动作
  (`click_cell` 标签矩形叠感应,外观不变);行情带盘口四列带方向
  (`import_tick`:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
  方向/价格保持默认)。r8 钉逐列口径。

## BD-UI-32(0.2.2 字号与独立窗轮,2026-10-19;用户三点裁定)

- **R1 全局字号上调**(`apply_ui_style` 新口):body/button 16、small 14、
  heading 22、monospace 15(egui 缺省 13/12/18/12)——用户实看「整体字偏小」,
  对照 Python 版;render_body 顶部施加,真实 App 与 kittest 同源(快照重基线)。
- **R2/R3 查询合约窗 + K线窗独立 OS 窗**(`show_viewport_immediate`):
  原生最小化/最大化/关闭钮(此前 egui::Window 只有关闭钮,且被物理关在
  主窗矩形内不能拖出——用户对照 Python 独立窗形态指出);可拖到屏幕任意
  位置、独立任务栏条目。OS 关闭钮 = close_requested → 状态位翻负 → 父窗
  下一拍不再 show → eframe 销毁窗。Embedded 回退(后端不支持多窗:
  kittest/WEB)= 主窗内弹窗同一本体,测试断言面零改动。
- **不动面**:运维窗/CTP 窗维持主窗内弹窗(用户只点了这两个窗)。
- **选型**:immediate 而非 deferred——回调可捕获 `&UiShared`(UiShared 非
  Clone,deferred 需 Arc 化会动 render_body 签名 = 无谓破坏性);StationApp
  1s 节流重绘节奏下 immediate 的父子联动重绘代价可忽略。
- **0.2.3 勘误(关不掉根因,用户实看指出):** egui 销毁判据 = 子 viewport
  本拍未 used——0.2.2 调用点未按状态位守卫,每拍 show = 每拍 used = OS 窗
  永不销毁(X 翻位后又被 show 回来,等效 CancelClose),且窗在主窗一开就
  自己出现。修复 = show 整段包进 `contracts_window_open()` 守卫。测试面
  教训:Embedded 回退掩盖 Immediate 真窗面(21 绿 ≠ 真窗面正确),s24 注入
  recording renderer 补钉;复审纪律补条:新机制的每个调用点必须按状态位辞行。

## BD-UI-33(0.2.4 四窗一型独立化,2026-10-19;用户裁定)

- **运维窗 + CTP 网关窗也转独立 OS 窗**(「四窗皆可脱离主窗」):与查询合约/
  K线同形态同守卫;Embedded 回退维持主窗内弹窗(kittest 断言面与快照零改动)。
- **合约表吃满窗口:** 撤 max_height(260) 内嵌时代残留;动作行(计数 +
  订阅钮)前置,ScrollArea auto_shrink(false) 吃满剩余窗高——放大窗口 =
  表格变高(用户「放大窗口意义在哪里」的直接回应)。
- **`show_independent_window` 助手(四窗一型):** 守卫模式内建(0.2.3 教训
  固化成调用方纪律:位关不 show = GC 销毁);Embedded/Immediate 两面的
  "被关"统一以返回值出表,消除四份 viewport 样板重复。

## BD-UI-34(0.2.5 表头固定 + 主题 + K线最大化/ESC,2026-10-19;用户裁定)

- **表头固定(`pinned_table` 五表全上):** 官方 DIY 模式(scroll_area.rs:412
  注释「painting a sticky header on top of it」)——单 grid 实例(头行+体行)
  保列对齐,ScrollArea 之后以本拍捕获的表头格矩形重画表头带(背景盖行 +
  Label 重画;矩形已含滚动平移 → 横向跟随、纵向盖顶、零拍滞后;Label 无
  交互 sense → 点击穿透)。两轮机制实锤:①vertical 嵌 horizontal 高度塌缩
  (s3 底行钉 462<500 抓到);②同 id 双 grid 各从空白重建 State,save 交替
  request_repaint = 重绘环(kittest max_steps 爆)——两案皆弃,留 overlay 案。
- **主题四套(金融深色默认/egui深/egui浅/Qt灰白):** 菜单钮一键循环;
  Theme 枚举 + AtomicU8 运行期态(零配置口径);apply_ui_style_for 统一施加
  (主窗/独立子窗/kittest 三处同源)。语义色跨主题恒定。回答用户「颜色是
  默认还是自配」:此前 = egui 默认深色骨架 + 自配语义色(非全默认)。
- **K线窗:** maximized 打开即最大化(s25 钉 builder.maximized=Some(true));
  ESC 快捷关闭(body 返回 true → close_kline 同链;s25 钉 Embedded 面注入
  Event::Key;Immediate 面键按子窗焦点路由)。
- **行情带外层 ScrollArea 不可撤:** resizable 面板吃满前提(BD-UI-27),
  本轮实撤一次即 s3 底行钉实测塌到 462——已恢复并注释钉死。

## BD-UI-34 返修(0.2.6,2026-10-19;用户实看三报,全有机制实锤)

- **竖线(资金右):** 覆盖带 ui.put 视口外表头格 → 布局 min_rect 撑宽 →
  Frame 描边矩形撑到 x=1222(= 346.9 + 16 列总宽)。二分实锤:关覆盖带线
  消失;空内容时线跟内容宽走(1007)。修 = painter.text/rect_filled 纯绘制
  (零布局副作用)+ clip 裁越界。教训:覆盖/装饰一律走 painter,不得用布局
  调用(put/label)画视口外内容。
- **表头缝:** 带高须 = 表头格高 + item_spacing.y(3px),否则滚动行从间距带
  露出。
- **空表缩格:** 空态不得提前 return——pinned_table 照调(空 body),表头与
  吃满滚动区在场。s26 钉三表空态表头同在场。
- **group_frame_plain 使用纪律(0.2.6 钉死):** Frame::show 描边矩形按内容
  min_rect 收缩(实锤:label 内容时 gw 宽缩到 112)——只配 auto_shrink(false)
  填充物(pinned_table)使用;非表格格(资金/日志)必须走带填充 ScrollArea 的
  group_frame,否则 Frame 收缩、右边框落行中段。

## BD-UI-35(0.2.8,2026-10-19;用户实看三报,全机制实锤)

- 行情带状态行「tick N 格 / 最后更新」= 用户裁定撤除(考证:从未删过,
  0.2.0 撤的是题字)。s8 反向钉防回归。
- 表头缝硬根因 = clip_rect_margin(默认 3.0)外扩裁剪;覆盖带必须按
  inner_rect ± margin 起画。s27 当时用「滚动快照 + 像素扫描」核,0.3.2 起换成
  裁剪矩形 + 绘制指令(同一判据,不同取数)。
- 空态提示一律在表头之下(grid body 首格),不得再置于 pinned_table 之前。
- 列头单源纪律扩散到全部四张格表(const HEADERS):本轮自审又抓到两处
  空态分支凭记忆写错——手写第二套表头 = 必错模式,单源是唯一防线。
- 快照回读串行化(SNAP_LOCK):本机 wgpu 并发回读会随机成组失败,测试闸
  对此环境敏感;新增快照测试必须走 snap() 不得裸调 harness.snapshot。
  **0.3.2 作废**:快照与像素回读整条撤除,这把锁也随之删除(见文末「0.3.2 口径变更」)。

## BD-UI-36(0.2.9,2026-10-19;纸面网关迁出界面包)

- 用户裁定:纸面网关 = 网关,应与 ek-ctp 同级独立成包(Python 蓝图 PaperAccount
  即与 CTP/Xt/Qmt 同级独立 app;SimNow 登不上时切换的本地模拟流)。
  曾议落位:留 ek-ui(否决:界面包藏网关,无头测不到)/并入 ek-ctp(否决:污染
  纯 CTP 协议面)→ 裁定 = 独立 crate ek-paper。
- 语义零迁移实锤:旧 sim.rs 与新 matcher+plugin 非注释代码行 449↔449,
  Compare-Object 仅 4 处 Arc 写法面差异。
- 工厂表归一:sim 登记项上移 ek-station 0.1.8 default_kinds(无头/有头同一张表);
  ek-ui ui_kinds/pick_kinds 撤自建 sim 项防双份(注册名冲突 = add_plugin Duplicate)。
- 教训钉(注册表耦合顺序):改工厂表必须【先发布被依赖方】——station 0.1.8
  未发布前 ek-ui 测试 UnknownKind("sim")(本地改动对 registry 依赖不可见,
  这正是禁 path 耦合的代价与回报)。

## BD-UI-38(0.2.10,2026-10-19;纸面网关整体退场)

- 用户裁定(2026-10-19):纸面账户功能整体撤除,界面不再有 sim——
  **ctp/SimNow 两套前置(12 小时/7×24,选前置用 ek-ctp scripts/simnow-fronts.ps1
  -Probe)已满足当前需求**。BD-UI-37(sim 对外面对齐 CTP 的改造方案)作废,
  未执行半成品整堆丢弃(ek-paper 工作区回滚到 0.1.1 已发布态)。
- 退场涟漪(三包):ek-station 0.1.12 撤 ek-paper 依赖 + 工厂表 sim 项
  (pipeline_headless 改 dm/oms/ctp 装配 + CTP 回报行直发注入,账本/行情/因子
  钉数值零变);ek-ui 0.2.10 撤 ek-paper 依赖 + 下拉/兜底/钮/测试全清;
  ek-paper 0.1.x 档案原样留存(不删仓库不发版不 yank——yank 反咬已发布老版)。
- 界面口径:接口下拉 ∪ 集改 {ctp};parse_gateway 空兜底 sim→ctp;运维窗
  「流控查询」钮撤(UiCmd::QueryRate 变体同步撤——公共 API 减项);
  demo 窗 = dm/oms/ui(网关经运维窗激活);demo_tour 纸面段整段删(8 步重编)。
- 测试口径:ui_sim.rs 整删(9 钉);ui_converter_chain 网关位改测试本地
  "ctp" 受理夹具(受理形制忠实 ek-ctp:SUBMITTING 单上总线 + ok 回执 +
  `{gw}.` 全号;成交按 OnOrderTrade/OnRtnTrade 同构行直发)——本钉主体
  一直是 oms 五段管线,与网关撮合无涉,换顶替零减钉;kittest 夹具标签
  sim→ctp(五张快照重基线:s3/s10/s16/s21/s27,钮撤除 + 标签改名像素变);
  71→62 钉(减的 9 = ui_sim)。
- 纪律钉:历史条目(BD-UI-36、CHANGELOG 0.1.8/0.1.9 sim 入表等)是史实,
  不改写;退场只记新条目。

## BD-UI-39(多账号面:账户面板取代网关窗,规划 docs/ek-multi-account-plan.md)

> 本条取代的旧口径(史实保留不改写):BD-UI-24 的 `连接 CTP` → 硬编码
> `ctp.configure` / `ctp.subscribe`;BD-UI-27/33 的「CTP 网关配置窗」单账号窗体;
> BD-UI-38 的接口下拉 ∪ {ctp} 与 `parse_gateway` 空兜底 `"ctp"`。

- **菜单「网关」→「账户」**:旧单账号网关窗退役,收纳为账户面板的添加/编辑表单
  (9 输入位 + 密码掩码 + 收录勾选原样沿用,`ctp_grid` 未改字段)。旧窗的死面
  (`ctp_window` 状态位、`MENU_CTP`、`BTN_CTP_CONNECT`)一并删除,不留"供旧测试引用"。
- **账户清册在界面侧**(`UiShared.accounts`):行 = 派生注册名 + broker + 账号 +
  在场位 + 元账号标记 + 表单留档。派生名唯一入口 `ek_ctp::derived_gateway_name`
  (界面不自己拼字符串);密码只在内存,本轮不持久化(规划 §10.2)。
- **添加时序钉(规划 §5.A)**:一个钮两条 cmd —— `Start{factory:"ctp", name:<派生名>}`
  先装配,`CtpConfigure{gateway:<派生名>, form}` 后注入凭据(凭据不进 svctl.start 通道)。
- **单源纪律在"添加"这一刻定源(规划 §5.C)**:无元账号且无在场账户 → 新账户自动成为
  **行情元账号**(md 前置保留;清掉它等于这个账户自己也永远收不到 tick);已有元账号或
  已有在场账户 → 按纯 TD 装配(md 前置清空)。订阅两个入口(下单面板代码回车 /
  查询合约窗勾选)一律经 `subscribe_target` 路由到元账号,未指定就明确报错不静默发。
- **元账号连接后锁定(用户裁定「账户连接后不允许切换」)**:任何账户已装配即拒切换;
  换源流程 = 撤挂单 → 移除 → 改配置 → 重连 → 重放订阅(订阅集 `note_subscriptions`
  登记,元账号新装配后自动重放)。
- **移除守卫**:该账户有活跃委托 = 不给移除钮(只给"先撤单才能移除"提示);有持仓 =
  可移除但警告"柜台侧不受影响,重加后由轮转查询重建视图";两步确认沿用 `confirm_step`。
- **网关名回落只剩一个阶梯**:`default_gateway()` = 上次手动选择 → 首个已装配 → 首个;
  一个账户都没有 = 禁用「委托」钮并给提示(旧"空 → 字面量 ctp"兜底退役,B4)。
  撤单三处(行内钮 / 全撤 / 行双击)一律随该单自己的 `gateway_name`。
- **诚实标注钉**:账户表状态列写「已装配 / 未装配」不写「已连接」——界面只能从
  `svctl.list` 看见实例在场,登录成败 ek-ctp 无状态口可查(开放项)。
  **(0.2.19 已接上:`{gw}.status` 落地,状态列改两段口径「已装配·<登录态>」;
  「不写已连接」这条口径不变,见文末 0.2.19 节。)**
- **测试口径**:s13 重写(添加账户 → 派生名 + 两条 cmd + 首个账户自动定源 +
  第二账户 md 清空不被抢源)、s17 菜单改「账户」、s18/s20 订阅路由到元账号、
  新增 s28(元账号锁定 + 挂单移除守卫 + 守卫解除);五张快照重基线
  (s3/s10/s16/s21/s27,菜单标签与 tick 列名「接口」→「行情源」像素变)。

## 0.2.19(接上网关状态口 + 『断开』钮;规划 docs/ek-ui-account-tabs-plan.md P3,2026-10-22)

事实源换成了 ek-ctp 0.9.0 的两个服务口(`{gw}.status` / `{gw}.disconnect`)。界面侧的六条口径:

- **D-UI-STATUS-1 状态列两段,不合并**:装配位(`svctl.list` 在场)与登录态(`{gw}.status`
  读数)是两件事,拼在一栏显示(`已装配·在线` / `·掉线` / `·登录失败` / `·查询断供` /
  `·已断开` / `·登录态未知`)但**不合成一个布尔**。0.2.18 的「已装配·登录态未知」保留为
  读数缺失时的显式档位 —— 缺口从「不可见」变成「本轮还没问到」,两者必须长得不一样。
- **D-UI-STATUS-2 只认 `logged_in`,不认 `handle_up`**:网关的「句柄已建 / registerFront 已发」
  在掉线时仍是真(ek-ctp DESIGN D-STATUS ①)。界面照抄它 = 替掉线的账户谎称在线(s48 读数二钉)。
  同理 **"从未回报过"不当"0 秒前回报过"用**:`td_last_account_ms = None` 是"没有这件事",
  不是"很久没做"。
- **D-UI-STATUS-3 阈值住在界面**:「查询断供」= 连续被拒达 `QUERY_REJECT_STREAK_LIMIT`(3)
  **或** 资金回报超 `ACCOUNT_STALE_SECS`(30 秒)。网关只给原料(计数与时刻),"几次/多久算断供"
  是界面口径 —— 它才知道自己多久刷一次、多久问一次。覆盖位 `set_status_thresholds` 已开,
  配置文件面还没定(规划 Q10 只说"进配置",ek-ui 现在零配置)。
- **D-UI-STATUS-4 轮询只问"已装配且在场"的行**:每 `STATUS_POLL_TICKS = 2` 拍问一次。
  实例被摘除后清册行还在,照问不误就是每次等满 2 秒超时、按账户把 eTimer 刷新循环拖住(c5 钉)。
  状态口在 ek-ctp 侧只读、不写日志,所以这条轮询不吃 eLog 环(512 行环保护不变)。
  **问不到就退回「未知」并写明原因**,上一轮的「在线」不得留档(s50)。
- **D-UI-DISCONNECT-1 断开 = 停会话,移除 = 停实例,两条口不共用**(规划裁定 1):
  『断开』发 `UiCmd::Disconnect` → `{gw}.disconnect`;『移除』发 `UiCmd::Remove` → `svctl.remove`。
  断开后装配态不变、徽标「已断开」、恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」。
  **同一道活跃委托守卫两者共用**:会话一停就撤不了单也不再回报,与『移除』要挡的是同一个
  观测黑洞(规划 §5.B),出口也同一个(『先撤 N 笔』)—— 这是执行自查补上的一条(s54)。
- **D-UI-STATUS-5 实例不在场即作废读数**:`sync_accounts_connected` 把行从「已装配」打回
  「未装配」时,登录态与细节一并清零;渲染侧只给已装配的行印状态细节(s55)。
  留着就是替一个已经不存在的会话说话。
- **纯行情账户看 MD 半边**:判据是"该行 td 前置留档是否为空"。TD 半边对它是全假的,
  照抄只会得到「未知」——那是假信息(s53)。
- **测试口径**:kittest 45 → 53 钉(s48 三档读数与旧读数不留档、s49 断供两判据与阈值覆盖、
  s50 问不到退回、s51 『断开』入列与徽标翻转、s52 未装配无『断开』钮、s53 纯行情看 MD、
  s54 断开共用活跃委托守卫、s55 摘除即作废读数);s37 口径翻转;总线级新增 c4/c5
  (真起一个 ek-ctp 实例:服务目录含 `.status`/`.disconnect`、ask 真拿到 `GatewayStatus`
  —— 读数里没有「问不到」就是两份 ek-ctp 没静默断的证据);s39/s40/s41 快照重基线。

## 0.2.20(三条开放口径落进界面 + 接 ek-ctp 0.9.1 分项;2026-10-22)

- **D-UI-F4-SCOPE 勾选的作用范围写死成一句话,两处同源**:勾选只管**这个网关自己**收录并上抛的
  合约;公用合约表 = 在场各网关的并集(规划 D3「合约与行情公用」的直接推论);改勾选须
  『连接/重试』才下发。为什么必须写在界面上:实连 S14b 里账户 B 改勾选「期货+期权」后公用表
  仍 852 条、期权 0 条,而界面没有任何地方区分「没生效」与「网关没查到」—— 让用户猜就是
  把口径缺陷转嫁给用户。**同一口径不得只写在一处**(面板底部 + 配置弹窗,s57 数到 2 处)。
- **D-UI-Q17 运维窗对账户清册实例只列不操作**:名字在清册里 → 那一格改写成「起停归『账户』面板
  (带撤单守卫)」,不给『移除』也不给『重注入』(后者对命名实例本来就是 unknown factory)。
  理由不是"整洁",是**守卫可绕过**:账户面板的『移除』带活跃委托闸 + 二次确认 + 持仓提示,
  运维窗那条是同一动作的无守卫副本,两条路并存等于守卫形同虚设。**清册外的 `ctp_*` 实例仍留
  这条出口**(手工起的 / 旧流程残留没有别的退路)—— 边界收口不等于把门焊死。实例仍列在表里:
  砍的是操作,不是运维可见性(s56)。
- **D-UI-LOG-WINDOW 日志窗自报是窗口不是历史**:溢出计数原来只有数字;现在同一行接上去处
  (「登录/掉线/查询被拒看状态列与状态细节,别回扫这里」)。依据是巡回 G1 的实连证据 ——
  90 秒没抓到「交易服务器登录成功」行而资金行下一拍就刷新,**登录其实成功,是 512 行环被
  状态推送流挤满**。取证的事实源自此是网关状态口(0.2.19 起),不是日志回扫(s59)。
- **D-UI-STATUS-6 分项点名但不另开一行**:ek-ctp 0.9.1 的 `td_instrument_rejects` 接进状态细节
  句尾(「查询连续被拒 3 次;合约查询被拒 2 次」),同一件事不重复点名,分项为 0 不加句子。
  总数与连续串**不分路**是对的(消费侧要的是「查询这条路连续被拒几次」),但合约一条没到手时
  下一笔单会被 ContractGate 以 `contract:unknown` 拒掉 —— 状态列只写「在线」就是假信息(s58)。
- **测试口径**:kittest 53 → 57 钉(s56 运维窗收口且工厂插件照旧、s57 口径两处同源、
  s58 分项三段、s59 日志窗口径);s39/s40/s41 快照重基线(面板底部多一行口径句);
  `.gitignore` 补 `**/snapshots/**/*.old.png`。

## 0.2.21(第三条断供判据:「从未到手」也要超时;2026-10-22,依赖不变)

- **D-UI-STATUS-7 「从未发生」不能只是"没有这句话",它自己也要有超时位**:0.2.19 立「`None` 不当 0 用」
  时,只把它用在"距最后一次回报多久"这一判据的**前置条件**上(`Some` 才谈超时)。实连 S15f 第一轮
  读数照出洞:F2 账户 `logged_in=true`、`td_query_rejects=0`、`td_last_account_ms=None` 持续 390 秒
  不变 —— 于是界面永远是「已装配·在线 + 资金回报尚未到手」。**徽标是绿的,查询却彻底停摆**,
  这就是 F2 在界面上的形状。
- **修法与边界**:登录着的分支第三条判据 = 距 `td_last_login_ok_ms` 超过同一个 `ACCOUNT_STALE_SECS`
  且资金时刻仍 `None` → `已装配·查询断供` + 「资金回报从未到手(登录已 Ns)」。刚登录几秒不算
  (轮转第一拍本就要 2~6 秒);真到手即回在线。**不新建第二个阈值**:"多久算等太久"在两条判据里
  必须是同一个数,否则同一件事有两种松紧。
- **为什么修在 ek-ui 而不是 ek-ctp**:判据原料(登录时刻 + 读数时刻)网关早就给了,缺的是消费侧
  怎么用 `None`。网关侧加"资金超时"位就是把界面口径往上游搬(D-UI-STATUS-3 的反面)。
- **测试口径**:kittest 57 → 58 钉(`s60` 三段:5 秒 = 在线带话 / 40 秒从未到手 = 断供带时长 / 到手即回在线)。

## BD-UI-40(0.2.26,2026-10-22;第五套主题「Qt深色」= Python 版黑色主题复刻)

- **依据是读出来的,不是印象**:Python 版 `enkeep_trade/enkeep_trade/trader/ui/qt.py:102`
  `qapp.setStyleSheet(qdarkstyle.load_stylesheet(qt_api="pyside6"))`;本机已装
  **QDarkStyle 3.2.3**(`.venv/Lib/site-packages/qdarkstyle`)。色板取自 `dark/palette.py` +
  `colorsystem.py` 原值,规格取自编译产物 `dark/darkstyle.qss`(2216 行实测:
  `border-radius: 4px` 出现 48 次且是全主题唯一圆角值;描边一律 1px;padding 以 2px 为主)。
- **主题从四套变五套**,菜单钮一键循环加一站:`Qt灰白 → Qt深色 → 金融深色`。
  **缺省仍是金融深色** —— 加主题不换缺省,现有 8 张快照一张没动(git 里只有 s42 是新增)。
- **`Theme as u8` 是 `set_theme`/`theme()` 之间的存储形式**,新变体必须留在枚举末尾、
  且 `theme()` 的 match 必须加 `4 =>` 臂。**漏臂是这条链上最阴的一种错**:编译器不报、
  `set_theme` 照存、读回来落进 `_ =>` 解回金融深色 —— 钮上文案换了而画面不换。挡它的是
  `r9c`(五个判别值逐个钉死 + 每个定点值往返一遍)。
- **映射口径(逐条对过 qss)**:
  - `window_fill = panel_fill = extreme_bg_color = #19232D`(Gray.B10)。qdarkstyle 所有容器
    同底、靠 1px `#455364` 描边区分 —— 三个字段同值不是偷懒,是照抄。
  - 按钮三态 = `#455364 → hover #54687A → pressed/checked #60798B`,且 qss 里
    `QPushButton { border: none }` → `bg_stroke` 与 `bg_fill` 取同色(画出来就是无边)。
  - `expansion` 一律 0.0:qss 的 hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
  - `fg_stroke` 宽度一律 1.0:Qt 不在 hover/pressed 加重文字,egui 缺省 1.5/2.0。
  - `selection.bg_fill = #346792`(Blue.B40):qss 里 `QWidget::item:selected` 与
    `QLineEdit:selected` 同值。**`selection.stroke.color` 被 egui 当选中项文字色用**
    (style.rs:374)→ 取 TEXT1 `#DFE1E2`;照抄 qss 的 focus 边框 `#1A72BB` 会让选中 tab 文字变蓝。
  - `weak_text_color = Some(#ACB1B6)`(Gray.B90)、`hyperlink_color = #259AE9`(Blue.B70)、
    `faint_bg_color = #293544`(Gray.B20)、圆角全态 `CornerRadius::same(4)`。
  - `interact_cursor = Some(PointingHand)`:Qt 按钮是手型光标,egui 缺省是浏览器口径。
- **已知偏离(做不到 / 决定不做,逐条给理由)**:
  1. **选中 tab 那条 3px `#259AE9` 下划线**(qdarkstyle 的签名样式)—— egui `Visuals` 没有这个
     概念,`selectable_label` 只能整块填充。做它要改 `render.rs` 里 8 处手绘,不在本轮范围。
     本轮取「整块强调色」代替「灰蓝底 + 亮蓝下划线」。
  2. **字号不动**:Python 版是全局 `QFont.pointSize(11)`(96dpi ≈14.7px),我们 body 16px。
     字号在 `apply_ui_style_for` 里是跨主题的全局口径、不是主题属性,改它牵动其余四套与 8 张快照。
  3. **禁用文本色 `#788D9C` 做不到**:egui 只调 `disabled_alpha`,不能指定具体灰蓝 → 保持 0.5
     (那档亮度约 0.55,视觉近似)。
  4. **菜单底色 `#37414F` 做不到**:egui 0.33 的 menu/popup 背景跟 `window_fill`,无独立字段。
  5. **不移植图标资源**(qss 的 `rc/*.png` 箭头、树形 branch、窗口关闭钮、14×14 复选框图标)
     —— egui 自绘,只映射颜色不映射形状。
  6. QGroupBox 的 1px 描边 + 粗体标题、表格 gridline:egui 无对应物,靠
     `noninteractive.bg_stroke = #455364` 近似。
- **有意不设的字段(防「多余」)**:`text_edit_bg_color`(缺省跟随 `extreme_bg_color`,同值设两遍
  是重复)、`code_bg_color`(本 UI 的 `ui.monospace` 只换字体不带背景)、`warn/error_fg_color`
  (语义色在渲染侧直绘、跨主题恒定 —— D-UI-34 那条口径不变)、slider/progress 相关(本 UI
  0 处 slider、0 处进度条)。
- **测试口径**:ui_state 13 → 16 钉(`r9` 循环改五站 + `r9b` 色值钉 + `r9c` 编码钉 +
  `r9d` 反证钉),kittest 58 → 60 钉(`s42_qdark_theme_full_panel` 快照 + `s61` **真实手势**钉:
  不碰 `set_theme`,只点菜单钮,连点四下从缺省走到 Qt深色,每档都判「`shared.theme()` 换到位 +
  钮文案跟着换 + 画面底色真的换」,再点一下判环闭合回缺省;`s42` 夹具与 `s21` 同源 ——
  同一副内容换主题,快照差异只剩主题本身)。全绿 = 104 钉。
- **快照不只用眼睛看**:`target/theme-pixels.ps1` 把 s42 的 110 万像素数成直方图 ——
  面板底 `#19232D` 占 **93.17%**、`#455364` 2.09%、`#293544` 2.17%、正文 `#DFE1E2` 2125 px、
  选中块 `#346792` 748 px;反证两条同样硬:egui 原生深色底 `#1B1B1B` **0 px**、
  金融深色底 `#18202C` **0 px**(出现即说明主题没切过去)。`#54687A`/`#60798B`/`#259AE9`
  在静态快照里 0 px 是**预期**(静态图里没有 hover/pressed 控件、界面里没有超链接),
  那三支只在 `r9b` 的 Visuals 层钉住。
- **实连巡回取证(真窗口,不是 kittest)**:`examples/opening_multi_tour.rs` 加了一个
  `EK_TOUR_THEME` 开关(`qdark | finance | egui-dark | egui-light | qt-classic`,缺省不动;
  `examples/` 不在 `cargo package` 清单里 → 不进发布包、不动 crate API)。2026-10-21 17:3x
  用 7×24 前置(`EK_CTP_TD/MD` 指到 `EK_CTP_724_TD/MD`)跑一趟 `EK_TOUR_THEME=qdark`:
  控制台首行「【开盘演示】主题 = Qt深色」;元账号 `ctp_9999_278153` 登录 = true、纯 TD
  `ctp_9999_220380` 登录 = false(该账号在 7×24 前置未注册 —— 环境侧,与本轮改动无关);
  交易状态推送流 129 行。窗口截图 `target/gui-qdark.png`(1416×939)同样过像素直方图:
  `#19232D` 占 **83.96%**(余下主要是 OS 标题栏与边框)、`#293544` 3.65%、`#455364` 1.82%、
  正文 `#DFE1E2` 2111 px、选中 tab `#346792` 707 px,`#1B1B1B` 与 `#18202C` 仍是 0。
  截图里菜单钮读作「主题:Qt深色」,委托/成交两表是柜台真数据(6 笔委托 / 2 笔成交),
  语义色没漂(LONG 红 / SHORT 绿)。

## BD-UI-41(0.2.27,2026-10-22;对照 Python 版逐项核出来的界面面:表头带 / 网格线 / 日志表格化 / 页签头与下划线)

起因是用户实看 ek-ui 真窗口后点出的五处,并要求「去 Python 版本再看,逐个对比,肯定还有别的细节」。
核对方法:两边都读代码,不凭印象 —— Python 侧读 `enkeep_trade/enkeep_trade/trader/ui/mainwindow.py`
(布局)+ `widget.py`(表格与监控控件)+ 已装 qdarkstyle 3.2.3 的 `dark/darkstyle.qss`(2216 行,样式)。

### 逐条核对结果(12 条,两边都有行号)

| # | 项目 | Python 侧事实(行号) | 我们改前 | 处置 |
|---|---|---|---|---|
| 1 | 表头带底色 | qss:1946-1970 `QHeaderView::section { background #455364; color #DFE1E2; font-size 13px }` | 覆盖带填 `panel_fill` = 表体同色 → 等于没画 | **补**(本轮核心) |
| 2 | 表格外框 | qss:514-519 / 1875-1884 `border: 1px solid #455364` | 已有(`Frame::group` 描边 = `bg_stroke` `#455364`) | 不动 ✓ |
| 3 | 网格线 | 交易表 `widget.py:367 setShowGrid(False)`(注释「隐藏网格线」);**只有**合约表 `2423` / `2621` 没关 | 全无网格 | **只给合约表补** |
| 4 | 斑马纹 | `widget.py:359 setAlternatingRowColors(True)`,但 qss:515/1879 `alternate-background-color` = `#19232D` = 表体底 → 看不见 | 我们 `striped(true)` 硬开,奇行 `#293544`(实连截图里占 10.72%) | **收掉**,改跟 `visuals.striped` |
| 5 | 日志 | `LogMonitor`(`widget.py:1167-1180`)= 带「时间/信息/接口」表头的 QTableWidget,跟 Dock 伸展 | monospace 行 + `max_height(180.0)` 硬顶 | **换成 `pinned_table`** |
| 6 | 每格都有 tab 头 | 四个区全是 QTabWidget(`mainwindow.py:106/126/168/184`),单 tab 的「成交」「持仓」也有头 | 成交/持仓只是 `ui.label` | **补单 tab** |
| 7 | 选中 tab 下划线 | qss:1592 `:selected { background #54687A; border-bottom: 3px solid #259AE9 }` | 无(0.2.26 记为「做不到」) | **补** —— 那条已知偏离作废 |
| 8 | 未选中 tab 头底 | qss:1579 `QTabBar::tab:top { background #455364 }` + 1559 `!selected { border-bottom 2px }` | 无 | **补** |
| 9 | 服务目录表头 | 合约/账户类表都有 `setHorizontalHeaderLabels` | `svc_grid` 六列纯数据无头 | **补**(用户点名) |
| 10 | 表头字号方向 | 表头 13px < 正文 ≈14.7px(`QFont pointSize 11`,`qt.py:106-107`) | 表头 = 正文 = 16px(方向反了) | **表头改 Small 14** |
| 11 | 单元格 padding | `widget.py:369 item { padding: 4px 8px }` | `item_spacing (8,4)` | 相近,**不动** |
| 12 | 列集合 | 委托 16 列 / 行情按所分表 / 成交 12 列 | 委托 17(多「操作」)、行情 14 用 tab 分所 + 「今开」、成交号放第一列 | **有意不动**(功能差异,不是漏) |

### 落法与理由

- **`Chrome` 取色层**:qdarkstyle 有「表头带」和「选中 tab 下划线」两个概念,egui `Visuals`
  里没有对应字段(它只有 `selection.bg_fill` 一根)。所以这层只能在渲染侧直绘。
  取色**不新造颜色**,全部从各主题已声明的字段派生:`line = noninteractive.bg_stroke.color`、
  `header_text = noninteractive.fg_stroke.color`、`tab_underline = hyperlink_color`、
  `tab_fill`(Qt深色)= `hovered.bg_fill`。Qt深色那一列取出来正是 qss 原值
  (`#455364` / `#DFE1E2` / `#54687A` / `#259AE9`),其余四套取自己的色板 —— 五套一起改,
  因为只改 Qt深色等于没改用户日常看的那一套。
- **为什么文字必须重画**:epaint 0.33 没有 z-order(全 crate 搜过:无 `ZOrder` / `z_order` /
  `Shape::bg_fill`),形状按插入顺序画 → 后画的带底会盖住先画的表头文字。所以
  「盖底 → 画线 → 重画文字」是固定套路,`pinned_table` 覆盖带、`header_band`、`tab_label` 三处同一个理。
- **`header_band` 而不是 `pinned_table`**:账户清单 / 运维插件表 / 服务目录在**自动尺寸浮动窗**里。
  0.2.17 实锤(s39):`pinned_table` = `auto_shrink(false)` 吃满整块可用高 + 覆盖带,
  在这种窗里凑不出高度 → 表行与其后的口径句互相压字。这三张表行数个位数,不需要固定表头滚动面,
  只要那根带。
- **网格线的画法**:`pinned_table_grid` 从 grid 实测的**列边界矩形**(表头格)推列线 x
  (`h.max.x + item_spacing.x * 0.5`),从内容矩形 + 行数等分推行线 y。前提:每格都是 label
  → 行高一致(合约表正是如此)。所以网格线只给合约表,交易表不给(那边关了网格)。
- **日志时间戳口径**:`LogRing::push` 入环打 `chrono::Utc::now().with_timezone(+08)` 的
  `%H:%M:%S`,与 `fmt_dt` 同一口径 —— `beijing_tz` 因此从 render 提到 state,一份实现两处用。
  本机不是 +08 时口径错 = 日志与成交表差几小时,交易员对不上账(钉:
  `log_entries_stamp_beijing_wall_clock`)。
- **`snapshot()` 门面不动**:`LogRing` 内部从 `VecDeque<String>` 换成 `VecDeque<LogEntry>`,
  但 `snapshot()` 仍返回正文串 —— 30 处既有调用点(测试 / 巡回取证 / demo)一行没改。
  渲染侧另开 `entries()` / `logs_entries()`。两个口各有真实消费者,不是重复。

### 已知偏离(记死,不藏)

1. **日志只有两列**(时间/信息),Python 有第三列「接口」:我们的 `log_line` 调用点不带网关,
   正文自带 `[ops]` / `[订阅]` 这类前缀。硬造一列空列不如不造。
2. **日志不自动跟到最后一行**(`stick_to_bottom` 撤除):Python 的 QTableWidget 也不自动滚到底。
3. **斑马纹**:Python 的意图是开(`setAlternatingRowColors(True)`),但 qss 让它不可见。
   我们跟「不可见」这一结果。**这一条我先前给过相反判断**(一度主张保留斑马帮读 17 列宽表),
   最终按「与 Python 一致」定;若日后要开,应开在主题层(`visuals.striped`)而不是表里硬开。
4. **字号**:全局 Body 16 / Small 14 vs Python 全局 `pointSize 11` ≈14.7px。字号是跨主题全局口径,
   本轮只把「表头比正文小」这个**方向**修对,没有动全局字号。
5. **列集合**(表 12 行):功能差异不是漏。

### 审计(遗漏 / 错误 / 重复 / 多余 / 逻辑)

- **改了什么、删了什么,逐条可查**:删除的只有 `group_frame`(四格全走 `group_frame_plain` 后无使用者)
  和 render 侧那份 `beijing_tz`(搬到 state,render 改 import)。**没有删任何功能面**:
  日志钮、溢出提示句、清空钮、`snapshot()` 门面、`log_grid` 之外的所有表容器都在。
- **发现并修掉的自己的错**(都是本轮内自查抓到,不是门禁抓的):
  ① 合约表两处调用点我先把**空态分支**换成了 `pinned_table_grid(…, rows.len(), …)`(空表画网格),
  下一眼读回时看到并改回;② `Chrome` 字段用了裸 `Color32` —— `use egui::Color32` 是
  `theme_visuals` 函数内的局部 import,结构体在函数外看不见,编译报 E0425;
  ③ `Painter::line` 在 0.33 收 `Vec<Pos2>` 不收元组 → 改 `vline` / `hline`;
  ④ `Grid::show` 返回 `InnerResponse`,`gr.rect` 不存在 → `gr.response.rect`;
  ⑤ 新测试里「时间」「服务」「实例」都不是唯一标签(行情带也有「时间」列、运维窗也有「服务」标题、
  服务目录有三对「实例」头)→ 改 `query_all_by_label` 数个数,顺带把断言变强。
- **重复**:一度想给日志表加「时间列 + 正文列 + 接口列」三列,与「调用点不带网关」冲突 → 砍成两列。
  `snap` / `snap_budget` 两个快照入口是**必要**的两条:一条 0 容差,一条带墙钟预算,共用同一把锁。
- **多余**:`group_frame` 删掉;`Chrome` 里一度准备放 `tab_head_stroke` 与 `tab_underline_width`
  两个字段,前者与 `line` 同值、后者只有 2/3 两个常量 → 都不进结构体,写在使用处。
- **逻辑问题(本轮最容易再犯的一种)**:带墙钟时间列的快照天然不确定。第一反应是「调容差」,
  真正的处理是把**结构面**钉在 AccessKit 标签上(确定性),把**像素图**降级成带预算的辅助证据,
  并在 `snap_budget` 的注释里写清为什么这三张要预算。
- **没动的**(有意):`snapshot_lock()` 旧调用形式(9 处)只在新钉里换成 `snap_guard` ——
  改全部旧站点属于另一轮清理,本轮不夹带。

### 证据

- 门禁:`scripts/gate.ps1` 全绿(fmt / test 111 钉 / clippy `-D warnings` 0 / doc 0 / check-package strict)。
- 快照:9 张重生成 + 新增 `s63_logs_table_headers`、`s65_ops_service_headers`;
  肉眼核过 `s42`(Qt深色)与 `s21`(金融深色):六张表都有表头带,委托/未成交、资金/日志、
  行情带交易所、成交、持仓五处都有 tab 头,选中 tab 有下划线,画面无斑马。
- 像素直方图(`target/theme-pixels.ps1`,本轮加两条硬检查):`#259AE9 ≥ 1`(选中 tab 下划线,
  改前 0)与 `#54687A ≥ 200`(选中 tab 底,改前只在 hover 时出现 = 0)。
- 真窗口:`EK_TOUR_THEME=qdark` 实连 7×24 前置一趟,截图过同一套像素检查(见本轮报告)。

## BD-UI-42(0.2.28,2026-10-22;表头带与网格线的几何根因:`Response::rect` 是控件矩形,不是列框)

用户实看:「运维那个表格表头还是有问题的,表头背景没有按这个表格整行来,最后一列感觉好像只有
文字有背景,其他没有文字,导致好像断开一样」+「资金账户那个表格怎么又有问题了,怎么老是有不同的
表格有各种问题」。这次不逐张表打补丁,而是把「带和线按什么算宽度」这一条查到底。

### 十张表全核一遍(先列清楚有哪十张)

| 表 | 容器 | 带从哪来 | 本轮结论 |
|---|---|---|---|
| 行情带 `tick_grid` | `pinned_table` | ScrollArea `inner_rect` ± `clip_rect_margin` | 本来就对 ✓ |
| 委托 `order_grid` / 未成交 `active_grid` | `pinned_table` | 同上 | 对 ✓ |
| 成交 `trade_grid` | `pinned_table` | 同上 | 对 ✓ |
| 持仓 `pos_grid` | `pinned_table` | 同上 | 对 ✓ |
| 日志 `log_grid` | `pinned_table` | 同上 | 对 ✓ |
| 查询合约 `contract_grid` | `pinned_table_grid` | 同上 | 带对;**列线错** → 修 |
| 资金格 `acct_grid` | `header_band` | 表头文字矩形并集 | **错** → 修 |
| 运维插件表 `plug_grid` | `header_band` | 同上 | **错**(用户点名的那张)→ 修 |
| 服务目录 `svc_grid` | `header_band` | 同上 | **错** → 修 |
| 账户清单 `account_grid` | `header_band` | 同上 | **错** → 修 |

(下单面板 `order_form_grid` 是表单不是表,无表头行,不在这条上。)

### 根因只有一条

`egui::Response::rect` 是**控件自己的**矩形,不是列框。Grid 的路径:
`GridLayout::next_cell(cursor, child_size)` 给出列框 = `child_size.max(prev_col_width, prev_row_height)`
(grid.rs:180-185),随后 `align_size_within_rect` 按 `LEFT_CENTER` 对齐,**返回子控件尺寸**
(grid.rs:188-193)。所以一个表头 label 的 `rect.max.x` = 表头文字的右端,不是列的右边界。

0.2.27 我在列线那行写的注释是「取表头格的列边界(列宽由 grid 量出来,唯一权威源)」——
**这句话是错的**,而错的地方正是新代码唯一依赖的那个量。本轮更正注释也更正算法。

两个后果:

1. `header_band` 的带 = 表头文字矩形的并集 → 带只到最后一列表头文字的右端。运维窗『操作』列
   宽 = 两个按钮(~200px),表头「操作」只有 ~40px → 列里 160px 没有带;服务目录最后一列
   「实例」为空时同理。用户看到的「最后一列只有文字有背景 / 断开一截」就是这个。
2. `pinned_table_grid` 的列线 = 本列表头文字右边 + 半间距 → 表头比数据窄时(「名称」2 字 vs
   「螺纹钢2605」)列线画在列里面,不是列之间。

### 两条改法

- **带铺整行**:`header_band` 的横向范围改 `ui.max_rect()`(这一格 / 这一窗的可用宽)。
  与 `pinned_table` 那条带同一口径(那条铺 ScrollArea `inner_rect` = 整格宽),也与 Python 一致
  (`QHeaderView` 铺满整个表视口,最后一节 stretch)。
- **列线取下一列的左边界**:每列的格都左对齐在列起点,所以「下一列表头/数据格的 `min.x`」
  就是列边界,往回让半个 `item_spacing.x` 即列线。这个量不依赖表头文字宽度。

### 新钉与「判据到底抓不抓得住」

- `s69_ops_window_header_band_covers_the_row`:像素判据 = 带的最宽连续段 ≥ 体表最宽一行的
  文字跨度。两个坑都踩过并写进注释:① 带里有表头文字把连续段打断,要取带**底**几行;
  ② 格子的外框线也是带色(1px 横跨整格),单看最长段会把框线当成带(s70 第一版就是这么
  「通过」的)→ 加「带必须有厚度」(上下各 3 行的带色像素数不低于本行一半)。
- **判据有效性是实测过的**:把 `header_band` 换回旧算法重跑 → s69 直接红,带 **375 px**
  (修后 720 px)。不是写完就信的钉。
- **诚实的一条**:s70 的「带 ≥ 体表」断言在夹具里新旧都过 —— 资金表只有五列、列宽由数据
  撑开、内容几乎铺满整格,夹具里带和体表本来就差不多宽。用户在真窗口看到的资金带短一截
  (最大化时带 336 px / 整格 450 px)由 s70 的**快照**抓(旧算法下差 627 px)。
  教训:「带必须盖住体表」和「带必须铺满整格」是两条不同的判据,夹具只抓得住前者里
  内容真的比格子窄的那些表。
- `s71_contract_table_gridlines_land_on_column_bounds`:合约表是全仓唯一开网格线的表,
  原来没有任何带真行的快照核它。补一张(三行真数据,含「精对苯二甲酸709」这种名字明显
  比表头宽的列),肉眼核过列线落在列与列之间。

### 顺带修的一处潜在随机红

wgpu 后端钉(Windows 缺省 Vulkan,避开本机那张 "GameViewer Virtual Display Adapter" 的坏回读)
原来埋在 `snap()` 里。只用 `harness.render()` 的像素钉(s67/s68)靠"同二进制里恰好有别的
测试先跑过 snap"才拿到 Vulkan —— 测试线程顺序不保证 = 随机红。本轮 s69/s70 单独跑就直接
炸在 wgpu 里,证明这条不是假想。提出为 `ensure_wgpu_backend()`,`snap` / `snap_budget` /
四个 render 钉各自先钉(OnceLock 保证只写一次且先于任何 wgpu 实例)。

### 审计

- 本轮改动面:`header_band` 的横向范围、`pinned_table_grid` 的列线 x、测试侧
  `ensure_wgpu_backend` 提取 + 三个新钉。**没有删任何功能面**,没有动主题、没有动依赖。
- 0.2.27 的 CHANGELOG/DESIGN 里那句「列宽由 grid 量出来」保留在原条目里不回改
  (历史条目不改写),但代码注释已更正,并在本节写明它当时是错的。
- 遗留(有意不动):带铺 `ui.max_rect()` 后,浮动窗里两张宽度不同的表(插件表 / 服务目录)
  带都铺满窗宽 —— 与 Python「表头铺满视口」一致,但如果日后要让窗内两张表各自贴合自己的
  列宽,应改成传 grid 的 `response.rect`。本轮按 Python 口径统一,不混两种。


## 0.2.29 — Wave A:数据监控表换 `egui_extras::TableBuilder`(BD-UI-43 / BD-UI-44)

### 为什么换(不是"换个库好看一点")

交接报告 §4 实拍八条毛病里,**2 列宽未按内容 / 3 数字不成列 / 5 成交表被右缘切掉且无横向滚动
提示 / 6 买5卖5 贴左缘 / 8 行线切进行里** —— 五条的根因是同一个:**表是我们自己拼的**。
`Grid` 没有"表头"概念(表头只是第一行),固定表头只能靠 painter 覆盖层;列宽由 Grid 在
**首帧**按当时可用宽量一次,之后内容变宽不回头;数字列没有对齐语义;行线是我们按 `row_h`
手算的、与真实行矩形无关 → 必然切进行里(0.2.29 第 1 步的 T1 钉在旧代码上实测:13 根线落在
y=423/444/…/674,文字中心在 424/445/466 → 判据红,证明判据抓得住这个缺陷)。

换 `egui_extras::TableBuilder`(0.33.3,与 egui 同版,emilk 官方维护)一次解决四条:
列宽按内容(两趟 sizing,`max_used_widths` 跨帧保留)、表头天然在体表滚动区之外
(`Table::body` 自己开 `ScrollArea::new([false, vscroll])`,table.rs:746 → **固定表头是库的行为**,
不是我们的覆盖层)、`striped` 跟 `visuals`(table.rs:482)、每格矩形由库给
(`TableRow::col` 返回 `(Rect, Response)`)。

### 换表过程中撞到的三条 egui 事实(写下来免得下次再撞)

1. **一次点击只发给一个 `Response`。** `context.rs:1384`
   `let clicked = Some(id) == viewport.interact_widgets.clicked;` —— 整个 viewport 只有一个
   `Flags::CLICKED`;AccessKit 那条路(`context.rs:1344-1350`)也只给被点的控件。于是
   「整格可点」(`TableBuilder::sense`)与「格里的钮 / 勾选框」不会同时拿到 `clicked`。
   换表第一轮直接把 5 个钉打红(s8 单击带入代码 / s11 双击撤单 / s23 行首格选中 / s18 / s29)。
   修法:`tcell` 带回**两个**响应(格响应 + 格内容自己的响应),调用点把两者 OR 起来
   (`CellOutcome` 把 `Response` / `()` / `InnerResponse<R>` 三种格返回值统一掉)。
2. **`Column::auto()` 不会为了"挤得下"而缩列。** 12 列成交表在 ~450 px 的格子里列总宽 ~800 px,
   库不缩(实测表头节点右缘到 x=1608,画布只有 1280)→ 溢出是真的,必须有一层横向 ScrollArea
   才谈得上"最后一列可达"。
3. **egui 的滚动条缺省是看不见的。** `scroll_area.rs:1418-1425`:把手色 =
   `scroll.foreground_color ? visuals.fg_stroke.color : visuals.bg_fill`,缺省
   `foreground_color = false` → 把手色 = 体表色,**条画了但和体表同色**。实拍两张
   (`tests/snapshots/s21_full_tables.png` 与 `target/chk-0229-frame.png`)都确认"列被右缘切掉、
   但没有任何横向滚动提示" = 毛病 5 只治了一半。修法:`pinned_table_impl` 里把
   `spacing.scroll` 显式写死 `floating=false` / `foreground_color=true` / `bar_width=10.0`
   (Qt 口径:滚动条占位、高对比把手)。改完 s21 里三张表的横向条肉眼可见。

### 边界:哪些表**不**换(写死,免得日后当成"没换干净")

- **换**(固定表头 + 会溢出 + 有行交互):行情 / 委托 / 成交 / 持仓 / 日志 / 查询合约 / **资金**。
  资金表原来在"浮动窗短表"那一类里,实测它和日志表共用同一块 tab 容器(不是浮动窗)→ 并入。
- **不换**(自动尺寸浮动窗里的短表,0.2.17 实锤 s39):运维插件表 / 服务目录 / 账户清单 /
  下单表单(`order_form_grid` 是表单不是表)。这类用 `Grid` + `header_band`。
  `pinned_table` = `auto_shrink(false)` 吃满整块可用高,在自动尺寸浮动窗里凑不出正确高度 →
  表行与其后的口径句互相压字。
- 列分隔线 + 可拖列宽只给合约表(`GridLines::Grid` → `resizable(true)`),照 Python
  `setShowGrid(False)` + 合约窗可拖列的口径;其余表只有行线。

### T7 行数上限实测(本机,同一夹具,5 次样本里的最优单帧建构)

| 行数 | dev(`cargo test`) | release(`cargo test --release`) |
|---|---|---|
| 50 | 11.98 → 29.4 → 12.2 ms | 1.32 → 2.16 ms |
| 200 | 42.80 → 105.2 ms | 4.96 → 8.33 ms |
| 1000 | 208.14 → 512.1 → 214.7 ms | 26.44 → **47.60 ms** |

判据「1000 行单帧 < 60 ms」说的是**实窗 = release 构建** → **成立**(两轮 26.44 / 47.60 ms),
不换 egui_table;退路(egui_table 0.7.0 虚拟滚动)保留在方案 §8.3,不启用。

**但这条不能当墙钟钉 —— 本轮实测逼出来的结论。** 同一 dev 判据在同一台机器上摆动 2–4 倍
(50 行 11.98 / 29.4 / 12.2 ms;1000 行 208 / 512 / 215 ms)。原因不是表格层,是夹具环境:
`cargo test` 里 75 个测试并发跑,快照钉那批在做 wgpu 渲染,墙钟样本被抢;而快照钉共享
SNAP_LOCK,一个墙钟红还会连带一片 `PoisonError`。我先按「规模比 ≈ 成本比」改过一版
(线性期望 4 / 5,上限 8),门禁里照样红了一次(实测比值 8.6,而同一轮 1000 行只用 215 ms
—— 比值被 50 行那档的小样本噪声放大)。**结论:这台机器上墙钟阈值只能当手工实测记录,
不能当 CI 钉。** `t7_…` 最终钉的是两件事:① 每 n 行都被真实建构出来(`query_all_by_label`
计数 = n,确定性、无噪声 —— 这才是「TableBuilder 没有虚拟滚动」的真正退路警报);
② 一条数量级上限(dev 3000 / release 300 ms)只抓病态爆炸。性能结论靠手工实测记录在这里,
要重测就 `cargo test --test ui_kittest t7_table -- --nocapture` 单独跑。

### 本轮新钉(118 + 4 − 1 = 121 钉,15 张快照)

`t2_column_width_follows_content`(同一张合约表喂短名/长名,下一列表头左边界必须右移 > 20 px,
且长名文字不得越过那条列线)、`t3_numeric_columns_are_right_aligned`(成交表价格列 + 行情带盘口(买1价 / 卖1价)每列三行数字右边界
差 ≤ 1 px,判据走 AccessKit 节点矩形不靠像素)、`t4_trades_table_scrolls_horizontally`
(12 列 + 长名称 → 滚一下首列表头必须横移 > 100 px、y 不动 = 固定表头不回归、滚动后「接口」
列右缘必须落进可视区)、`t5_row_backgrounds_do_not_alternate_under_qdark`(Qt深色相邻行底色
相同且 = 主题体表色 #19232D,证明斑马没被硬开、底色没硬编码)、`t7_table_builder_row_count_benchmark`
(见上一节:钉「每行都被建构」+ 数量级上限)、`t8_book_frame_price_and_volume_columns_are_aligned`
(下单侧五档盘口十行:价列与量列右边界差 ≤ 1 px、档位标签同一列;判据有效性实证 = stash 掉
代码改动后红在「价格格必须全被抓到」)。

顺带记一条 harness 事实:**滚轮事件只受理"指针在体表/空白处"的位置**,悬停在表头格上单发一次
滚轮不动(实测 x 不变);连发两次(真实用户滚轮天然是多拍)才受理。判据按这个形状写。

### 尾巴:五档盘口(BD-UI-45)—— 毛病 6 的本体在下单侧,不在行情表

上一轮审计我把「毛病 6 有判据了」记在行情表的买1价/卖1价列上,**位置错了**:用户实拍的
「买5/卖5 不对齐贴左缘」是下单侧的常驻五档盘口 `render_book_frame`。它原来是一支文本
`ui.label(format!("卖{i}  {:.1}  {:.0}", p, v))` —— 价与量靠**空格**对齐,位数一变(101.5 →
12345.5)整块错位。换成 `TableBuilder` 三列(档 / 价 / 量),价与量右对齐。

三条边界写在这里:
- **不用 `pinned_table`**:下单侧是窄 `SidePanel` 且外层已有 `ScrollArea`,`pinned_table` 的
  `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训的同型),这里用缺省
  `auto_shrink([true, true])` 贴合内容。
- **不加表头**:Python 侧没有五档盘口这个块(`widget.py` 只有行情表的买1价/买1量/卖1价/卖1量
  四列 + 「点击买列填充买1价」联动),这是我们自加的常驻盘口,保持无表头。
- **买/卖色仍是硬编码**,集中成 `BOOK_ASK_COLOR` / `BOOK_BID_COLOR` 两支常量,Wave B 令牌层接管。

`t8` 的判据形状值得记:同一个 tick 同时喂行情带表格与下单侧盘口 → 同名标签两处。判据按
**夹具里的 y 分区**只取盘口(`rect.min.y > 500.0`),夹具里行情带在顶部(< 300 px)、下单表单
字段在 500 px 以上才到盘口。
---

## 0.2.30 — Wave B:设计令牌 + 只留一套主题(BD-UI-46 / BD-UI-47)

**BD-UI-46(颜色 / 字号 / 间距只有一个来源)**:新增 `src/tokens.rs`。`qdark` 支是 qdarkstyle 3.2.3
qss 的原值(BG1 `#19232D` / BG2 `#293544` / BG4 `#455364` / BG5 `#54687A` / BG6 `#60798B` /
TEXT1 `#DFE1E2` / TEXT3 `#ACB1B6` / ACCENT2 `#346792` / ACCENT4 `#259AE9`);`semantic` 支是**用途**色
(涨 `#E05050` / 跌 `#2E9E2E` / 警 / 错 / 成 / 信 / 弱)。口径:**涨跌色与对错色不许混同**(U3 钉),
**源码里除 `tokens.rs` 外不得再出现 `Color32::from_rgb`**(U2 机械钉扫源码)。
egui-charts 0.2.0 的 `ChartConfig::default()` 读它自己的 RON 设计令牌(青涨红跌),**不受 egui
`Visuals` 影响** → K 线必须 `Chart::with_config` 显式覆盖,否则「红涨绿跌」在图上反着画。

**BD-UI-47(只有一套长相)**:用户裁定只留 Qt深色。`Theme` 枚举 / `theme()/set_theme()/cycle_theme()` /
`apply_ui_style_for` / 菜单主题钮 / `EK_TOUR_THEME` 一并删除 —— 公开面真删,不是留着不用。
配套新增 `src/ui_ext.rs` 五支(`section_title` / `muted_label` / `space_group` / `primary_button` /
`num_cell`),**每支都要查到调用点**(U4)。浮动窗才用 `section_title`;四张主监控格仍然遵守
BD-UI-31「表题文字撤除,带位即身份」。

**几何教训(本轮实测)**:`Frame::group` 的描边按**内容**矩形画(`frame.rs:486` 用 `content_ui.min_rect()`),
要「预留多大就画多大」必须自己 `allocate_exact_size` + `painter().rect(..., StrokeKind::Middle)`。
`table_heights` 给的是**请求**行高,TableBuilder 实际行高 = max(请求, 格内最高控件):两者不等时
自绘行线整体错位 —— 这就是 0.2.28 治过的毛病 8 的复发路径,行高必须 `max(文字+6, INTERACT_HEIGHT)`。

**已知遗留(交接报告 §7.1 / §13)**:浮动窗自动尺寸导致表头带比表体短(实测 84 px,s69 钉住 ≤ 90);
合约窗视口裁行导致行线计数判据降级(s72 ≥ 10 根,间距与「不压文字中心」仍然严格)。

---

## 0.3.0 — Wave C:下带数据区换 egui_dock(BD-UI-48)

**BD-UI-48(数据区交给停靠框架,框架形状不变)**:菜单条 / 行情带 / 下单侧**不进 dock**,仍是
egui 原生面板;只有下带右侧四组(委托 / 成交 / 持仓 / 资金)换成 `DockState<DataGroup>` 树,
在 `CentralPanel` 里 `DockArea::show_inside`(`DockArea::show` 与 `CentralPanel::show` 互斥)。
格内容仍由 0.2.29 的四个 group 渲染函数出,**dock 只换容器不换内容** —— 这条边界让 Wave A/B 的
表格判据一行都不用改。

**为什么先 spike**:§3 明写「kittest 能否定位 dock 的 tab 按钮 / 分隔条」未实测,而这一条决定
这一波所有判据钉写不写得出来。spike(commit `52104cd`)六条全过才落地;退回路径是撤依赖 +
回滚 `Cargo.lock`,Wave A/B 成果不受影响。

**spike 换来的三条硬口径**
1. dock 的 tab 节点在 AccessKit 里查得到,但**精确标签查不到**:`query_all_by_label("委托")` = 0,
   `contains("委托")` = 1(标签比标题长)。判据钉一律 `contains`。
2. `DockState::new(vec![..])` 把所有 tab 塞进**同一个叶子**;要 2×2 必须显式 `split`
   (`Right 0.5` + 两半各 `Below 0.5`)。不 split 时四组挤成一列 —— S4 直接红。
3. kittest 里 `SidePanel::right().default_width(300)` 没吃到 300(实测侧宽 ≈ 96)→
   判据不许假设「1280 − 300」,中央范围必须从实测推。

**判据形状的选择**:V2 钉的是**树**(4 叶子 + 3 分隔节点 + 每刀 `fraction == 0.5`),不是像素。
原因:渲染侧「委托 / 资金」这些标签在 dock tab 与格内二级 tab 上都会出现,拿标签矩形当列边界会
量到错的节点;渲染侧的 50:50 由快照 `s3 / s42` 钉住。这是**口径降级还是口径更正,要写清**:
Wave B 的 U6 钉的是「我们自绘的四格边框等宽」,那个容器已随 `group_frame_plain` 退役 ——
U6 删除、V2 接替,不是把 U6 放宽。

**样式纪律**:dock 的 `Style` 只走 `Style::from_egui(ui.style())`(吃 `tokens::apply` 填好的
egui `Style`),`dock.rs` 里不得出现 `from_rgb` / `Color32::`(V5 机械扫)。
---

## 0.3.1 — Wave D(部分):排序 / 双击 / 格式定死(BD-UI-49)

**BD-UI-49(展示格式与排序都收进一层,不散在各表)**:数字与时间格式只有 `src/format.rs` 一份口径;
排序键只有 `UiShared::table_sorts` 一份,而且**排序只作用在喂表前的快照副本**(`sorted_rows`)上,
数据模型不被界面排序改写。这条边界就是 Python `widget.py:396-419`「更新途中重排会错位」那个病的正解。

**时区口径(动手前先定死,Wave D 测试方案 §0)**:表格时间列 = 北京 +08 墙钟,与日志同源;
K 线钟面 = UTC 重标定,不动。判据一律喂固定 epoch,不读当前时间。

**本轮实撞的三条 egui/kittest 口径(写下来免得后人重踩)**
1. `egui_extras` 的 `.sense(Sense::click())` **不吃表头格** —— 表头格只有 hover 感。
   先试「格内 `interact`」失败,正解是**表建完后在同一层(横向 ScrollArea 的内层 ui)补 `interact`**;
   格内的 ui 被 TableBuilder 借走,而且坐标必须落在同一层里才对得上。
2. kittest 的 `Node::click()` 走 AccessKit 动作,**静态文本节点没有动作** —— 点了等于没点。
   判据要的是真实输入事件:`Harness::input_mut()`(0.33.3 里不带闭包,直接返回 `&mut RawInput`)
   往同一拍事件队列塞 `egui::Event::PointerButton`(0.33 不叫 `InputEvent`)。
3. 判据挑节点**不能只按 y 筛**:成交表与委托表的表头带在同一行,只按 y 筛会挑到委托表的「价格」
   (实锤:点击落在 x=746,而成交表的「价格」格在 x≈1200)。必须同时按 x 筛。

**没做**:`#[ignore]` 两条 —— W5(右键菜单代码在,判据没定对形状)、W7(退出确认没实现)。
理由字符串写在测试上。这是 Wave D 的欠账,不是已完成。
**Wave D2 补的两条(同版本 0.3.1,提交 `d2a641e`)**

- **退出确认走 egui 0.33 的 viewport 关闭协议,不走宿主**:`ctx.input(|i| i.viewport().close_requested())`
  为真且未放行 → `ViewportCommand::CancelClose` + 弹确认窗;放行位 `exit_confirmed` 置起后重发
  `ViewportCommand::Close`。这条完全住在 ek-ui 里,**没动 ek-station / 宿主一行** ——
  本来以为要在 `main.rs` / `runtime.rs` 挂 `on_close_event`,那是 Qt 的思路,不是 egui 的。
- **判据形状要跟着机制走**:kittest 没有「点窗口关闭按钮」这个动作,所以 W7 钉的是机制形状
  (拦关闭 + 放行路 + 状态归属)。实窗证据(0.3.1 当时):优雅关闭(taskkill)后进程存活 +
  确认窗在场。0.3.2 起「看确认窗长什么样」这条改为**实跑行为核**:启动 → 请求关窗 →
  进程不退(优雅 `taskkill` 后仍在),不出图。
- **右键菜单的教训(挂起前定性的东西)**:同一个二级点击,普通按钮的 `context_menu` 在 kittest 里查得到,
  体表格的格查不到 —— 差别在**控件有没有 AccessKit 动作**,不在事件本身。
  以后要钉右键菜单,得给菜单一个真控件(按钮/图标),或者退到源码扫描;别拿静态文本格硬试。

---

## 0.3.2 口径变更:截图 / 读图 / 像素快照全部撤除(交接报告 §17,本轮定死)

**为什么**:这台机器上的模型没有可视能力 —— 既截不了图,也读不了图。原来靠「看一眼」和
「数像素」立起来的判据,在这一轮之后没有任何人能复核;`tests/snapshots/` 里 15 张 `.png`
基线本身成了不可读的死数据。裁定:**判据一律改成代码能自己判的**,计划 / 测试 / 文档里
不再出现「截图、读图、像素扫描、前后对照图」。

**四类判据(取代「快照 + 像素」)**

1. **语义树**:AccessKit 节点在不在、标签是什么、`Node::rect()` 几何(列对齐、行位置、贴边、滚动)。
2. **状态层**:`UiShared` 字段与访问器,纯单测(不建构界面也判得出来)。
3. **源码机械扫描**:`from_rgb` 只许出现在 `tokens`;开关必须显式(`striped` 透传、
   `show_leaf_close_all_buttons(false)`);格式只走 `src/format.rs`。
4. **本帧绘制指令**:`harness.output().shapes` → `Vec<epaint::ClippedShape>` ——
   immediate 模式这一帧的真实绘制指令,确定性、可比、不需要眼睛。

**第 4 类实核出来的口径(本轮踩出来的,写下来免得后人重踩)**

- 图层形状也在 `FullOutput.shapes` 里:`egui/context.rs:2526` 把根 viewport **所有层**
  (`viewport.graphics.drain(..)`)并进来 → toast 画在 `Order::Foreground` 层也钉得住(e1–e4)。
- `ClippedShape.clip_rect` 分得开「画了」与「被裁掉了」:`TextShape::visual_bounding_rect()`
  与 `clip_rect` 求交,交为空 = 这一帧没露出(s27「数据行不许从表头带上方露出」靠这个)。
- **坑 1**:独立 OS 窗(`show_viewport_immediate` 的 Immediate 面)有自己的 FullOutput,
  根 output 里没有它 → 这类判据退回状态层 + 源码扫描(s10 就是这么改的)。
  kittest 里四窗走 Embedded 回退(主窗内 `egui::Window`),形状在根 output 里。
- **坑 2**:同一支 BG4 在画面里到处都是(主窗各表的带底横线、dock 未选中 tab 的 2px 下边线、
  侧栏分隔竖线)。判「这张表的线」必须先用**这张表自己的带**定 x 闸:带 = 盖住该表表头格
  (`Node::rect()`)的 BG4 实心矩形;横线还要求横跨带宽的 80%。硬编码「窗在画面哪一块」的
  老区域在 0.3.0 换 dock 容器后已经错位(s70 第一版就是这么红的)。
- **坑 3**:`query_by_label` 遇同名标签直接 panic(主窗表与合约表共用「交易所 / 名称」)。
  跨表挑格一律 `query_all_by_label` + 按全仓唯一锚点标签(「最小变动」)的 y 筛。
- **坑 4**:`Rect` 没有 `is_empty()`,只有 `is_positive()`;`Rangef` 不从 `RangeInclusive`
  自动推(要 `Rangef::new`);`f32` 不 `Ord`(`.max()` 换成 `fold(f32::max)`)。

**测试里的取数 helper**(`tests/ui_kittest.rs`)

| helper | 出什么 |
| --- | --- |
| `shapes_of` / `clipped_shapes_of` | 本帧形状 / 形状 + 各自裁剪矩形 |
| `filled_rects(color)` | 该色实心矩形(`rect_filled` = fill 同色且描边为空) |
| `header_bands(color, min_w)` | 实心矩形里够宽够高的(高 ≥ 8 px,1px 框线不算带) |
| `hlines(color)` | 该色水平线段 `(y, x 范围)` |
| `texts_containing(needle)` | 文字形状 `(整串文本, 视觉矩形, 实际取色)` |
| `text_color_of(&TextShape)` | `override_text_color` → 第一个非 PLACEHOLDER 的 section 色 → `fallback_color` |
| `text_span_in(region)` | 区域内真被画出来(裁剪后非空)的文字最宽跨度 = 体表宽度 |
| `toast_texts` / `toast_marks` | 右下角(x ≥ 900、y ≥ 500)的 toast 正文 / 「▎」级别标记 |

**逐条改判(原来 → 现在)**

| 钉 | 原来 | 现在 |
| --- | --- | --- |
| s3 | 首帧快照 | AccessKit 几何钉保留 + BG4 实心矩形(宽 > 100)在场 |
| s10 | K 线窗快照 | 表头标签钉 + `kline_symbols()` 状态钉 + `render.rs` 里 ChartConfig 四色源码扫描 |
| s16 | CJK 快照 | 字体挂在 Proportional / Monospace 两族 + 语义树查到中文串 + 这一帧真排出 > 300 pt 宽度 |
| s21 | 满表快照 | 数据格在场 + 三张表列名逐列计数 + 行数按委托号/成交号计数(CTP_7701 两处 = 委托行 + 成交行的委托号格) |
| s27 | 滚动后快照 + 闸外像素 | 表头 y 不动 + 带矩形 + 真画出来的行文字(裁剪后)必须在带底之下 |
| s39 / s40 / s41 | 三张弹窗快照 | 纯语义树计数(测试名也去掉 `_snapshot`) |
| s42 | 整面板快照 | `ctx.style().visuals == tokens::visuals()` + BG1 大面积底 + BG4 整行横线 |
| s63 / s65 | 快照 | 表头标签计数 |
| s67 | 数 `#455364` / `#259AE9` / `#54687A` 像素 | 绘制指令:BG4 带面积 > 4000、ACCENT4 横线 ≥ 1、BG5 tab 底 ≥ 1 |
| s69 / s70 | `band_vs_body` 像素 + 硬编码窗口区域 | 带 = 盖住表头格的 BG4 实心矩形;体表 = 带下方带内真画出来的文字跨度(缺口 ≤ 90 / 带 ≥ 体表) |
| s71 | 带真行的快照 | 竖线线段(egui_extras 列边界,`table.rs:907`)必须落在相邻两列内容之间,或落在最后一列与表右缘之间 |
| s72 | `scan_hlines` 像素扫 + 快照 | `hlines()` 取带内 BG4 水平线段:≥ 10 根、间距均匀、每根在两行之间、离行文字中心 ≥ 5 px |
| t5 | 每行右侧取一个像素 | `visuals.striped == false` + 源码扫描(透传 `.striped(striped)`、无 `.striped(true)`)+ 整帧 0 块 BG2 斑马 |
| t8 | 快照 | 纯 AccessKit 右边界对齐(价列 / 量列 / 档位标签) |
| u3b | 矩形内数红 / 绿像素 | 文字形状实际取色 == RISE / FALL + `rise_fall()` 函数钉 + 源码扫描 |
| e1–e4 | 两次渲染数右下角色带 | toast 正文与「▎」标记的文字形状:内容 + 实际取色 + 位置 + y 序 + 条数 ≤ `SCREEN_CAP` |
| e5 / W7 / V4 / V5 | 已经是源码扫描 | 判据不动,措辞里的「截图」删掉 |

**删掉的东西**:`SNAP_LOCK` / `ensure_wgpu_backend` / `snap` / `snap_budget` / `snap_guard` /
`count_near` / `band_vs_body` / `scan_hlines` / `Shot` + `shot_of` / `ek_px` / `ek_near` /
`ek_bands` / `EK_*_RGB`;`tests/snapshots/` 15 张基线全删;`Cargo.toml` 里 `egui_kittest` 的
`features = ["snapshot", "wgpu"]` 撤除(egui_kittest 没有 default feature,`Harness` /
AccessKit / `run_steps` 不受影响)。

**不许用来变绿的路**:恢复快照基线、`check-package -AllowDirty`、把断言改成永真。

---

## 0.3.2 Wave E — 告警 toast(BD-UI-50)

**BD-UI-50(告警只有一条产生口,toast 只有一层载体)**:告警的产生归 `UiShared::note_gateway_status`
(状态轮询那一拍),toast 的呈现归 `src/toast.rs`(载体 `egui-notify 0.21.0`)。两件事都不许散:
产生口散到某张窗的渲染里 = 那张窗没开就没有告警;载体散成自绘 overlay = 与 dock / 表头覆盖层抢 z 序。

**载体口径(0.21.0 源码实核,交接报告 §6 第 2 条到这里才关掉)**

- `Toasts::show(ctx)` 把卡片画在 `LayerId::new(Order::Foreground, Id::new("toasts"))` 这个**独立图层**
  (`egui-notify-0.21.0/src/lib.rs:211`)→ 不被我们的表头带 / dock 描边压住(方案 C13 的答案)。
  判据能钉到它,是因为根 viewport 的**所有图层**都并进 `FullOutput.shapes`(§17 口径第 ① 条)。
- **toast 是 painter 画出来的,不是控件**:没有 `Response`、没有 AccessKit 节点,`Toast` 的
  `level / caption / width / height / duration` 全是 `pub(crate)`(`toast.rs:62-74`),
  `Toasts.toasts` 也是私有。所以判据只能走**本帧绘制指令**(正文与「▎」的文字形状),不能走语义树。
- **唯一的交互是右上角「❌」**(`lib.rs:387-392` 只对那颗叉做命中测试)。
  → Python 那条「点击卡片跳该合约 K 线」(`alert_toast.py` 的 `mousePressEvent`)**在这个 crate 里做不到**:
  要做得复刻它的锚点与宽度公式(`toast.width` 每帧按 galley 重算,`lib.rs:323-328`)再自建命中层,
  而那份公式我们没有可读的公开面。**本轮不做,记成欠账**(见本节末)。
- crate 没有「同屏上限」开关 → `SCREEN_CAP = 3` 由我们兜(第 4 条进来前先 `dismiss_oldest_toast`)。
- crate 没有 Python 的「左边框 4px 级别色」→ 级别可辨走 `ToastLevel::Custom("▎", Python 色)`,
  **不假装像素一致**。

**位置与节律口径(照 Python `alert_toast.py`)**

| 口径 | 值 | Python 出处 |
| --- | --- | --- |
| 位置 | `Anchor::BottomRight` + `reverse(true)` = 新的在最底、向上堆叠 | `_reposition` 的 `reversed()` |
| 边距 | 20 / 20 | `_margin_right = 20` / `_margin_bottom = 60`(60 是避任务栏,我们画在窗内容区里,不隔) |
| 卡片间距 | 10 | `_gap = 10` |
| 同屏上限 | 3(超限关最旧) | `_max_toasts = 3` |
| 自动关 | 8 秒 | `show_toast(duration=8000)` |
| 级别色 | `#1890FF / #FAAD14 / #FF4D4F` | `AlertToast._LEVEL_COLORS`(45-49) |

级别色只住在 `tokens::alert`(U2 单一色源钉:`src/` 里 `Color32::from_rgb(` 只许出现在 `tokens.rs`)。

**告警产生口(与 Python `AlertEngine` 的对照)**

- 唯一入口 = `note_gateway_status`:`{gw}.status` 读数折成登录态之后,`is_alarm()`
  (登录失败 / 掉线)或 `QueryStarved`(查询断供)就灌一条 toast,文案用**状态细节原文**
  (「资金回报已 31s 未更新」比「查询断供」更可行动)。
- 去重键 = `网关 + 登录态`:同一状态只弹一次,状态换档才算新告警。不去重的后果是每轮轮询一条,
  8 秒自动关与同屏上限 3 都没有意义。
- 原来这条挂在账户面板渲染里(`render_accounts_window` 的状态细节循环)—— **已撤**,
  账户窗没开也必须知道掉线了。E6 的机械扫描钉就是挡这个回潮的。

**判据(e1–e6,全部先红后绿)**

| 钉 | 判据形状 | 红证据 |
| --- | --- | --- |
| e1 | 没有告警的那一帧 vs 灌一条告警的那一帧:右下角必须多出正文文字形状,「▎」必须取 ERROR 色 | 旧代码没有 `toast` 模块,编译面红 |
| e2 | 三级各一条 → 三条「▎」的实际取色 == Python 那三支,且正好三条 | 缺省 `ToastLevel::Error` 的 crate 色是 `#C85A5A`,不是 `#FF4D4F` |
| e3 | 三条正文按 y 升序 = 最早 / 中间 / 最新 → 最新的 y 最大(在最底) | `reverse(false)` 时顺序正好相反 |
| e4 | 灌 5 条 → 屏上 ≤ `SCREEN_CAP` | 不关最旧时 5 条全在 |
| e5 | 源码扫描:`Duration::from_secs(8)` / `Anchor::BottomRight` + `.reverse(true)` / `SCREEN_CAP: usize = 3` + `dismiss_oldest_toast` / 每帧 `drain_and_show` | 开关不显式写就红 |
| e6 | 状态层:轮询一拍出告警 / 同状态不重复 / 转好不弹 / 换档再弹 + 机械扫描「`push_alert(` 在 src/ 只许一处且在 state.rs」 | 旧 `note_gateway_status` 不产生告警 → 第一条就红(0 条) |

**连带口径(本轮实撞,写下来免得后人重踩)**

- **toast 在屏上时 egui-notify 每帧 `request_repaint`**(`lib.rs:434`,退场计时与出现动画)→
  `Harness::run()` 的「等 UI 自己静下来」(max_steps = 4)直接炸。21 处改成 `harness.run_steps(4)`
  定帧数。教训:**只要界面上有活计时器,就不许用 `run()`**。
- `egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` → 直接放
  `Mutex<Option<Toasts>>` 编译不过。`toast::ToastsCell` 就是为这一条存在的(自己决定 Debug 印什么)。
- 常驻队列必须住在 `UiShared`:每帧新建 = 8 秒自动关与同屏上限都没有意义(与 `DockState` /
  `Chart` 同一条纪律,交接报告 §5.3)。

**Wave E 没做的一条(欠账,不是已完成)**

「点击卡片跳该合约 K 线」做不到,原因在库不在我们(`Toast` 无公开交互面,见上)。
今天还有一层:**我们唯一的告警源是账户状态,它本来就没有「该合约」可跳** —— Python 那条能跳,
是因为它的告警来自按合约的 `AlertEngine` 规则;ek-ui 没有按合约的告警规则(数据侧缺口,不是 UI 缺口)。
出路两条:① 等告警带上 symbol 时自建命中层(要复刻 crate 的宽度公式,脆);② 换成自绘 toast
(~100 行,像素与交互全归我们,但推翻「toast 归 egui-notify」这条选型)。**本轮两条都没选**,
按 Wave E 的验收判据(三级色 + 8 秒 + 右下角向上堆叠 + 上限 3)收口。

## 0.3.3 Wave F — 列差集收口(BD-UI-51)

**为什么有这一轮**:六波做完之后,「跟 Python 长一样」这件事还剩一层没核 —— **列差集**。
逐列把我们的表头/表体和 Python 的四个 Monitor 摆在一起,查「我们多一列 / 少一列 / 列序不同 /
同一列取值不同」。核的过程抓到两个真 bug(不是差集,是错),这是本轮最大的收获。

**两个真 bug(判据当场红出来的)**

| bug | 形状 | 为什么能躲这么久 |
| --- | --- | --- |
| 成交表少一格 | 表头 12 列,表体只发 11 个格 → 整行左移一列(`代码` 位印 `名称`),`RIGHT_COLS` 三列也落在错列 | 老判据只查「列名在场」(s21)与列宽几何(t2/t3),没查「表体第 i 格 == 表头第 i 列」 |
| 持仓成本/均价互换 | ek-data `avg_price` = 成本价、`price` = 持仓均价;Python 成本 ← `avg_price`、均价 ← `price`;我们写反 | s21 只查「成本/均价两列都在」,不看值 |

**列差集三条 + 第四条(本轮新查出来的)**

1. **成交表列序**:照 Python `TradeMonitor.headers`(`widget.py:1270-1283`)—— `代码` 首列、
   `成交号` 第 10 列、`成交额` 改名 `成交金额`。**改**。
2. **行情表「交易所」列**:Python `TickMonitor` 有(`widget.py:709-725`)。**不补** ——
   行情带按所分二级 tab,同一视图里每行「交易所」恒等于 tab 名,加列是纯噪还占宽度。
3. **委托表「操作」列**:Python `OrderMonitor` 没有(`widget.py:1341-1358`)。**保留** ——
   撤单是危险操作,明钮比「双击 + tooltip」可审计(Python 的两条我们也都有)。
4. **枚举值显示语言**(新查出,不在原计划):我们印 wire 串(`LONG / OPEN / PARTTRADED / SHFE`),
   Python 印中文(`多 / 开 / 部分成交 / 上期所`);ek-data 有 `chinese_alias()` 可直取。
   **本轮不改**:牵动下单面板下拉、CSV 导出、排序键与既有判据(s11 按 `PARTTRADED` 查树、
   s18 按 `SHFE.rb2605` 点合约),值得单独一版。已写进交接报告待办。

**判据(f1–f4,全部先红后绿)**

| 钉 | 判据形状 | 红证据(0.3.2 上跑) |
| --- | --- | --- |
| f1 | 成交行 12 个夹具值各一处 + 表体按 x 升序 == 期望序列 + 表头 12 列按 x 升序 == 期望序列 | `F1SYM` 查不到(那格没建构);补格后列序仍错也红 |
| f2 | 持仓行:成本表头 < `98.25`(avg_price) < 均价表头 < `101.5`(price) < 最新价表头 < `77.125` | 成本格印的是 `price` → 第一条就红 |
| f3 | 源码扫描:`TRADE_HEADERS` 字面量逐个按 Python 序出现,且是单源 12 列常量 | 成交号在首列 → 红 |
| f4 | 源码扫描:`render.rs` 里不许有带精度/符号/对齐的裸 `format!` 说明符(排序键两支放行);`format.rs` 必须齐六支 | 持仓/资金/涨跌幅/最小变动共 10 处裸格式 → 红 |

**Wave D 欠账收口的三条(同一版本 0.3.3,BD-UI-52)**

| 钉 | 判据形状 | 红证据(0.3.2 上跑) |
| --- | --- | --- |
| w5 | 右键首格 → 菜单「调整列宽 / 保存数据」两节点在场;**再点「调整列宽」必须真的翻 `table_resize_on`**;**再点「保存数据」必须落 `target/ek-ui-trade_grid.csv`,且表头逐字 == 屏幕表头、每行 12 字段** | 原来整条 `#[ignore]`(体表格不吃二级点击)→ 0 节点 |
| w8 | 委托表点「价格」表头 → O-2(3.5) 到 O-1(101) 之上;再点翻降序 | `table_sort("order_grid") == None`,行序不动 |
| w9 | 持仓表点「数量」表头 → y 序 1 < 3 < 5 | `table_sort("pos_grid") == None` |
| w10 | 行情带点「最新价」表头 → 5 < 98 < 12345(缺省序是按 symbol,所以必须**变**) | `table_sort("tick_grid") == None` |

**这一段自查里揪出来的假判据(留着给后人看:判据自己也会骗人)**

- `f3` 的第一版在**整份 render.rs 里做子序列查找**(「这些列名按这个先后出现」)——
  实测把 `TRADE_HEADERS` 前两列对调它**照样绿**,因为它命中的是更靠前的 `ORDER_HEADERS`。
  改成「先切出 `TRADE_HEADERS` 字面量块,再逐个取字符串字面量比序」后,错序当场红。
  **教训:源码扫描必须先把判据对象切出来,再比;全文件 find 出来的绿是假绿。**
- `f4` 的第一版只认 `format!("{:...`(无名参数)两种子串,`format!("{price:.2}")` 这种
  **命名参数**会整条漏过去。改成逐条 `format!` 抠 `{...}` 说明符再判(带 `.` / `+` / `>` 即违规),
  只放行排序键那两支零填充。
- `w5` 的第一版只数「菜单项节点存在」—— 挡不住「菜单项挂了个空壳」。补了「点下去状态真的变」
  与「CSV 真的落地且列数与屏幕同源」两条。

**连带口径(写下来免得后人重踩)**

- **表头 `interact` 一直是通的,缺的只是「有人接 `clicked`」**。委托表/持仓表/行情带 0.3.1
  起就能点出 `clicked`,只是调用点丢掉了返回值 —— 补排序是「接线」不是「加交互」。
- **被横向裁剪的格仍在语义树里,矩形也还在**(表体节点不受 `clip_rect` 影响)。
  于是「靠 x 窗挑这一行的格」会把**邻表溢出列**也挑进来(实测:1280 宽时委托表 17 列装不下,
  它的「时间」表头矩形伸进成交表的 x 窗,同一行里出现两处「时间」)。
  正解:① 判据夹具给宽窗(2600×900),两张表都不横向裁剪;② 或用「该表专属表头」定行 y。
- **列序改了,老判据的锚点会搬走**:W1/W3/T4/W5 原来都锚在「成交号」或 `T-1`(成交号列),
  列序照 Python 之后它们落到横向裁剪之外 —— **裁剪外的坐标点不到那一格**
  (节点还在树里,命中测试不吃)。四处锚点全部改到「代码/名称」列。
- **表头文字挂排序箭头后,精确标签匹配会失手**:点过一次的表头变成 `价格 ▲`,
  再要抓它必须用 `_contains`(W8 第二次点击即此形)。
- **`context_menu` 挂真控件才收得到二级点击**(W5 的定性):体表格的格先吃过 hit-test,
  体表层后补的 `interact` 与表不同层 → `secondary_clicked` 永远为假。
  首格改成 `Label + Sense::click()` 之后菜单就绿了 —— 结论不是「kittest 不行」,是「挂错了层」。

**欠账(不是已完成)**

- 行情带/委托表/持仓表**排序入口有了,但列宽拖动仍只有合约表**(`resizable` 口径未变);
  右键菜单里的「调整列宽」是全局开关(`table_resize_on`),不是逐表拖边界。
- 「保存数据」只在成交表现有(CSV 走 `trade_cells`,与屏幕同源);委托/持仓/行情带没有导出。
- **排序键的列号与表头列号只有三处交叉钉**(w8 钉委托表第 7 列价格、w9 钉持仓表第 5 列数量、
  w10 钉行情带第 3 列最新价)。若日后有人**同时**改 `ORDER_HEADERS`/`POS_HEADERS` 与对应
  `*_sort_key` 的列号,其它列的点表头会静默排错列 —— 目前没有一条钉能全列覆盖。
  真要补,形状是「点第 i 个表头 → 行序 == 按该列显示串排序」(12 列 × 4 张表 = 48 次点击,
  成本明显,故未做)。**这是本轮留下的已知缺口,不是已覆盖。**
- 委托表第 17 列「操作」没有可排的数据(排序键给空串):点它只挂箭头不换序 —— 无害但会让人
  以为坏了。要治就得把「操作」列从表头点击目标里摘出来(现在 `pinned_table_impl` 对每列一视同仁)。
- **右键菜单只挂在成交表首格(代码)那颗真控件上** —— 横向滚到右边再右键别的格不会出菜单。
  Python 是整表右键。要治得给每行一颗覆盖全行宽的真控件(或等 egui 让体表格的格报二级点击),
  本轮未做。
- 「保存数据」的落点仍是 **`cwd/target/ek-ui-<表>.csv`**(0.3.1 起如此):生产里 cwd 由快捷方式
  决定,不确定 —— 应收成用户文档目录或让用户选路径。另:CSV **不做字段转义**,今天是安全的
  (成交表 12 列全是代码/枚举/数字/时间),但**加任何自由文本列之前必须先转义**,否则一个逗号就错列。

---

## 0.4.0 — 整窗 dock 化:行情每所一格 / 下单侧一格 / 数据面「每个一个」(BD-UI-53)

**BD-UI-53(推翻 BD-UI-48 的框架边界)**:除菜单条外**没有任何原生面板** —— 一块
`DockState<Pane>` 收全部 13 个格子:下单 1 + 行情 6(每所一格)+ 委托 / 未成交 / 成交 /
持仓 / 资金 / 日志。0.3.0 的「菜单条 / 行情带 / 下单侧不进 dock」这条边界到此作废。

**为什么改(用户裁定原文)**:「dock 不仅仅是右下边四个,上面行情,每个交易所都要一个,左边下单
也是,右下角不是四个,而是每个一个,例如资金、资金是两个。而且既然 dock 有类似 tab 样式了,
那里面的 tab 就不需要了,就直接表格就可以了。」

**根因(不是「补几个格子」而是框架口径冲突)**
1. BD-UI-48 把 dock 当成「下带右侧的容器」,所以它天生只可能装 4 组;行情带与下单侧是另外两块
   原生面板 —— 用户要的「行情每所一格 / 下单侧也进 dock」在旧口径下无处安放。
2. 组内那层 `tab_label`(行情按所 tab、委托|未成交、资金|日志、以及成交/持仓那两个只有一个 tab
   的装饰头)是 0.2.27 时代为了模仿 Python `QTabWidget` 画的;dock 出现之后它就成了**第二层
   tab**,和 dock tab 语义重复。用户点名要撤的就是这一层。
3. 资金与日志共享一格、6 个所共享一格,于是「一个监控面 / 一个交易所一格」这条要求根本没有容器
   可表达。

**默认树**(`dock::default_tree`,比例令牌在 `tokens`;**刀序 = Python 主窗框架逐刀对齐**)
```
根(Vertical,Below 0.30)= 行情带 ← 横跨整个窗宽(Python setSizes([240,560]) = 30:70)
└ 下带(Right 0.234)= 下单(Python setFixedWidth(300))| 数据区
        数据区(Right 0.5):委托[+未成交] | 成交
        两半各(Below 0.5):持仓 | 资金[+日志]
```
**0.4.0 第一版把刀序写反了(用户当场指出「你把之前的布局修改了」)**:第一版把「下单 |
右侧」当第一刀 —— 下单通高、行情只占右上一角,那不是 Python 的框架(Python 是
`v_splitter` 先横切上下带,行情带横跨全宽)。事实源:`mainwindow.py:340-373`
(`v_splitter.setSizes([240,560])`,`top_window` = 行情)+ `211-269`(下带里 `trading_dock`
走 LeftDockWidgetArea,右侧 `right_splitter` 才是 2×2)。修正:v2 钉刀序(根必须
Vertical 0.30 / 上半必须装 6 所 / 下带必须 Horizontal 0.234),s3 钉几何(行情空态贴
窗口左沿 x<40、全撤在下带 y>400 且在行情带之下)。

行情 6 所**默认叠成一个叶子里的 6 个 tab**而不是并排:全窗宽下 6 等分每格 <210 px,
14 列的行情表仍会裁列;并排/拆窗是用户一步就能做的动作(dock 全格可拖、可 detach)。

**空态不吞表头(BD-UI-35 口径的收口)**:「表头恒在 + 表体内一行空态」是该仓既有口径
(委托/成交/持仓/日志/账户清单/合约窗),但行情格、资金格与运维窗服务目录三处走了
「空了就只画一句空态」的岔路 —— 资金格是资金/日志格的默认前台,用户第一眼看到的就是它
(「资金和日志那边表格有问题,表头都没有」)。三处统一回「表头照画、空态进表体」,
新钉 s73 在全空状态下钉八张表的独有表头(涨跌幅/状态描述/成交金额/盈亏比/余额/信息/
实例/账号)。回看传播链:这些空态分支是 0.2.x 逐格手写的,从没有一条钉把「空态也必须有
表头」写成全仓规则 —— 现在有了。

**状态口径:「谁在前台」只认 dock 树**。`leaf.active` 是唯一事实源
(`state::pane_is_active_in`);`UiShared` 里那三个组内 tab 字段删除,公共方法
`selected_exchange` / `topleft_tab` / `bottomright_tab` 保留原名、语义改成「激活对应格」。

**本轮实撞的四条(写下来免得后人重踩)**
1. **`split(fraction)` 的 `fraction` 是「第一个孩子」的占比,不是「老节点」**:
   `Split::Left/Above` 的第一个孩子是**新**节点,`Split::Right/Below` 才是老节点。
   按「老节点占 78%」写 `Split::Left, 0.78` → 下单格被撑成 78%、行情挤成 22%,
   数据区只剩 139 px 宽(几何 dump 实锤),六张表裁成一条。
2. **一次 `split` 之后父索引变成分隔节点**:老内容搬到新索引,想再往老叶子加 tab 必须接住
   返回的 `[老, 新]`,否则 `tree[surface][old].append_tab(..)` → `panic: node was not a leaf`。
3. **`append_tab` 会把新 tab 设为前台**(`leaf.active` 跟着变):补完「未成交」「日志」之后
   默认看到的就是它们,委托表 / 资金表整片不出。补完必须显式把 `leaf.active` 切回 0。
4. **dock 状态 API 不能在 dock 渲染路径里调**:`show_dock` 画格子的整段时间都持着树的锁,
   std `Mutex` 非重入 → 自锁挂死(实撞:行情行单击里调 `set_selected_exchange`,
   整套 kittest 300 s 超时)。现在一律走 `dock_tree_try()`(`try_lock` + `debug_assert`)。

**判据口径的变化(0.3.0 的三条 spike 结论仍然成立,且第 1 条这次又验证了一遍)**
- dock tab 标签**不进 AccessKit**(单 tab 叶子偶尔可见、多 tab 叶子不可见)→ 结构证据取**树**
  (V2 钉 6 叶子 / 5 刀 / 13 格;S64 钉 13 格各有 tab),内容证据取**格内控件矩形**
  (S3 用全撤钮 / 行情空态 / 委托与成交表头 / 资金空态当锚点)。
- 切格不点 tab(点不到),用状态口 `set_topleft_tab` / `set_bottomright_tab` /
  `set_selected_exchange` —— 这既是测试入口,也是插件侧唯一入口。
- 「等宽 / 贴边 / 无死区」这类几何钉保留,但锚点从 tab 标签换成表头首列(S3:两张表的
  `代码` 表头 x 差 = 数据区半宽)。

**样式纪律的延伸**:`dock.rs` 仍不许出现颜色字面量(V5 机械扫),但**不能只用
`from_egui` 的缺省派生** —— 它把 tab 底取 `visuals.widgets.*`,出来的灰阶与 qss
`QTabBar::tab:top` 不是一回事。`dock::dock_style` 在派生之后把三档钉成 qdark 令牌
(未选中 `BG4` / 选中 `BG5` / 下划线 `ACCENT4`),由 `dock::tests::dock_style_pins_qdark_tab_chrome`
与 S67(这一帧真的画出这三类指令)双侧钉住。**为什么这条重要**:0.4.0 撤掉自绘
`tab_label` 后,dock tab 是唯一的 tab 形态,不钉就是视觉基线静默退化。

**跟着退役的东西(不留死代码)**:`render_market_band` / `render_topleft_group` /
`render_bottomright_group`、`tab_label`、`Chrome` 的四档 tab 色、
`tokens::{MARKET_BAND_HEIGHT, ORDER_SIDE_WIDTH}`(像素定宽在 dock 里不存在,基准改成
`MARKET_PANE_FRACTION` / `ORDER_PANE_FRACTION` 并写明 Python 出处)。

**欠账(不是已完成)**
- 浮动窗里的三张短表(运维插件表 / 服务目录 / 账户清单)仍是 `Grid + header_band`,
  没换 TableBuilder:BD-UI-44 的边界(自动尺寸窗里 `pinned_table` 凑不出正确高度,
  s39 实锤表行与口径句压字)没有被推翻。它们的**表头**已由 s73 钉死(空态也在);
  要不要连「固定表头 + 滚动」一起统一,属产品裁定,未做。
- 「运维 / 账户」窗的表头带宽度缺口 ≤90 px 的旧账(0.2.30 记录)仍未收。

## 0.4.1 — 拆 egui_dock,回归原生容器;dock 拖动的真相与本轮裁定(BD-UI-54)

**BD-UI-54(推翻 BD-UI-53 的容器形态,保留其全部内容面成果)**:从 0.4.0 的
「整窗 dock」退回窗口三件套 —— **TopBottomPanel(行情带) + SidePanel(下单) +
CentralPanel(数据区 2×2 = 自写薄 QSplitter 两刀)**,三条 tab 条(行情六所、
委托|未成交、资金|日志)与成交/持仓两张单 tab 头回归自绘 `tab_label`;
`egui_dock` 依赖与 `src/dock.rs` 撤除。0.4.0 的实质改进**原样保留**:每交易所一格
(六所固定 tab)、资金/行情/服务目录空态带表头(s73)、七张表 chrome、Python 刀序
尺寸(240/300)。

**为什么拆(用户问题的完整链路,证据都在 egui_dock 0.18.0 源码里)**
用户先后问:「dock 拖不出主界面范围,这拖动有什么意义」「如果不用 dock,用什么部署界面」。
两条源码事实把答案钉死:
1. **egui_dock 的「浮窗」不是 OS 窗**:`show_inside` 里
   `window_bounds.get_or_insert(ctx.content_rect())` 加 `drag_and_drop.rs` 两处
   `constrain_rect_to_area(.., window_bounds)` —— 落点预览被摁在**主窗内容区**;它的
   window surface 渲染的是 `egui::Window`(同一 OS 窗内的浮层)。「拖出 OS 窗」这个
   概念在这套坐标平面里不存在;指针出窗后 egui 拿不到 hover 位,松手时无落点 →
   tab 弹回原位(用户看到的现象)。
2. **没有接管点**:`DragData`/`DragDropState` 全是 `pub(super)`,TabViewer 钩子只有
   `title/ui/id/on_close/closeable/allowed_in_windows` —— 「拖动中把手交给 OS 视口」
   做不了,除非 fork。而 OS 窗能力本来就是 eframe 视口的事(K线/合约/账户/运维四窗在用)。

选型普查(egui-ecosystem 经验包 + 外部核对)的结论:留在 egui 仍是最优组合 ——
布局 = 原生面板(对 Python 架构逐件等价);**表格 = `egui_table 0.7.0`**(rerun-io,
egui ^0.33 同代,粘性表头/列、列宽拖、虚拟滚动,下一钉迁入);K线 = egui-charts 0.2.0
(Rust 全场唯一的蜡烛+画线工具+指标引擎,离开 egui 就没了);多 OS 窗 = eframe 视口。
Python 对照:QDockWidget 全部 NoDockWidgetFeatures(连拖都不给),dock 时代的窗内重排
本来就是超出 Python 的自由度 —— 用户裁量后裁定不要。

**新容器的事实源**(`render.rs`,布局 v7):
```text
TopBottomPanel(菜单)
TopBottomPanel::top("market_band") 可拖,缺省 240 = Python setSizes([240,560]) 上带
    └ 六所固定 tab 条(state::MARKET_ORDER)+ 当前所行情表(每所有自己的表与空态)
SidePanel::left("order_side") 可拖,缺省 300 = Python setFixedWidth(300)
    └ 下单面板 + 盘口
CentralPanel
    └ splitter_v(row 0.5):splitter_h(col 0.5){委托|未成交 | 成交}
                            splitter_h{持仓 | 资金|日志}
```
`splitters.rs`(~200 行)= 薄 QSplitter:份额在 `UiShared::data_split`(写侧就地
夹取 0.15..0.85,QSplitter minimumSize 语义),命中带 6px,拖拽/悬停画 ACCENT4 线、
平时 qdark BG4 线(样式令牌单源,V5 钉);纯装饰命中带不进 AccessKit(s39 同口径),
份额断言走状态钉,几何断言走渲染出的表头锚点。「恢复布局」= 三个 tab 态回缺省 +
两刀回 0.5 + 两块顶层面板的记忆尺寸清除(`PanelState` remove)。

**死锁隐患随 dock 一起消失**:`dock_tree` 的 Mutex 与 `dock_tree_try` 的 try_lock
样板全部删除;三个 tab 态(选中交易所 / 左上 / 右下)回到 v5 形态的独立 Mutex 字段,
getter 永远返回可用值(`selected_exchange` 对脏值夹回首所)。

**欠账(不是已完成)**
- **表格迁 `egui_table 0.7.0` 未做**(本轮裁定「表格使用 egui_table」):现有锚定为
  `egui_extras::TableBuilder` + 外层横滚;迁入后监控表吃粘性表头/列宽拖/虚拟滚动,
  选中/排序/行线/空态表头(delegate 不提供)由我们自己的一层补齐。迁入前欠账转为
  BD-UI-55。
- OS 视口「格子浮出」本轮不做(用户裁定)。
- 浮动窗三张短表的统一(上节欠账)与运维/账户窗表头带缺口旧账不变。

## 0.4.1 二期 — 表格层迁 `egui_table 0.7.0`(BD-UI-55,用户裁定「表格使用 egui_table」)

**范围**:十一张表 / 七类形制一次迁完,`egui_extras` 依赖退役(`Cargo.toml` 撤行,全仓
`src` 零引用)。入口层的**调用形状保留**(`body.row(h, |mut r| r.cell(..))`),换的只是
底下引擎:egui_extras 的形制是「推」(调用点闭包直接喂行),egui_table 是「拉」
(`Table` + `TableDelegate` 逐个回调)—— 适配层把「拉」接回「推」。

| 入口(render.rs) | 用在哪 | 形制 |
|---|---|---|
| `pinned_table` | 行情 / 委托 / 成交 / 持仓 / 资金(空态与正常态同一入口) | 无排序、不可拖列 |
| `pinned_table_sorted` | 以上各表的非空态(0.3.1 Wave D 排序) | 排序 + `resize` 开关 |
| `pinned_table_grid` | 合约窗表 | 网格线(全仓唯一,Python `setShowGrid` 口径) |
| `pinned_table_impl_cols` | 日志表(调用方按字体量串传列宽) | 第二入口,`col_current` 接管列宽 |
| `egui::Grid` | 下单表单 / 盘口 / 插件 / 服务目录 / 账户清单 | 浮动窗与表单短表(BD-UI-44 边界不变) |

适配层四件套:`PinBody` / `PinRowAdd`(行、格收集物)、`PinCell = Box<dyn FnMut>`
(**FnMut 不是 FnOnce**,原因见下面第 1 条)、`pin_cell`(格点击感应 + 右对齐数字列走
`ui_ext::num_cell`)、`PinDelegate impl egui_table::TableDelegate`(`header_cell_ui`
表头 + 排序点击、`row_ui` 选中/悬停底色 + 行线、`cell_ui` 每格 6px 左内边距、
`default_row_height`)。表头带仍在 `Table::show` 之前由外层整行画(带 + 底线,z 序在下)。

### 本轮排障撞到的 egui_table / egui 事实(每条都有源码坐标,写下来免得再撞)

1. **首帧同一帧跑两遍**。`Table::show`:`TableState::load(..).is_none()` →
   `do_full_sizing_pass` → 全列 `auto_size_this_frame = true` + `sizing_pass().invisible()`
   + `ctx.request_discard(..)`(table.rs:404-433,`max_passes = 2`)。invisible 那遍的
   形状被 tessellator 吞掉、不进 `FullOutput.shapes`,但 delegate 已被调一遍。格闭包写成
   `FnOnce` 就在这一遍被消费掉,真正落屏的第二遍槽已空 → **表头全在、表体全空**
   (交接报告 §3.2 的症状)。改 `FnMut` 修好。**纪律:新写格内动作必须幂等**
   (同帧重放;现有动作 select_row / set_order_form / 撤单都满足)。
2. **体表按视口剔除行与列,表头不剔列**。`region_ui` 的 `col_range` 由 `ui.clip_rect()`
   推(table.rs:633-648),`row_range` 同理(table.rs:650-666,连 sizing pass 也剔行);
   `header_ui` 不剔列(table.rs:539-627)。→ 滚出视口的**体表格连 delegate 都不调**,
   不进 AccessKit 语义树;表头文字恒在(所以「列名都在」这条判据看不出横向剔除)。
3. **列宽写回只认 `resizable` 列**。`finish` 按 `visible_column_lines` 把
   `used_width = column.range.clamp(max_column_widths[col])` 写回 `ColumnState.width`
   (table.rs:816-843);注册点两处 —— 表头那处带 `clip_rect().x_range().contains(
   header_rect.right())` 闸(table.rs:614),体表那处没有(table.rs:738-749)。→ 首帧把
   所有列临时标 `resizable`(`measuring = TableState::load(..).is_none()`)才能把 17 列
   全量出来存进 `TableState`;第二遍起 `resizable` 回到调用方给的开关(网格线表 /
   `resize` 开关 / `col_current` 表恒可拖)。代价:**不可拖列的表在首帧之后不再随内容长宽**。
4. **`SplitScroll` 的象限调用序** = right_bottom → left_top → right_top → left_bottom →
   finish(split_scroll.rs:79-151)。`num_sticky_cols = 0` 时左两象限零宽,但 delegate
   **照调** → 同一颗表头被画两遍 → AccessKit 双节点(s3/s26/s63/s71 报「成交金额」两处
   就是这个)。几何判据(零宽、`!clip_rect().is_positive()`)分不开「退化象限」与
   「视口外的格」(left_top 的第 ≥1 列 clip 也是负的)→ 表头改用**调用序规则**
   (`header_drawn: Vec<bool>`,第一遍只登记不画),几何判据 `quadrant_is_degenerate`
   只留给 `row_ui`。
5. **`Ui::allocate_rect` 会推进游标** —— 交接报告 §4.1「格内容跑到下一列」的根因。
   改用 `Ui::interact`(ui.rs:1131-1145:只 `create_widget`,`interact_rect = clip ∩ rect`,
   不动布局)→ 格感应层与格内容互不干扰。
6. **`Rect::intersects` 用 `<=`** → 零宽 clip 仍「接触」标签 → `is_rect_visible` 为真;
   而 tessellator.rs:1367 又按 `!clip_rect.is_positive()` 丢形状。合起来 =
   **「形状表里没有、语义树里有」的分裂**:装饰(底色/线)漏不进 `FullOutput.shapes`,
   但 label 的 AccessKit 节点照建(label.rs:279-280 的 `widget_info` 不受可见性闸)。
   本轮多个判据读数都栽在这条上。
7. **`Ui::max_rect()` 会随内容长大**(ui.rs:916 = `placer.max_rect()`)。表 scope 直接
   取 `ui.max_rect()` 会把表画到游标**之上** → 表头带与表错位(s70 实锤:表头 y 568..594
   vs 带 597..632)。改 `.max_rect(Rect::from_min_max(ui.cursor().min, ui.max_rect().max))`。
8. **`Grid::num_columns` 让最后一列吃掉父 ui 剩下的宽**(grid.rs:142-154 + 该 API 自己的
   注释)→ 盘口「量」贴到下单面板右缘、价与量之间空一大截(t8 实锤:十行里只有 3 个量格
   还在盘口块里)。盘口不给列数,列宽回到内容驱动(旧口径 = `Column::auto()`)。
9. **表头带底边 = `top + head_h`**。egui_table 行间距为 0,旧 TableBuilder 在表头与首行
   之间留了一格 `sp.y`;带底照旧多画半间距就切进第一行(实锤:带 407..442 vs 行 0 436..462,
   文字 440..458)。
10. **行线横跨 `band_x`**(带横范围),不是这一行的内容宽。旧口径取体表可视宽;行线只跨
    427..825 而带跨 423..991 时,s72 的「行线必须横跨表宽」只数到 1 根。
11. **每表一层 `id_salt(("pinned_table", table_id))` 的 scope**(交接报告 §3.1 的根治,
    本轮保留):`Table::show` 自己的 scope 不带 id_salt,SplitScroll 内部 `new_child` 的
    ScrollArea auto-id 由父 ui 的 child 计数器派生 → `order_grid` 与 `log_grid` 撞出同一个
    id(17412648412055905594),滚动状态互污 → `show_scroll` 每帧翻转 → repaint 死循环
    (30 个测试 `Harness::run exceeded max_steps`)。

### 口径变更(换库带来的,不是 bug;每条都记死)

- **虚拟滚动**:体表滚出视口的行/列不进语义树(上面第 2 条)。旧 TableBuilder 全列全行
  都建构,裁不裁都在树里。**判据面**:凡是要读「靠右的列」或「靠下的行」的钉,夹具宽度
  决定读不读得到 → 测试层新增 `harness_wide_for`(2600×900)宽夹具(F1 早就这么写,换库后
  从「可选」变「必须」)。
- **斑马**:egui_table 0.7.0 全库没有 `striped` / `faint_bg_color` / 行底色(grep 零命中)。
  行底色只剩「选中 = `visuals.selection.bg_fill`、hover = `widgets.hovered.bg_fill`」一条,
  在我们 delegate 的 `row_ui` 里。qdark 下 `visuals.striped = false` 这条主题口径不变。
- **列宽**:不可拖列只在首帧量一次(上面第 3 条)。
- **`egui_extras` 的 `Column::auto().at_least(24)`** → `Column::new(100.0).range(24.0..=
  f32::INFINITY).id(Id::new(i))` + `AutoSizeMode::Never`(缺省 `Auto` 会逐帧重量,与
  「列宽存进 TableState」这条打架)。

### 判据钉(先红后绿;99 条 kittest 全绿)

本轮把交接时的 73/99 推到 99/99。改写清单(每条都记「为什么新库要这么改」):

| 钉 | 改法 | 为什么 |
|---|---|---|
| `t7_table_builder_row_count_benchmark` → **`t7_table_row_count_and_virtual_scroll`** | 旧:50/200/1000 行都必须 `query_all_by_label` 计数 = n。新:① 面板拉高(1280×2400)装得下 30 行 → 30 颗全在(行不丢);② 1280×860 里 50/200/1000 行建构颗数**相同**(实测 9 / 9 / 9)且 < n;③ 数量级闸照留 | 虚拟滚动(口径变更第 1 条)。颗数一涨 = 虚拟滚动没了 |
| `t5_row_backgrounds_do_not_alternate_under_qdark` | ② 层从「必须透传 `.striped(striped)`」改成「行底色只许走 `visuals().selection.bg_fill` 且源码里不许残留 `.striped(`」;① ③ 两层不动 | 口径变更第 2 条:库没有这条路 |
| `t8_book_frame_price_and_volume_columns_are_aligned` | 从「按 x<280 切下单侧」改成**按行锚取格**(档位标签 = 锚,价/量必须与锚同高、依次在右边) | 同名标签在行情格与盘口之间是常态(夹具里 `tick.volume=1234` 与 `bid_volume_2=1234` 同串),x 切挡不住(实测量格数成 11);顺带把「档/价/量 三格同行」钉住 |
| `s71_contract_table_gridlines_land_on_column_bounds` | 竖线过滤加一道 `y.min >= band.min.y - 2.0` | 主窗 splitter 的分隔线也是 BG4 竖线(x=798,y 278..562),与合约表 y 段重叠;合约表自己的 5 根列线实测 548.03 / 596.91 / 733.94 / 763.97 / 825.0,全在列边界上 |
| `s11` / `s21` / `s23` / `s38` / `f2` / `w1` / `w8` | 换宽夹具 `harness_wide_for`(2600×900),断言一字不改 | 口径变更第 1 条:这些钉读的正是靠右的列(委托号 / 成交号 / 来源 / 最新价 / 已摘除标注 / 撤单钮) |
| `s72`(本轮新增,由临时诊断钉转正) | 行线必须落在相邻两行之间、横跨带宽 ≥80% | 毛病 8「行线切进行里」的回归口 |

脚手架拆除:`[patch.crates-io]`(vendor egui / egui_table 加打点用)、
`tmp/egui-patch`、`tmp/et-patch`、两条临时探针(`x_probe_trade_text_dump` /
`x_probe_contracts_window`)、`tests/x_scratch_probe.rs`、排障期间的 `eprintln!` 打点。

### 性能读数(不当 CI 钉)

旧口径(0.2.29,全行建构)1000 行单帧 dev 208.14 ms / release 26.44–47.60 ms。
本轮 dev 1000 行单帧 **10.0 ms**(1280×860,只建 9 行)。**两个数不能直接比** ——
旧的全建构、新的只建视口行;真正可比的是「同一副 1280×860 夹具、同一判据」下
`harness.run_steps(3)` 的最优样本,本轮实测 10.0 ms(dev)。墙钟阈值照旧不当钉
(0.2.29 已实锤摆动 2–3 倍),`t7` 只留数量级上限(dev 3000 / release 300 ms)。

### 未做 / 待裁定

- 推送 / tag / 发版:仍未做,等用户批准(0.4.0 / 0.4.1 同属一个未发布序列)。
- 不可拖列「首帧后不再随内容长宽」(口径变更第 3 条)是本轮接受的代价;若日后要
  「内容变了列宽跟着变」,入口层给这类表开 `resize` 即可,不必再换库。
- 浮动窗三张短表统一、运维/账户窗表头带缺口旧账:不变。

## 0.4.1 三期 — 界面两处裁定:日志撤「清空日志」钮 / 行情带 tab 条移到表下(BD-UI-56)

### 裁定与范围

用户 2026-10-22 看实窗后提的两条,都是界面面裁定,不动状态层、不动表格引擎:

1. **日志面不要「清空日志」钮** → `render_logs` 里那条 `ui.horizontal` + 钮撤除,
   标签常量 `BTN_CLEAR_LOGS` 一并撤。状态口 `UiShared::clear_logs()`(`state.rs:1876`)
   **保留**:它是状态口,`ui_state.rs::r3` 钉着它;界面不给入口 ≠ 能力撤除。
2. **六所 tab 条放到行情表下面** → `render_market_band` 里 `render_market_pane` 与
   `ui.horizontal(tab 条)` 顺序换过来。

### 一处偏离 Python,记死

Python 侧行情带是 `QTabWidget`(`mainwindow.py:106-204`),缺省 `tabPosition = North`
= tab 在上。这一条**按用户裁定改到表下**,不再以 Python 为准 —— 这是本项目「界面
以 Python 为基准」纪律上的一处显式例外,写在这里免得日后被当成回归。

### 判据面(先红后绿)

- `s0`:「就地操作按钮在场」→「`清空日志` 必须在 **0 处**」。
- `s1_clear_logs_button_local_action` → **`s1_log_surface_has_no_clear_button`**:
  判据从「点钮 → 就地清空 + 幂等」换成「钮整窗查 0 处 + 三行日志与「时间/信息」两列表头
  照常在场」。撤一个钮最容易连带把整面撤掉,所以钉的是「不在场」与「还在场」两半。
- 行情带侧 **零改写**:V1(框架形状)、V2(六所 tab 序 + 缺省高)、s3(横跨全宽 +
  下单格在行情带之下)、W10(排序锚点「今开」/「最新价」同高)取的都是「tab 标签在场」
  与「格内容左沿 / 同高」关系,与 tab 条在表的哪一侧无关。实测改完 99/99 全绿。

### 本轮实测到、没动的毛病:空态表头挤在一起(待裁定)

用户同时问的「表格没有内容时表头全部挤在一起」= 确有其事。探针(临时,已删)实测:

| 事实 | 读数 |
|---|---|
| 首帧空态把列宽记死 | 成交表首帧空态跑 3 帧 → 列宽间距 `[63.3, 28.4, 43.4, 28.4, 28.4, 28.4, 28.4, 57.4, 28.4, 43.4, 43.4]`;之后喂进「螺纹钢2605 / 71230.5 / 142461.0」再跑 3 帧 → **完全不变**。对照(同一份数据在建构前喂好,首帧即有内容)→ `[63.0, 97.0, 47.9, 53.5, 53.3, 70.0, 28.4]` |
| 空态列宽 = 表头文字宽 + 4.4 px,带填不满 | 1280×860 夹具:行情表 14 列表头只铺到 x 612,表头带铺到 x 1276 → **右侧空 664 px**;委托表 17 列反过来溢出带右沿(表头文字越过带边进隔壁格) |

机制(egui_table 0.7.0 源码坐标):`Table::show` 只在 `TableState::load` 为 `None` 的
那一帧做 `do_full_sizing_pass`(table.rs:365-379),`finish` 只给 **resizable** 列写回
宽度(table.rs:816-843,`visible_column_lines` 登记闸在 table.rs:614 / 738-749)。
BD-UI-55 为了保住「列宽随内容」把首帧的列全标 `resizable` → 空态量出来的窄宽被存进
状态,之后这些列不再是 resizable → 永不更新。实窗启动时网关还没数据,**首帧就是空态**,
所以用户在实窗里看到的就是这套窄列。

Python 侧不是这样:`widget.py:646` `resizeSections(ResizeToContents)`,而 Qt
`QHeaderView.stretchLastSection` 缺省 `True` → 最后一列吃掉剩余视口宽度。

两条待裁定的修法(未选,未做):

- **甲(照 Python)**:列常 `resizable`(照 Python `ResizeToContents` 的表用户也能拖)
  → `finish` 的 grow-only 分支(table.rs:840-843)每帧把列宽长到内容宽,「记死」这条
  自然消;再在适配层给空态表补「最后一列拉伸填满带」(照 `stretchLastSection`)。
  代价:每张表都出现可拖列分隔线,判据面里凡按列线定位的钉要重看。
- **乙(列宽下限)**:每张表给一份语义下限(代码 90 / 名称 120 / 价格 80 / 时间 100 …),
  空态也长得像有数据。代价:与「列宽随内容」这条口径打架,且下限表是新造口径。

## 0.4.1 四期 — 皮肤换成 shadcn/ui Nova 深色(BD-UI-57)

### 裁定与范围

用户 2026-10-22 裁定:「其他的样式风格我们就参考之前的 egui-shadcn。**只搬皮肤(色板 +
圆角 + 1px 描边)**」。范围写死:

- **动**:`src/tokens.rs` 的色值与圆角常量;`Chrome` 派生里「带底 / 描边」那一根旧耦合;
  `splitters.rs`、`ui_ext.rs`、K线 `ChartConfig` 的取色(仍走令牌)。
- **不动**:控件层(`selectable_label` / `Button` / `Label` / `TextEdit`)、表格层
  (egui_table 0.7.0 + 我们的 delegate)、语义色(`semantic::*` / `alert::*`)、字号阶梯
  22/16/14、行高 26、布局尺寸(行情带 240 / 下单侧 300)、`apply_cjk_fonts`。
- **不引库**:Cargo.toml 零新依赖。

### 为什么不引库(判据面实测,不是印象)

1. **两个同名库不是一个项目**:`pjankiewicz/egui-shadcn` 0.1.0(不在 crates.io)与
   FerrisMind/shadcn-rs(crates.io 上叫 `egui-shadcn 0.5.0`,默认分支 `master`)。
   用户裁定的是后者。
2. **换控件 = 判据面整片失效**(本机把库装下来跑 AccessKit 探针):库自己的 `button`
   在语义树里 **0 个节点**、`tabs` 触发器 **0**、`select` 占位 **0**;对照原生 egui 按钮
   = 1。根因在库自己 `button.rs:1079`:`allocate_exact_size(Sense::click)` +
   `painter().text` —— 文字是画上去的。全库 `selectable_label` **0 处**、`ScrollArea`
   72 处、`allocate_exact_size` 42 处。我们 99 条 kittest 钉绝大多数靠 AccessKit 名册
   取格 / 取钮 / 取表头 → 判为**不可行**。
3. **引库只用它的 Theme 也不划算**:`~30 行映射` 换 `egui ^0.33 / chrono / log /
   lucide-icons / regex` 五个依赖 + 29,903 行代码 + 它 README 自己写的「API 不稳定,
   必须钉死版本」;而且它的 `Theme::scoped` 是 `ui.scope + set_style`,**不改全局
   `Visuals`** —— 我们整窗的 `window_fill` / `panel_fill` 它管不着。
4. 顺带实测一条好消息:全库 **0 处** `FontData` / `insert_font` → 它不劫持字体,
   我们的 `apply_cjk_fonts` 安全。

### 色板对照(旧 qdarkstyle 3.2.3 → 新 Nova)

| 角色 | 旧(qdark) | 新(Nova) | 源 |
|---|---|---|---|
| 页面 / 面板 / 输入底 `BG1` | `#19232D` Gray.B10 | `#0A0A0A` | `background` |
| 次级底 `BG2`(`faint_bg_color`) | `#293544` B20 | `#171717` | `card` |
| 表头带底 / 未选中 tab 底 / 按钮底 `BG4` | `#455364` B40 | `#262626` | `secondary` = `muted` |
| hover / 选中 tab 底 `BG5` | `#54687A` B50 | `#404040` | `accent` |
| pressed / checked `BG6` | `#60798B` B60 | `#525252` | **本仓合成**(neutral-600) |
| 正文 `TEXT1` | `#DFE1E2` B130 | `#FAFAFA` | `foreground` |
| 次级文本 `TEXT3` | `#ACB1B6` B90 | `#A1A1A1` | `muted-foreground` |
| 选中行 / 选中文本 `ACCENT2` | `#346792` Blue.B40 | `#525252` | 本仓(比 hover 再亮一档) |
| 下划线 / 链接 / 拖拽高亮 `ACCENT4` | `#259AE9` Blue.B70 | `#E5E5E5` | `primary` |
| **一切描边 `BORDER`(新令牌)** | 无(旧 = BG4 同色) | 白 10% → 预乘存 `#1A1A1A_1A` | `border` = `oklch(1 0 0 / 10%)` |

色值源 = 库的 `ColorPalette::shadcn_dark(ShadcnBaseColor::Neutral)`(`Theme::default()`
就是这一套),本机跑探针把 26 支色读出来核对。**没搬的**:ring `#737373`(egui 没有
focus ring 字段可落)、`chart_1..5` `#1447E6/#00BC7D/#FE9A00/#AD46FF/#FF2056`(图表色板
在渲染侧另钉)、`sidebar` 一族(我们没有 shadcn 意义的 sidebar)、`destructive`
`#FF6467`(错误语义走 `semantic::ERROR`,金融语义不玩配色)。

### 圆角与描边

- 旧:一律 4px(qss 2216 行里 `border-radius` 唯一值,出现 48 次)/ 描边 1px。
- 新:**两档** —— 卡片 / 窗 / 菜单 `CORNER_CARD = 10`(`--radius`),控件
  `CORNER_WIDGET = 8`(`calc(var(--radius) - 2px)`)。依据 = 库自带 `DEFAULT_RADIUS`
  刻度实测 `r1 4 / r2 6 / r3 8 / r4 10 / r5 12 / r6 16`,`ControlSize::Sm/Md/Lg` 的控件
  圆角正是 6 / 8 / 10。描边 1px 不变。
- **不搬**库的 `ControlSize` 字号 13/14/15 与行高 32/40/48:字号阶梯 22/16/14 与行高 26
  是 BD-UI-32 用户实看裁定「整体字偏小」定下的,不退。

### 拆掉的一根旧耦合(`Chrome`)

旧 qdark 里 `BG4` **同时**是「表头带底」和「一切描边」(`noninteractive.bg_stroke` =
`#455364` = `QHeaderView::section` 底)→ `Chrome.line` 一支两用。Nova 把这两件事分成
`secondary` `#262626` 与 `border` 白 10% → `Chrome` 多出 `band_fill`,`line` 只管线不管底。
判据面跟着分家:凡按「带填充」定位的钉继续吃 `BG4`,凡按「线」定位的钉(行线、列线、窗框
横线、分隔条平时那根线)改吃 `BORDER`。

### 一条 egui 事实(写下来免得日后误判)

`Color32` 是**预乘 alpha** 存储(ecolor-0.33.3 `color32.rs:8`「0-255 gamma space sRGBA
with _premultiplied alpha_」)。库的 `border` 换算出来是 `#1A1A1A_1A`,那是白 10% 的**正确
形态**,不是库预乘错了。`Color32::from_rgba_unmultiplied` **不是 const**(在 `pub const`
里用报 E0015)→ 常量必须用 `from_rgba_unmultiplied_const`。

### 库的表底色为什么不照抄

库自己 `table.rs:166-167`:`hover_bg = muted.gamma_multiply(0.5)`、`selected_bg =
muted.gamma_multiply(0.7)` → `#131313` / `#1A1A1A`。那是给「透明表体浮在 `card` `#171717`
上」设计的弱档;我们的表体浮在 `background` `#0A0A0A` 上,照抄会糊成一片。搬的是它的
**角色次序**(hover 弱于选中),值取我们色板里够得着的两档:`accent` `#404040`(hover)
→ `#525252`(选中)。`ACCENT2` 与 `BG6`(pressed/checked)同值是**有意**的:两者不会落在
同一类控件上(按下的按钮 vs 选中的行)。

### 判据面(先红后绿)

- 机械改名:测试侧 `tokens::qdark` → `tokens::shadcn`(20+ 处)。
- **U1 整条重钉**(`u1_qdark_visuals_match_qdarkstyle_palette` →
  `u1_visuals_match_shadcn_nova_palette`):Nova 值 + 圆角两档(控件 8 / 窗与菜单 10),
  并**新增两条**:① 底色阶梯必须单调(页面 < 控件 < hover < 选中),挡「把选中调成和
  hover 同值 → 选中的行看不出来」;② `widgets.active.bg_fill == selection.bg_fill`
  是有意设计。
- **U2 针扩到 alpha 构造器**:只数 `Color32::from_rgb(` 会放跑 `BORDER` 这一支。
- 重钉:s42(主题落样式 + `border` 整行横线)、u1(菜单,画面底色字面量 `#0A0A0A`)、
  s10(K线源码扫针 → `shadcn::BG1` / `shadcn::BORDER` / `shadcn::TEXT1`)、s67(带底 /
  下划线 / 选中 tab 底)、t5(斑马)、V5(splitters 必须出现 `shadcn::BORDER` 与
  `shadcn::ACCENT4`,不许出现字面量色)、合约表列线与行线(改吃 `BORDER`)。
- 改名:s42 / u1 / s67 / t5 四条测试名里的 `qdark` 去掉。
- 全绿:kittest **99/99**,全仓 **150 条 0 失败**。

### 未做 / 待裁定

- 空态表头挤(BD-UI-56 记下的两条实测事实)仍未修,等口径裁定。→ **已由五期 BD-UI-58 收口**
  (裁定见下一节)。

## 0.4.1 五期 — 表格形制统一:网格线全表恒在 + 空态列宽按表头比例填满(BD-UI-58)

**用户裁定**(2026-10-22):① 「网格线在黑色背景下反而显得整齐」→ 行线与列线是**全部表**
的固定形制,不再是某张表才开的东西;② 「没有数据时按当前表宽初始化,按表头分配各列比例,
等有数据再根据数据调整」。

### 改前的形制分裂(临时探针实测,1280×860 四帧)

行线只有合约表有(旧 `GridLines::Grid` 这一开关只管它);列线只有合约表 + 日志表 + 右键开关
开着时的成交表有,行情带 / 委托 / 持仓 / 资金一张都没有;`Grid` 短表层(插件表 / 服务目录 /
账户清单)只有表头带下边那一根。空态列宽:行情带表头只铺到带宽 51%(右侧空 663.8 px)、
资金 61%(188.4)、合约窗 83%(95.4)。

### 表格层(`pinned_table` 家族)怎么统一

- **行线**:`GridLines` 枚举删除,`PinDelegate::row_ui` 无条件在行底画 `hline`。
- **列线**:`Column::resizable` **恒开**。库只在 resizable 列上登记可拖边界
  (table.rs:738-749)并画那根竖线(table.rs:896,色 = `noninteractive.bg_stroke` =
  `shadcn::BORDER`)。这条同时把「列宽随内容生长」送到全部表:grow-only(table.rs:840-843)
  本来就只对 resizable 列生效。
- **两条一起换来的是右键「调整列宽」退役**:它唯一的效果是「这一张表能不能拖」,而这件事
  现在是固定形制 → 空壳钮(用户 0.3.3 立 w5 钉时点名的正是这类)。状态口
  `table_resize` / `table_resize_on` / `toggle_table_resize` 一并删。

### 空态列宽:种子必须写进 `TableState`

`header_proportional_widths(ui, headers, available, hint)`:各列表头文字宽(`layout_no_wrap`
+ 12 px 格内边距,下限 24)按比例分配 `available`;首列先容下空态提示那句(`hint` 宽作为首列
下限),其余按剩余宽比例分。**表头总宽已超可用宽时不放大**(委托 17 列 / 持仓 16 列就是这种,
保持横向滚动)。
> 本节是五期当时的做法。六期 BD-UI-59 撤除提示词后 `hint` 参数一并删掉(没有提示要容了),
> 现行签名与口径以下面的六期节为准。

为什么不能只传 `Column::new(seed)`:库只在 `TableState::load` 为 None 时才用 `column.current`
(table.rs:370-375),而那一刻正是首帧;首帧库给每列打 `auto_size_this_frame`(table.rs:377-379),
`finish` 于是走 `*column_width = used_width` 那一支(table.rs:837-839)把种子覆盖掉。实测:
只传种子,空态右侧照样空 663 px。做法 = 在表 scope 内、`Table::show` 之前把种子
`insert` 进 `TableState.col_widths` 并 `store` → `TableState::load` 不是 None → 首帧不做全量
量测 → 种子留在状态里;之后 grow-only 按内容往上长。只在「状态里列宽总和还填不满可用宽」时写,
用户手动拖宽之后不跟他抢。

实测填满率:行情带 51% → **95.1%**、资金 61% → **91.6%**、合约窗 83% → **97%**。
列宽随内容生长(探针复现 BD-UI-56 那条「首帧空态 → 列宽冻死」):成交表空态最宽列 69.4 px →
喂进两笔成交后 97.0 px。

### 短表层(`egui::Grid` + `header_band`)怎么跟上

新 `grid_chrome(ui, band, heads, row_bottoms)`:
- 行线 = 这一行内容底沿往下半个 `item_spacing.y`。Grid 的行间距就是 `item_spacing`
  (grid.rs:113 / 432),半个间距正是两行正中;最后一行那根就是这张表的底边线 —— 与表格层
  「行线画在行底」是同一条不变式(那边行间距为 0)。
- 列线 = 第 2..n 列表头矩形左沿往左半个 `item_spacing.x`。Grid 格内是 LEFT 对齐
  (`align_size_within_rect`),控件左沿就是列左沿。
- 表头行那根下边线仍由 `header_band` 画,不重复。`header_band` 改为返回带矩形。
- 行底从 Grid 闭包里 `end_row` 前的 `ui.min_rect().max.y` 测出来(不需要每格的矩形)。

### 判据面

新增 s74(空态填满 ≥ 85% 带宽)、s75(表格层五张表每张都有行线 + 列线)、s76(三张短表同样)。
s75 / s76 当时的锚点是**空态行文字**(AccessKit 真节点)而不是表头列名:库把视口外的表头列剔掉
(实测委托表 17 列只画到「成交均价」,「状态描述」这一拍没有文字形状),拿被剔掉的列名当锚点
红的是判据自己。(六期 BD-UI-59 撤除提示词后这两钉重钉,锚点见下一节。)w5 改成反向钉:
「调整列宽」必须 0 个节点。s71 / s72 文档里「合约窗是全仓唯一开网格线的表」退役。
kittest **102/102**,全仓 **153 条 0 失败**。

### 一处偏离 Python 记死

Python 交易表 `setShowGrid(False)`。按本裁定反过来:**网格线恒在**,不再以 Python 为准。

### 未做 / 待裁定

- 空态填满只在「这张表这一拍没有数据行」时生效;有数据但内容很窄的表(日志表只有一行短正文
  时右侧仍空)不填满 —— 按裁定「等有数据的时候再根据数据来调整」,这是口径内。
- 列线从表头顶一直画到本可视区底(库的口径),空态时最后一行下面那段空白里也有列线。留着:
  与「网格显得整齐」这条裁定一致,像 Excel 的空格区。
- 推送 / tag / 发版:仍未做,等用户批准。

## 0.4.1 六期 — 空态不再画「(无数据)」提示:表体就是空的(BD-UI-59)

**用户裁定**(2026-10-22,接在五期之后):「表格里面如果没有数据,原来有提示那个无数据等等的
提示词就不要了,它里面就是空的,表格里就是空的,大家都能看到。」

### 形制:空态 = 表头 + 0 行

原来每张表都有一个 `if rows.is_empty() { 画一行提示文字; return; }` 分支(七处表格层 +
三处短表层)。现在**分支本身删掉**:空与非空走同一条路,body 收出 0 行就是空表。

- 表头恒在(s73 / s26 正面那一半不变)—— 撤的是提示行,不是表。
- 0 行就没有行底可画 → **空态没有行线**;列线照常在(库从表头顶画到可视区底,五期口径)。
- 短表层 `grid_chrome` 的 `row_bottoms` 允许为空:此时列线只到表头带底(带里那一段)。

### 空态列宽的判定从「调用方声明」改成「现场数出来」

五期的 `pinned_table_impl_cols` 靠调用方传 `empty_hint: Option<&str>` 知道「这一拍是空态」。
提示撤除后这个参数没有内容可带了 → 改成 `n_rows == 0` 现场判定(body 已经先收完,行数已知):

```text
empty_seed = if n_rows == 0 && col_current.is_none() { 按表头比例分可用宽 } else { None }
seed       = empty_seed.or(col_current)     // 日志表那种「列宽接管」优先
```

写进 `TableState` 的那一步不变(五期实查:只传 `Column::new(w)` 会被首帧
`auto_size_this_frame` 的按内容收缩覆盖)。`header_proportional_widths` 的 `placeholder`
参数一并删掉 —— 首列不再需要容下那句提示。

**填满率因此略降**(这是裁定的直接结果,不是回归):行情带 95.1% → **94.5%**,
资金格 91.6% → **86.3%**(改前 51% / 61%)。s74 的 85% 下限仍然过。

### 判据锚点跟着换(这是本轮最大的判据工程)

s75 / s76 原来拿「空态那一行的文字」当锚点。提示撤除后锚点没了,而表头列名不能直接用
(库把视口外的表头列剔掉,实测委托表 17 列只画到「成交均价」)。新锚点两件套:

1. **首列表头**:四张数据表都以「代码」打头(TICK/ORDER/TRADE/POS_HEADERS[0]),资金表以
   「账号」打头 —— 首列永远在视口里,不会被剔。两道闸挡掉下单表单里的同名字段标签:
   `min.x > 300`(数据区)或 `min.y < 100`(行情带贴窗左沿)。
2. **带的几何定身份**:包住那条表头文字的 BG4 带 = 这张表的带;带按位置分行情带(通窗宽、
   在菜单条之下)/ 委托(上左)/ 成交(上右)/ 持仓(下左)/ 资金(下右)。最后断言五张表都被
   找到 —— 认错了带或者哪张表没了都会红。

并且这两钉现在**喂数据**(tick×2 + 委托/成交/持仓/资金各两行):空态 0 行本来就没有行线可画,
「有数据的表必须有网格线」才是这一钉问的东西。

其余四处拿空态文案当锚点的钉(s26 / s14 / s3 结构钉 / 前台格内容钉)锚点换成表头列名:
行情带用首列「代码」(y < 120 与下单表单那个「代码」分开),资金用「余额」。
s26 同时加反向钉:那八句提示词必须数到 **0** 个节点。

### 判据审计(同一轮自查,探针 `x_probe_empty_grid`,1280×860,四帧,用完即删)

「改完再审一遍:有没有遗漏、判据真挡得住吗」—— 实测出五条,四条是判据自己的洞:

1. **空态有没有列线原来没人钉**。探针实测全空状态下:行情带 14 / 委托 10 / 成交 11 / 持仓 11 /
   资金 5 / 合约窗 5 根列线,从表头顶画到可视区底(带底 58 → 最深 860)→ 五期「网格线恒在」
   在空态成立,但当时没有任何钉覆盖它 → **新增 s77** 专钉这一拍。
2. **行线判据会被杂线蒙过去**:贴 1 行时「≥1 根行线」也绿 —— 带底下那条缝里混着格边框与
   滚动条线(实测委托 / 成交**空态**也数得到 2 根横线)→ 每张贴 **2 行**、下限 **≥2**。
3. **列线下限形同虚设**:原来 3~8 根、实测 9~14 根,少画七八根都不会红 → 提到 4~10。
   下限不钉在实测值上是因为列宽随内容长,可视列数会变。
4. **短表层的锚点会认错带**:kittest 里弹窗位置互相重叠(账户清单带 566..1286×403..425 与
   插件表带 87..727×402..424 在 y 上叠住)→「别名」被认成插件表的带;服务目录的「服务」在
   运维窗里**也是一个小节标题**(`section_title(ui, "服务")`)→ 这个词不能当锚点。
   改法 = **一次只开一个窗** + 带身份要「同一条带里含这张表的多个独有表头」(插件表「版本」+
   「统计」、账户清单「别名」+「行情源」、服务目录三个「实例」列头),列线下限 = 列数 − 1。
   实测:插件表 行线 4 / 列线 5,服务目录 4 / 14,账户清单 2 / 6。
5. **同一判据两份实现会漂**:s75 / s77 的测量代码提成 `pinned_grid_line_counts` +
   `PINNED_GRID_TARGETS`。

工程口径(不是 bug,记死):短表层空态列线只到表头带底,表格层空态列线贯穿到可视区底 ——
短表下面紧跟着别的内容,列线往下延伸会穿过它们,所以这两处的空态**本来就不该一样长**。
非表格的口径句(`(无 tick)` 盘口框、`(全部无依赖)` 依赖区、下单面板那句「无账户:先在…」)
不在裁定范围内,保留。

### 与 Python 的关系

`QTableView` 空表本来也不写提示句(表体就是一片空)—— 这一条与 Python 同形,不是偏离。
五期那条「网格线恒在」才是偏离,仍然记死。

### 未做 / 待裁定

- 空态时表体是一片带列线的空白,没有任何「这里应该有数据」的说明。用户裁定明确要这个;
  若日后要「首次启动的一句引导」,那应该是 toast / 状态条,不是表格里的格子。
- 推送 / tag / 发版:仍未做,等用户批准。

## 0.4.1 七期 — 光标 / 表宽 / 列线 / 滚动条 / 垂直居中(BD-UI-60,2026-10-22;用户五条裁定)

**裁定**:① 鼠标不要随便变形状;② 表宽 = 所在区域 100%,字段该压缩;③ 所有列分割线去掉,
只要行分割线;④ 滚动条尽可能细、暗一些;⑤ 表格里的文字(含表头)上下居中。三个岔口由用户选定:
剩余宽补给**最后一列**、超宽表**保持横向滚动**、hover/拖列时的高亮列线**保留**。

### 先测出来的失败证据(探针两轮,1280×860,用完即删)

- 表宽:行情带内容右沿只到带宽 **60.5%**、资金 **56.7%**、短表 **41.6%**;委托 17 列 / 持仓 16 列
  反过来已溢出(99.2%)。
- 滚动条:`bar_width = 10.0`、`floating = true`、`foreground_color = true` → 轨道 10 px 高、
  把手 `#FAFAFA`(`scroll_area.rs:1421-1425`:`foreground_color` 为真时取 `visuals.fg_stroke.color`)。
- 表头在带里的位置:表格层上隙 11.0 / 下隙 4.0(**偏下**,不是「顶到顶上」);短表层上隙 3.0 /
  下隙 10.0(**才是偏上**)。表格层表体文字上隙 6~8 / 下隙 6~8 —— **本来就居中**。
  ⑤ 这条裁定按实测拆成两处:两层各自的表头要修,表格层表体不动。

### 库级事实(本轮实查,记死)

- `egui_table` 的列线撤不掉:它只在**可拖列边界**上画(`table.rs:738-749` 登记 + `896` 画线),
  而 `resizable` 必须恒开(列宽写回 `TableState` 只认 resizable 列,`finish` 816-843)。
  唯一可行的撤法 = 常态描边变全透明,hover / active 两支留着当拖列反馈。
- 列宽是 **grow-only**(`table.rs:840-843` 只 `max(旧宽, 内容宽)`):列宽记历史最大值,内容变短
  不回收 —— 用户看到的「按最大的那个来做」就是它。
- `finish` 写回的是**这一帧量测到的**宽度;被剔出视口的列量测值会变小 → 「补宽 → 更挤 → 更多列
  被剔 → 存宽更小 → 再补」会滚起来。所以补宽必须**一次只补一次**(状态钉 `table_fill` /
  `stored` / `filled`,只在区域宽或存宽真的变了时重来)。
- 首帧给有数据的表按表头比例下种子会把窄表头列(「时间」「接口」)压成 26 px,格内文字被
  列宽裁掉 → 种子只给 `n_rows == 0` 那一拍,有数据让库做全量内容量测。
- `Grid::min_col_width`(`grid.rs:373`,落到 `min_cell_size.x`,`grid.rs:231` 取
  `max(内容宽, 下限)`)是**下限**不是固定值 → 填不满时铺满、装不下时出横向滚动、不截断文字。
  浮动窗是自动尺寸的:下限取「当帧可用宽」会让窗跟着内容长 → `grid.rs:276-278` 每帧
  `request_repaint` 死循环(实撞 15 条判据)→ 目标宽必须第一帧钉死(`ui_ext::grid_col_width`)。
- 补宽目标宽必须减掉这张表自己的滚动条占位(`bar_width + bar_inner_margin + bar_outer_margin`),
  否则 `Σ列宽` 比可视区还宽,`egui_table` 剔视口外的列,那一列上的撤单钮 / 时间格 / 已摘除标注
  直接消失(实撞 s11 / s38 / w6)。
- `Ui::allocate_ui_at_rect` 在 0.33 已 deprecated(等价 `scope_builder(UiBuilder::max_rect(..))`);
  `Ui::set_cursor` 是 `pub(crate)`,想在格内把文字往下挪只能靠 `add_space` 或换 max_rect 子 ui。
- `Ui::vertical_centered` 名字骗人:它是 `top_down(Align::Center)` → **水平**居中,不管垂直。

### 判据设计

- **反向钉的计数必须与颜色无关**:列线撤除的判据若还按 `BORDER` 一色数竖线,把描边换个颜色
  就糊过去 → 新增 `vlines_any`(任意非空描边),s71 / s75 / s76 / s77 四颗全部改钉「列线 = 0」。
- **② 没有形状落点**:列线撤除后列边界在绘制指令里不存在了,表宽这条只能读状态 →
  `render::table_fill_probe(ctx, table_id) -> (Σ列宽, 目标宽)`,s78 钉「Σ ≥ 目标 9 成」。
- **① 在 kittest 里测不出来**:harness 不模拟 OS 光标(指针放到分隔条中心跑三帧读到 `Default`;
  手动 `set_cursor_icon` 再读能读到 → 读的路径没问题)。这一条钉在源码面 + 真窗实看。

### 与 Python 的关系

Python 交易表 = `resizeSections(ResizeToContents)`(`widget.py:646`)+ Qt `stretchLastSection`
默认 True(全仓只有 `portfoliomanager/ui/widget.py:68` 显式关掉,交易表没关)→ 列按内容量宽、
剩余由最后一列吃掉、表宽恒等于视口宽;超宽不压、出横向滚动。本仓照这个口径。
滚动条「细 + 暗」**不是** Python 对齐:主交易窗没有自定义 `QScrollBar` qss(全仓唯一一处
qss 在 `backtest_visualization/widgets/equity_drawdown_chart.py:1171-1173`,12 px 宽),
这一条是用户新裁定,取 shadcn 色板既有档(BG4 → hover BG5 → 拖 BG6)。
五期「全部表都有列线」被本期部分推翻:行线留、列线撤;五期 / 六期记录不改写。

### 未做 / 待裁定

- 「不改光标」只覆盖我们自己的分隔条。面板 `resizable`(行情带 / 下单侧栏)与列边界可拖这两处
  hover 时仍会变光标(egui / 库内置)。要连这两处也钉死,代价是分栏不能拖、列宽不能随内容生长 ——
  用户没下这个裁定,不动。
- 短表层的「剩余补给最后一列」用的是 `min_col_width` 等分下限,不是 Python 的「最后一列吃掉
  剩余」;视觉上等价(表宽 = 区域宽),但最后一列不会特别宽。要不要完全照 Python,待裁定。
- 推送 / tag / 发版:仍未做,等用户批准。

## 0.4.1 八期 — 表头高度统一:短表层带高 22 → 29(BD-UI-61,2026-10-22;用户提问)

**提问**:「所有表表头的字体大小都一样吗?表头的高度都一样吗?好像菜单里的表头高度不一样。」

**探针实测(9 张表,1280×860,喂数据 + 开三个窗)**:字号本来就一致(全表 `Small` = 14.0);
带高不一致 —— 表格层 29.0 / 短表层 22.0,差 7 px。用户看到的正是短表层那三张
(运维窗插件表、服务目录、账户面板账户清单)。

**根因是两层各自算各自的带高,没有共用公式**:

- 表格层:`head_h`(`table_heights` = `max(Small + 6, INTERACT_HEIGHT)` = 26)+ `clip_rect_margin`
  (3)= 29,带底 = 表头行底边。
- 短表层:「galley 高 + 上下各半个 `item_spacing.y`」= 17 + 3 + 3 ≈ 22。七期 BD-UI-60 只把它
  从「上 0 / 下 6」改成对称,**高度本身没动** —— 这就是七期修完居中后仍然留下的不一致。

**改法**:`header_band` 的带高改用同一条公式(`table_heights(ui).1 + clip_rect_margin`),
带中心取表头行的垂直中心(`heads[0].0.center().y`;Grid 格内 LEFT_CENTER 对齐
(`grid.rs:188-193`)→ label 矩形中心就是行中心)。三张短表加 `.min_row_height(table_heights(ui).0)`
= 26,让短表行高也落到主表那一档(`grid.rs:381` 是下限,内容更高时照样长)。

**判据面**:新增 s79(带高极差 ≤ 1 px 且 = 29 ± 1;11 个独有表头词字号 = `Small` 且带里居中 ± 2 px)。
带身份又踩到 kittest 弹窗重叠那个老坑:「最小变动」被账户窗的带借走,量出上隙 20 / 下隙 0 →
判据改成「取中心离文字最近的带」。BG4 实心矩形里 5 px 高的横向滚动条把手靠高度闸挡掉。

**没改的**:表头字号(本来就一致)。若日后要「表头比体表文字小一号」或「表头不粗」,那是新裁定。


## 0.4.1 九期(2026-10-23):行情带 tab 条归位 / 整行高亮 / 竖分隔条贯穿 / 账户表单单列(BD-UI-62)

**四条用户裁定 + 一条实连验收面裁剪**,全部先取证再改(探针 `x_probe_band`,1280×860;本轮起
用户裁定「不要截图看图,看代码 + 跑 UI 测试」)。

**1. tab 条是固定条,不许跟表格抢地方。** 旧口径 `render_market_band` 先画表(内层 ScrollArea
`auto_shrink([false,false])` = 把整块面板高吃干),tab 条排在它后面 → 被推到面板底之外,与数据区
「委托」tab 头压在一起(实测 tab y 274..300 vs 委托 tab 头 278..304)→ 用户看到的「表格被下面
标签挡住,只有滚动才能看到」。改法:**先算出 tab 条高度 `tab_h`,表在 `scope_builder` 给的子矩形
里画**(`available.y - tab_h`),tab 条再排在剩下的地方。子矩形里先 `add_space(clip_rect_margin)`
—— 子 ui 的 clip 就是那块 max_rect,表从 cursor 顶开始画的话表头带上隙 3 px 会被裁掉(带高 26
而不是 29;s79 当场红过一轮)。tab 标签 = `exchange_tab_name()` 中文名,叫法照 Python
(`xt_gateway.py:1089-1094`、`datafeed/ak/futures_member_pos.py:14-19`)。这是对 Python
`QTabWidget` tabPosition=North 的偏离之一(0.4.1 用户裁定「tab 放表下」),名字这一条是回归 Python。

**2. 高亮 = 整行,不是「有内容的那几格」。** egui_table 给 `row_ui` 的行矩形是
`col_x[0]..=col_x[last]`(`table.rs:689-690`)= 所有列宽并集;列总宽窄于可视区时(接管版列宽的
日志表、两列表)右边就不亮。补法:`row_ui` 里把底色矩形右边补到「与表头带同宽」—— 带宽在 delegate
构造时已捕获(`band_x`),用**宽度**而不是绝对坐标,横向滚动时不会错位。实测高亮块 1265 = 带宽 1265。

**3. 数据区那根竖线必须是一根。** 旧口径 `split_v` 上下分完,再在每排里各来一刀 `split_h` →
「竖刀」实为两条独立线段(上排 278..562、下排 568..852,同一个 x),悬/拖只亮被碰的那半。新
`splitters::quad`:四格由同一套矩形切出来,分隔条 = 一根贯穿横线 + 一根贯穿竖线,命中带也各一条。
份额仍是同一对 `DataSplit { row, col }`(缺省 0.5:0.5 = Python `right_splitter`),`clamp_frac`
界不变。旧的 `split_v`/`split_h`/`run_split`/`rects` 删除(V1 源钉跟着改成 `quad`)。

**4. 账户表单:一字段一行,md 账号/密码不单列。** `acct_form_label` 固定宽标签格(86 px)+
同一行剩下的宽度给数值 → 数值对齐成一列。**不用 `Grid`**:0.2.17 实锤 Grid 列宽在测量期被
「窗宽 / 列数」夹死(每格 ≈120 px),`desired_width` 永不生效,一根真实前置只显示出 "tcp:/"。
md 用户 / md 密码从表单撤掉、`CtpField::MdUser/MdPassword` 退役;`CtpForm.md_user/md_password`
退回成装配侧内部字段(留空 = 回落 td 凭据)。Python 侧核对:全仓没有 `md_user/md_password`,
CTP 配置只有一套账号/密码 + `appID`(`trader/utility.py:339` 一带)—— 我们那两格本来就是多出来的。

**5. CTP 实连验收面不再来回测改密。** `tests/ui_ctp_live.rs` T5 的步 11「错密码 configure」+
步 12「改密再 configure」退役(用户:「我们不用来回测试这个修改密码,我们基本上不会的」)。
「已连接态换凭据 → 守卫复位 → 再登录」这条链的钉在 `ek-ctp/tests/gateway.rs` 的 configure 强制
重连面(纯逻辑面,不烧实连时间)。步号 11/12 空出不重排,判据标签与历史记档保持可比。

**中文名这条的边界(审出来的口径,记死)**:中文名**只用在行情带 tab**。表格里的「交易所」列
和下单侧栏的「交易所」下拉仍是线上代码 —— 那是 Python 口径(`widget.py:1753`
`exchange_combo.addItems([exchange.value …])`,表格里 exchange 走 `EnumCell` = 枚举值)。
「tab 用名字」是用户裁定的一处偏离,不外溢到其它控件;要外溢得另开裁定。

**同一轮自查补的两处**(改完才发现的半成品):① tab 条预留高度原本按 `TextStyle::Small` 算,
而 `tab_label` 的 `selectable_label` 实际走 `TextStyle::Button` + `button_padding` —— 少算一档,
窗口一窄就又会外溢,改成按 Button 那一档算。② 「整行高亮」原本只把**底色**补宽,悬停判定还按
内容宽(`rect_contains_pointer(r)`)—— 等于鼠标放到行尾空余段根本不会亮,裁定只做了一半;
改成判定也用整行矩形(`fill`),s81 的悬停点因此挪到带右缘往左 5 px(实测 Σ 1250 < 带宽 1265,
这个点只在「内容之外、整行之内」,正好钉住这一半)。

**判据层补的一道闸(重要)**:`header_bands` 加圆角闸 `corner_radius == CornerRadius::ZERO`。
`tab_label` 的 tab 头底与表头带**同色同实心**(BG4 + 空描边),tab 条从「表上」移到「表下」之后
就贴在表头带旁边,被 s79 数成表头带(极差 3.0 px = 26.0 的 tab 头混进 29.0 的带里)。表头带一律
`rect_filled(.., 0.0, ..)` 方角,tab 头用 `widgets.inactive.corner_radius` 圆角 —— 这道闸把两者
分开,且与 tab 叫什么名字无关。

**新钉三颗**:s80(tab 中文名 + 在表下 + 不与数据区 tab 头相交 + **点标签真换所**)、
s81(悬停在「内容之外、整行之内」仍亮,且高亮宽 ≥ 带宽)、
s82(悬上排/下排竖线,ACCENT4 竖线覆盖上排表头带顶 → 下排表头带底)。105 → **108 钉**。


## 0.4.1 十期(2026-10-23):柜台原因打到账户行 / 演示登录判据改读状态口(BD-UI-63)

**起因是一次"为什么登不上"的追查**,追查本身给出两条硬证据:

1. **同一份代码、同一对凭据,两套前置结果相反**:仿真套(30001/30011)两账号 TD 链全齐、整条
   `live_two_accounts` 1 passed;7×24 套(40001/40011)上纯 TD 账号走到「连接成功 → 授权验证成功
   → **交易服务器登录失败,代码:3,信息:CTP:不合法的登录**」,而元账号同一时刻 TD+MD 全成。
   结论照 `ek-ctp/LIVE-TEST.md:25`:7×24 与仿真不是同一套交易系统,账号归属决定成败。
2. **演示脚本的登录判据是假的**:仿真那一轮两个账号都登录成功、纯 TD 账号三笔全 `ALLTRADED`
   并有三笔成交,`opening_multi_tour` 的 `logged()` 却报 false/false。根因是它抓日志行
   (`contains(gw) && contains("交易服务器登录成功")`),而日志环只有 512 条,开盘那一拍状态推送
   就涌进 482 条,把登录那几行挤出去。**登录成败的事实源从 0.9.0 起就是 `{gw}.status` →
   `AccountRow.login`**;日志行是给人看的叙述,不是判据。演示脚本三处判据(步3 / 步8b / 步9)
   全部改读状态口。

**界面侧改的口径**:登录被拒时,柜台原因要**跟着红字打在账户行上**。做法是给 `AccountRow` 加
`login_brief` —— 只在告警态(登录失败 / 掉线)由 `apply_status` 从 `td_last_error`(回落
`md_last_error`)抠出来:

- 抠法照 ek-ctp `map::error_text` 的形状(`map.rs:332-333` = `{消息},代码:{id},信息:{正文}`)
  → 取「信息」正文、剥掉 `CTP:` 前缀、代码括在后面 = `不合法的登录(3)`。形状对不上回 `None`:
  **宁可不写原因,也不编一个原因**。
- 状态列 = `已装配·登录失败·不合法的登录(3)`(红字 `ERROR`)。**整句不进状态列**:Grid 列宽被
  长句挤宽会把别的列顶窄(s39 实锤过竖排那一版),整句照旧在窗下方「状态细节」那一行给全 ——
  短标签是补充不是替代,两层都在。
- 这一位不粘:四态重置点(装配中 / 状态口问不到 / 已断开 / 离场作废)与恢复在线都清掉它 ——
  健康行不带着上一次被拒的原因。

**判据**:新 s83 四层(状态列带原因 / 整句仍在状态细节行给全 / 状态侧 `login`+`login_brief` 事实齐
/ 恢复在线后原因不留在行上)。108 → **109 钉**。

## 0.4.3(2026-10-23):应用级审核落地面 —— 观测面不可自摘 + 回执补 arm + 三处零风险收口(BD-UI-64)

起因:用户要求从 ek-station 与 ek-ui 两个下游入口把全链读一遍(各调用、各模块、先后顺序、相互依赖、业务流),查遗漏 / 错误 / 重复 / 多余 / 逻辑问题。判据面 = 代码阅读 + 跨 crate 服务面对账(22 个 oms 服务 + 7 个 ctp 服务 + 3 个 svctl 服务,按请求类型逐个数引用),不出截图、不看可视行为。

### 接住的两条(要改代码的)

- **观测面自身不可自摘(P1)**。运维窗的插件行来自 `svctl.list`,而 `Supervisor::rows()` 就是 `engine.plugin_names()` —— 里面**有 `svctl` 自己也有 `ui` 自己**,原先照画「移除」。两条路都是坑:摘 `svctl` 是单向门(工厂表 `default_kinds` / `ui_kinds` 都不登记 "svctl",摘掉后连『重注入 svctl』也只会回 `unknown factory: svctl`,账户面板的连接/断开/移除全废且没有恢复入口);摘 `ui` 是自己等自己 —— `UiCmd` 一律由 ui 插件自己的 worker 在 eTimer 拍上执行,摘自己只能走到 ek-core `close_one` 那 2 秒超时的 detach 分支,窗口还开着但快照/日志/指令队列全停。现:运维窗对这两行只画「管制面/观测面自身:不在运维窗摘除」+ 保留『重注入』(在场时撞宿主 `Duplicate` → svctl 回拒,无害);`plugin.rs` 的 `UiCmd::Remove` 对 `UI_NAME` 直拒不发 ask。`svctl` 那一侧的闸长在 ek-station 0.2.11(`supervisor.rs::remove_by`),两半各管自己知道的那一半,不重复实现。
- **摘除回执补 `Rejected` arm(P2)**。ek-station 0.2.11 给 `RemoveRes` 增了 `Rejected { name, reason }`;原先非 `UnknownName` 的宿主错被塞进 `NoSuchName { name: format!("{e:?}") }`,而 `wire.rs` 把那一栏文档写明是「原请求名」—— 本 crate 的日志会打成「remove `IllegalName("…")` 不存在/不可摘」,用户请求的名字丢了。增项对 exhaustive `match` 是破坏性的:不补 arm 编译期就挡下,这正是想要的形状。

### 三处零风险收口

- **start 失败也 `close_all(3s)`(P3)**:`run_station_ui` 原先 `engine.start().map_err(..)?` 直接返回,而 ek-station headless main 的同一位置会闭。`start` 是逐个 launch 的,失败时已有插件在 Running —— 不闭就没有 `on_stop`(网关侧会话收尾与流目录处理被跳过)。`boot` 失败不用闭:那时插件只登记未 launch。
- **发布守卫补 6 条(P4)**:`scripts/check-package.ps1` 的必含清单原先只覆盖 16 个 `src/*.rs` 中的 11 个(漏 `tokens.rs` / `toast.rs` / `splitters.rs` / `ui_ext.rs` / `format.rs`)与 10 个测试中的 9 个(漏 529 行的 `tests/ui_converter_chain.rs`)。这些文件被改名或误删时守卫不响,包照发。现 16 + 10 全量。
- **参数冲突不许静默(P8)**:`--pick` 与 `--from` / `--ready-file` 同给时原先 pick 优先、后两者静默忽略 —— 调用方以为传了清单,实际弹的是选窗。现直接 usage exit 2。另把 `launch.rs` 那行串位的文档注释拆开(P9,编译无碍但 docs.rs 上文本是坏的)。

### 记为「服务面有、消费面零」的账(P5,代码零改动)

按请求类型对账出来的零消费面(全链 src 里没有任何 `ask`/`ask_service` 走到它们):`oms.get_tick` / `oms.get_trade` / `oms.get_account` / `oms.get_all_ticks` / `oms.pipeline_status`、`{gw}.query_rate`。其中两条早有裁定写在 Cargo.toml(:26「本 crate 不调费率那口」、:28「`eAccountDone.` 目前仍无消费方,要接等有人提出来再加」),这一轮把整本账记在这里,不再靠零散注释。`oms.pipeline_status` 当初是「可观测性补偿」而界面没有它 —— 要不要在交易监控页显形,等裁定。`{gw}.send_order` / `{gw}.cancel_order` 不算零消费:界面按 BD-UI-30 一律走 oms 五段管线,那是裁定不是遗漏。

### 记为「总线口在、界面不接」的账(P6,代码零改动)

`UiCmd::CtpConfigure` 的 handler 在 `plugin.rs` 活着,但**渲染侧没有任何路径 push 它**(只有 `tests/ui_ctp.rs`、`tests/ui_ctp_live.rs`、`examples/demo_tour.rs` 在用)。四态分离(保存 ≠ 连接)之后,编辑保存只写留档并明说「未重连:点行内『连接/重试』才下发到网关」,于是这条指令成了只有测试在走的路。本轮判:保留为总线口(测试与示例靠它单独验 `{gw}.configure` 而不牵 start),界面不接;要撤还是给它一个入口,等裁定。

### 记为不动的账

- **一拍里的串行 ask(P7)**:一个 eTimer 拍 = 1 次 `svctl.list` + 4 次 oms 全量 ask(各 2 秒超时)+ 每 2 拍按在场账户逐个问 `{gw}.status`。最坏一帧 8 秒以上,期间 `eTick.` / `eBarUpdate.` 堆在 1024 格邮箱,溢出即 `spilled` —— 而 spilled 就在运维窗那一列里显形。判:现状可接受(有显形、有上限),不动。
- **全链零 `deps` 是事实不是缺陷**:dm / oms / ctp / risk / ui 都没覆写 `deps()`,所以宿主的依赖序启动与「仍被依赖」警告目前都用不上;运维窗那句「全部无依赖」是对的。`DynPlugin` 十个方法逐个核过全转发,没有内层覆写被默认值吃掉。
- **`ek-station main.rs` 的 keep-alive(P12)**:stdin 不可用即每 60 秒醒一次、没有任何超时或退出条件 —— 按 BD-U3 的裁定保留(detached 子进程由进程外 kill 终止),不加 `--keep-alive` 开关。下一轮 ek-station 0.2.12 给这一档加了 `--exit-on-eof`(缺省不开)与两条测试钉,默认不变 —— 常驻是本 crate launcher 协议的地基(`spawn_detached` 用 stdin=null)。

## 0.4.4(2026-10-23):四条开放项的裁定(BD-UI-65)

上一轮记的四条账,这一轮按用户裁定处置:接一条、判不接一条、挂一条、记一条。

- **P5-a `oms.pipeline_status` = 接**。落在运维窗插件表下面一节「oms 管线」,不在交易监控页 —— 那一页给业务事实(委托/成交/持仓/资金),这一节给装配事实(五段管线 / 限速与撤单守卫 / 拆单器 / 每道风控闸),混层会把「这笔单被哪道闸挡了」问成「账上有多少笔」。节拍 `PIPELINE_POLL_TICKS = 4` 与状态轮询的 2 拍**分两个计数器**,不共用:两条节拍不同,共用会被相位带着走,判据也各自钉不住。问不到整节不画(这一节没有表、没有可空的东西,与六期 BD-UI-59「空态 = 表头 + 0 行」不是同一种空);日志只在「从有到无」那一次写一条,不每拍冲 eLog 环。
- **P5-b `{gw}.query_rate` = 不接**。判据在类型上:`QueryRateReply { ok, msg }` 不带数值,费率本体经 TD 回调落 `eLog`。在 ek-ui 单方面加钮 = 一个只会写日志的按钮。前置条件记死:要接先要 ek-ctp 给费率快照口(`{gw}.get_rates`)或事件实体(`eRate.`);且 0.9.6 起节律归调用方、三条查询路共用「每连接每秒 1 次」那一个名额,任何钮都必须自带节流(实连套里 margin/commission 两发被隔开 1.1 秒就是这条)。
- **P6 `UiCmd::CtpConfigure` = 本轮不动,0.5 决**。本轮读到的事实改变了判断:`Connect` 对在场实例已经 = `svctl.start`(必回「已在场」)+ `{gw}.configure`,即功能上已包含这条总线口,差别只在 Connect 会动账户行状态(`note_connect_sent`)。于是它的独有价值只剩「不碰 start、不动行、只下发配置」,而界面按四态分离裁定不需要这一档。0.5 波次若仍无界面生产者就撤(公开枚举减项走 0.5,不走补丁位);撤的代价是 `ui_ctp.rs` 那几条 configure 被拒的钉改走 `Connect`,判据纯度会降 —— 那也正是它现在被留着的原因。
- **P7 一拍串行 ask = 挂账(用户裁定暂不做)**。三件候选与代价记在 0.4.3 段:分拍轮转(单拍停顿上限从 `2+8+2N` 降到最慢那一组,代价是交易监控页从 1 秒刷新变 4 秒刷新 —— 用户能感觉到的变化)、超时分级(oms 四问 2s → 1s)、`spilled > 0` 上色 + toast 一次。不动的口径也记死:现状有显形(spilled 列)、有上限(1024 格),丢的是行情帧不是委托。**这一条已被下一版处置,见 0.4.5(BD-UI-66):三件做了、分拍轮转仍挂、composite 与独立线程判为不做;并且读 P7 时新读出一条比节拍更该修的病(超时把四张表清空),那一件也修了。**
- **P12 不在本 crate**:出口长在 ek-station 0.2.12(`--exit-on-eof` + 两条测试钉),默认常驻不变 —— 它是本 crate launcher 协议的地基(`spawn_detached` 用 stdin=null)。

## 0.4.5(2026-10-23):P7 三件先做,分拍轮转挂账(BD-UI-66)

0.4.4 把 P7 挂账之后,先把「一拍里的串行 ask」讲清(带例子),再按裁定动三件、挂一件、否一件。

**账本(读出来的事实,不是推算)**:`ask` 的时序 = 预检目标 Running → 把信封 put 进**目标的邮箱**(与事件同一条 bounded 队列,`ek_core::plugin::MAILBOX_CAP = 1024`,全链没人覆写)→ `recv_timeout`。所以超时咬的从来不是「目标快不快」,而是「目标邮箱前面排了多少条」。ui 只有一个 worker 线程,`refresh()` 里所有 ask 串行。正常一拍 ≈ 5 毫秒(`svctl.list` + 四次内存查询 + 每 2 拍一次只读 status),P7 在正常状态下不存在;坏在拥堵:回报潮把 oms 邮箱排满 → 四个信封排队 → 各自等满时限;摘一台带会话的网关 → `close_one` 占住 svctl worker 最多 2 秒;8 个账户 + 7×24 前置 → status 轮询逐个撞网关查询协调点的 900ms 墙钟护栏。最坏 `1 + 4 + N` 次 ask(N = 在场账户数)全串行。溢出时 `forwarder` 的 `try_send` 丢的是**新到的那一条**,`spilled` 计数 +1,每 128 条才打一条 stderr。

- **P7a(做)超时保旧帧**。读 P7 时新发现的病:`unwrap_or_default()` 把超时变成空帧,四张表闪空 = 「断言此刻没有委托」。现 `merge_frame` 三档:`Ok` 新读数 / `Timeout` 保旧 / 其余四类宿主错清空(事实)。与 `svctl.list` 那侧(超时从不碰快照)对齐,同一个「超时」两处口径不再不一致。判据 `u4`(三档各自判)+ `t4`(真链:摘掉 oms 必须清空且**不**标 stale —— 把「不在场」并进「保旧」就会让一台不存在的 oms 永远留着一帧委托)。
- **P7a 的显形(做)`trade_frame_stale` 状态位 + 迁移式告警**。旧帧必须被标明是旧的。进入 stale 弹一次 Warning toast + 一条日志,持续 stale 不刷,恢复只落日志。位置口径钉死:**不许在委托表上方挂 label**(第五轮审计撤表题的理由 —— 格身份就是 dock tab,再挂同名 label = 三重撞名),走 Foreground 层的 toast。判据 `u5` + `s88`。
- **P7b(做)超时分级**。`OMS_ASK_TIMEOUT = 1s` 只给四张全量表;`svctl.list` 与 `{gw}.status` 留 2 秒(那两口真会慢:摘除窗 2 秒 / 查询协调点 900ms 护栏)。必须与 P7a 同版:单独降时限只会让空帧更频繁。判据 `u8`(源码形状钉 —— 1 秒与 2 秒在测试里判不出来,真链上四口都是毫秒级,两种时限都「过」;所以钉「必须是命名常量 + 四口都吃它 + 慢口不许跟着降」)。
- **P7d(做)溢出当场显形**。`note_plugin_spill`(状态口,与 BD-UI-50 同口径)+ `stat_color` 上色。去重口径:每插件一次,溢满一整轮邮箱(≥ 1024)再来一次 Error。判据 `u6` + `u7`。
- **P7c(挂账)分拍轮转**。一拍只做一组,单拍停顿上限从 `2 + 8 + 2N` 降到「最慢那一组」,代价是交易监控页 1 秒刷新变 4 秒刷新。等裁定。
- **判为不做**:composite 服务(ek-oms 契约增项 + 偏离 Python 基线形状)、`refresh` 挪独立线程(ui 侧引入并发状态写)。
- **钉的松绑与踩坑**:`e6` 的「`push_alert(` 只许 1 处」抬到 4 处且全在状态层(抬数本身要过钉,渲染侧仍由 `e5` 把门)。判据坑两条:`toast_texts` 的 `x ≥ 900` 过滤是为短 toast 调的,长文案卡片从 x≈703 起会被整个筛掉;原子读不许 `AcqRel`。

## 0.5.0(2026-10-23):事件驱动改造 —— 界面不再周期取数(BD-UI-67)

起因 = 用户两问:①「`oms.submit_order`(10 秒时限)…每个账号 gateway 不是独立的吗?我们界面不是和后面分开的吗?你看 python 版本」②「按照 python 版本那种事件驱动,而且 pyside6 和驱动之间只是信号传递不是直接驱动:1、事件驱动,而不是我们界面主动查询;2、后台把信号推送送到就结束,而前台自己收到信号自己处理,不是由后台硬驱动」。方案讨论三轮:第一版被驳回(「怎么搞的怎么复杂。python 版本是怎么处理的。」)→ 对账三件全撤;第二版补 16 面清单与自审 E1–E11;第三版按 M0–M9 定稿后执行。

### 读基线读出来的三件事实(它们决定方案有多小)

1. **Python 的队列无界,所以它没有对账**(`event/engine.py:46 / 108-112 / 59 / 72-76`):`Queue()` 不阻塞、不丢、不计数,handler 在 EventEngine 线程上内联跑。基线**没有**低频对账、没有溢出传感、没有差异修复 —— 第一版方案里那三件是从「我们的邮箱有界」倒推出来的,不是基线的东西。
2. **「后台不硬驱动界面」在链上本来就已经成立**:`ctx.put` → ek-event `crossbeam_channel::unbounded()`(`engine.rs:78`,注释 21-22 写明照 Python `queue.Queue()`)→ 分发线程 → ek-core `Forwarder::forward`「分发线程上执行;永不阻塞」(`forwarder.rs:38-53`)。违反这一条的只有 ek-ui 自己:`refresh()` 里四次同步 ask。
3. **基线的列可变性**(`widget.py:89-98 / 112-113`):`BaseCell.__init__` 先置 `is_update = True`(插入时全列都写)再按表头把 `update: False` 的列改回 False,`set_content` 里 `if self.is_update` 才 `setText`。所以 CTP 后续回报里给空的 `volume / price` 从来没把已经显示的「总量」抹成 0。**整行覆盖就是这个病的 Rust 版本** —— 这一条是四本账合并语义的直接依据。

### 三条纪律(写进 `plugin.rs` 模块头,判据钉在 `tests/ui_ops.rs::u8`)

- **D1 界面不周期取数**。唯一例外 = 观测面 `svctl.list`(基线里对应物是同进程直读,不是推送)。
- **D2 后台只 put、不驱动界面**:`on_mail`(入账 worker)里没有任何 ask;会等的东西只许待在命令线程。
- **D3 命令不是取数**:点击即发,不占 eTimer 拍。

### 落地逐件

- **M1 routes 加四条线**:`eOrder.` / `eTrade.` / `ePosition.` / `eAccount.`(名字单源取 `ek_oms::wire::EVENT_*`,链上一份拼写)。**不订 `eTradeSync.`** —— 那是 ek-oms 处理完成交后重推的同一条成交(`plugin.rs:305-308`),订两条就是同一笔入账两次。done 线(`ePositionDone.` / `eAccountDone.`)是「这一批问完了」的信号,不是数据源,不订。
- **M2 四本 keyed 账**(`state.rs`):`trade_orders` / `trade_trades` / `trade_positions` / `trade_funds`,键 = 基线 `data_key`(`en_orderid` / `en_positionid` / `en_accountid`;成交无键 = 只插不改)。值带行序 `u64`,快照按它**降序**排 = 基线 `insert_new_row` 的 `insertRow(0)`(新事件在前)。合并只走可变列:委托 `name / traded / traded_price / status / status_msg / datetime`,持仓 `volume / yd_volume / can_use_volume / frozen / on_road_volume / market_value / avg_price / price / last_price / pnl / profit_rate / name`,资金 `balance / frozen`;其余列插入后不再被回报改写。平仓 = 写 `volume = 0` 留行,不删行(基线 `hideRow`,`PositionMonitor:1514-1517`)。摘除 / 下线走 `clear_gateway_rows(gw)` 按网关作废。载荷类型不对不静默丢:`note_bad_payload` 计数 + 第一次显形一条日志。
- **M3 refresh 撤四问 + 三处降档**:`svctl.list` 每 `LIST_POLL_TICKS` = 4 拍(第一拍必问);`{gw}.status` 改**摊平** —— 每拍最多问 `STATUS_ASKS_PER_TICK` = 2 个、游标环形推进(8 个账户 4 拍走完一轮,单拍 ask 上限不随账户数线性长);`oms.pipeline_status` 改**事件触发**(`take_pipeline_due()`:启动、`ePlugUp.oms`、运维窗「重问管线」钮)。新装配检测改吃 `ePlugUp.`(即时,不靠轮询回带)。
- **M4 启动一次四问**(`reconcile_trade`):基线无对应物 —— 插件热插拔是 ek 独有场景,界面重启时 oms 的内存账本是唯一能恢复四张表的东西。一次性、不是周期;走 `note_*` **合并**而不是覆盖式播种(网关可能在 oms 消化完之前就已经把委托推给界面)。只在 `on_start` 起的那条命令线程里跑一次。
- **M5 命令线程**(`spawn_cmd_thread`,20ms 空转切片,`cmd_stop` 在 `on_stop` 置位):基线对应物是「UI 主线程直调 `main_engine.send_order`,立即返回」(`engine.py:915-923`)。ek-core 的 ask 是同步的,所以把「等回包」从入账线程挪走;入账线程从此只做「收事件 → 写账本」,每事件 O(1)。
- **M6 邮箱 1024 → `UI_MAILBOX_CAP` = 100 000**:取代对账机制。基线不丢事件所以不需要修复;我们只在 ek-core 这一跳有界,把格子放大到 30 条/秒下约 55 分钟积压,溢出从常态变成异常。溢出仍然当场显形(0.4.5 P7d 的 toast + 运维窗上色),但不再触发自动修复。
- **M7 拆 0.4.5 两件**:`merge_frame` / `OMS_ASK_TIMEOUT` / `trade_frame_stale` / `note_trade_frame_stale` 随四问一起退役(它们唯一的调用点就是四问)。诚实记一句:**0.4.5 的 P7a / P7b 被这一版拆掉**,不是「保留下来」。
- **M8 判据**:见 CHANGELOG 的钉清单。
- **M9 已知边界**:当天历史委托两边都拿不到(全仓 grep `ReqQryOrder|ReqQryTrade` 零命中,CTP 只推实时回报);持仓 / 资金刷新跟着网关的查询协调点走 —— ek-ctp 0.9.8 起是「1 秒闸 + 资金/持仓交替」,实测每类约 2~4 秒一次(带抖动),不再是 1 秒;无自动对账。

### 三条偏离基线(用户裁定接)

1. 持仓行遇合约未就绪**不丢行**(基线 `PositionMonitor.process_event` 是 `if not contract: return` 直接丢);先收进行、合约到位补名称。
2. **启动一次四问**(基线无对应物,插件热插拔是 ek 独有)。
3. 成交账本软上限 `TRADE_CAP` = 5000 行,触顶丢最旧 + 第一次弹一次告警(基线只插不删、无上限)。

### 执行核查改的六件(方案里没有,执行时读出来的)

1. **`reconcile_trade` 从 `on_start` 挪进命令线程**。`on_start` 是 `engine.start()` 启动链的一环(`ek-core main_engine.rs:247`),四口最坏 4 × 2 秒会把后面插件的启动一起拖住;挂在入账 worker 上又违反 D2。
2. **补 `list_due` 标记档**。`svctl.list` 从每拍降到 4 拍之后,「点了移除、行 4 秒不动」会成为常态 —— 插件上/下线与 svctl 起停回执之后置标记,下一拍立刻重问。
3. **溢出判据改逐行各自的容量**。`note_plugin_spill` 与 `render::stat_color` 原先都吃 `ek_core::plugin::MAILBOX_CAP`(缺省 1024);M6 之后 ui 那一行的容量是 100 000,再吃 1024 就会把「ui 丢了 1024 条」说成「溢满一整轮」—— 屏幕上那句是给用户读的事实,说错比不弹更坏。新增 `state::mailbox_cap_of(name)` 作本 crate 侧的唯一事实源(`PlugRow` 不带容量字段,`ek-station wire.rs:30-43`),`stat_color` 加第三个参数 `cap`。
4. **`status_batch` 先按名去重**。账户清册允许两行共用同一个网关(第二行 `svctl.start` 回「已在场」而行仍在清册里),而 `{gw}.status` 是按网关寻址的同一个服务口 —— 不去重就是同一拍白问两次,还把环形节律打乱(两个格子问同一个网关,别的网关这一拍没人问)。
5. **摘掉 oms 之后管线读数必须自己清空**。`pipeline_status` 改事件触发之后没人再周期问它:摘除 oms 时不标记一次,运维窗「oms 管线」那一节会一直显示旧读数(0.4 是每 4 拍问一次,4 秒内自己就空了)。现 `ePlugDown.oms` 置 `request_pipeline_refresh()` → 下一拍问一次 → `TargetGone` 快错 → `poll_pipeline` 的 Err 分支清空并落一条「从有到无」。
6. **网关 / oms 上线之后重播种一次四张表**。`clear_gateway_rows` 作废被摘网关名下的委托与成交行,而 oms 那边那些行还在:重新添加这个账户时不重问一次四口,界面就永久少一段历史(0.4 靠每秒四问天然恢复,这一档是我改出来的洞)。现 `ePlugUp` 命中「oms 或清册里的网关」时置 `seed_due`,命令线程消费;命令线程里**用户指令优先于播种**(播种最坏 4 × 2 秒,不许把点击排到它后面)。顺带覆盖「ui 先于 oms 起来、第一次播种问空了」这一序。

### 退化与代价(不藏)

- 持仓 / 资金刷新从 1 秒变 2–3 秒(跟网关轮转,这是基线的真实节律,不是我们的节拍)。
- 无自动对账(基线语义):溢出真发生时不会自愈,只有显形。
- **持仓表的数据源从「oms 加工后的副本」变回「网关原始推送」**。基线正是后者:`OmsEngine.process_position` 只加工自己那份(`engine.py:1441-1474`),全 trader 只有 `EVENT_TRADE_SYNC`(`engine.py:1439`)与 eLog 两个 put,加工结果不外发;`ek-oms` 同样只有 `eTradeSync.` / `eLog`(`plugin.rs:305 / 356`)。逐列核对后这一条**不是退化而是对齐**:oms 那份由 `PositionHolding.get_position_data`(`converter.py:141-166`)导出,只填 volume / yd_volume / frozen / can_use_volume 四个数,**price 与 pnl 不填** —— 于是 0.4.5 吃 oms 那份时,该合约一旦有成交,持仓均价与盈亏会被导出对象归零。现吃网关那份(`ek-ctp map.rs:585-616` 填 volume / yd_volume / frozen / price / pnl,与 Python `ctp_gateway.py:540-589` 逐列同式):一涨(price / pnl 不再被成交冲掉)一跌(`can_use_volume` 不再显示,基线 CTP 路径本来也不填它)。持平这一列需要 ek-oms 另线重推或 ek-ctp 补 `volume − frozen`,都是跨 crate 增项 —— 挂账。
- 摘掉 oms 不再清空四张表(0.4 的 `t4` 判据随之改向):那些行属于网关,不属于 oms;作废只由 `ePlugDown.<网关>` 触发。

### 判据(本版新增 / 撤除 / 改向)

- 撤:`u4_merge_frame_*`(拆 `merge_frame`)、`u5_trade_frame_stale_*`、`u8_oms_ask_tier_*`(换成形状钉)、kittest `s88_stale_trade_frame_*`。
- 改向:`t4` 从「摘 oms 必须清空」改成「委托行是推来的,不拍任何 eTimer 就该出现;摘 oms 不动它们;`ePlugDown.<网关>` 才作废」。
- 新钉:`u3`(list 节拍 + 标记档 + 状态摊平 + pipeline 标记)、`u4` 可变列合并、`u4b` 新事件在前 / 播种保序、`u4c` 按网关清账、`u4d` 成交触顶 + 只弹一次、`u4e` 持仓 volume 可变且留行、`u4f` 载荷异常计数、`u5` 快照只在脏时重建(同一 `Arc`)、`u6` 溢出容量逐行、`u7` 统计列颜色吃行自己的容量、`u8` 源码形状钉(refresh 零 ask、四口只在 `reconcile_trade`、`UI_MAILBOX_CAP` 命名常量、四条线在 routes、`eTradeSync.` 不在 routes、`merge_frame` / `OMS_ASK_TIMEOUT` 不许留死代码)、kittest `s88` 运维窗「重问管线」钮在场且接线。
- `ui_state.rs::e6` 的告警产生口数仍是 4,但组成换了:`note_gateway_status` 1 + `note_trade` 1 + `note_plugin_spill` 2(0.4.5 的 `note_trade_frame_stale` 那一处随 stale 一起退役)。
- 第二轮核查补的钉:`u3` 加「标记档立刻重问」「同一拍同一网关只许问一次」「播种标记消费性」三条;`u6` / `u7` 按逐行容量重钉;`u8` 加「启动播种走标记」与「`on_mail` 里必须同时有 `request_trade_reseed` 和 `request_pipeline_refresh`」两条形状钉;`t3` 后半段扩成「摘 oms → 那一行没了 → 管线读数清空」。

### 成交表的去重口径(用户裁定 2026-10-23)

**成交按 `en_tradeid` 去重,推送与播种同一条规则。** 口径来自基线**引擎层** `BaseTradeEngine.trades_filter_dict`(`engine.py:1072-1080`,注释写明防的是「账户重连时柜台重推同一笔成交」),与 `ek-oms` 的 `trades_filter`(`oms.rs:203 / 260-264`)同键同口径;基线**表格层** `TradeMonitor.data_key = ""`(:1267)不去重,那是因为它只吃一个事件源。我们的成交表有两个源(网关推送 + 启动 / 上线播种),所以引擎层那一条必须落到界面账本上。

实现:`UiShared::trade_ids`(`Mutex<HashSet<String>>`,键 `{网关}.{成交号}`)在 `note_trade` 入口把关;它跟着账本走 —— 触顶被挤掉的行同时出集(`u4h`)、`clear_gateway_rows` 按 `{网关}.` 前缀同清(`u4g`)、`seed_trade_snap` 灌账时同步灌集。原先为播种单开的 `seed_trade` 撤掉(同一件事不该有两条机制)。判据:`u4g`(同一笔推两次只落一行 / 播种同规则 / 跨网关同号是两笔 / 摘网关后能重新入账)、`u4h`(去重集不许比账本大)。

代价与实话:基线 UI 在柜台重推同一笔时会出重复行,我们不出。屏面更干净,但「柜台重推」这件事从此在成交表上看不见 —— 要看它得去日志面(ek-ctp 的 `成交回报缺少在册委托` 一类)或 ek-oms 的过滤命中。

### 挂账(本版不做)

- **BD-UI-68 持仓加工列持平**(见上,需要跨 crate 决定)。
- **BD-UI-69 观测面 ask 也搬进命令线程**:`svctl.list` / `{gw}.status` / `pipeline_status` 仍在 ui worker 的 eTimer 拍上(摊平后单拍最坏 4 秒,比 0.4 的 24 秒低一个量级,但入账 worker 仍会被它们占住)。彻底做法是把三条 poll 也搬到命令线程,代价是那条线程要自带时间闸。
- **BD-UI-70 `UiCmd::CtpConfigure`**(0.4.4 P6 的「0.5 决」):渲染侧仍无生产者,按裁定应撤;撤是公开枚举的减项,单独一轮做。
- **BD-UI-71 偏离① 只落了一半**:「持仓行遇合约未就绪不丢行」落了(`note_position` 一律收进账本,不看合约在不在);「合约到位补名称」没落 —— 因为**链上没有任何一方填 `PositionData.name`**:`ek-ctp map.rs:609-615` 不填、`ek-oms oms.rs:368-370` 只在来值非空时抄、Python `ctp_gateway.py:551-556` 也不填,所以基线的持仓表「名称」列本来就是空的。要么在 `note_position` 里从界面自己的合约簿回填一行名称(偏离基线、但把①补全),要么把①的这一半撤掉(口径与基线一致)。等裁定。
- **BD-UI-72 资金表少更新一列**:基线 `AccountMonitor` 的可变列是 balance / frozen / **available**(`widget.py:1590-1592`),我们的 `note_funds` 只写 balance / frozen → 「可用」停在首帧值,不跟后续事件走。补一列就是三行的事,等裁定。
- **BD-UI-73 持仓表多更新一列 `name`**:基线 `PositionMonitor` 的 name 是 `update: False`(`widget.py:1482`,首帧写定、之后不再改),我们的 `note_position` 每次覆盖。现在看不出差异(链上没人填 name),但它和 BD-UI-71 咬在一起:一旦做「合约到位补名称」,每 2~4 秒一次的持仓查询会把回填的名称冲掉 —— 要做① 就得先把这一条改掉。
- `tests/ui_ctp_live.rs` 未实跑(无 `EK_CTP_LIVE_TD/MD` 环境):那 17 处 `trade_snap()` 读的是真柜台上账后的账本,判据形状未变,但本轮没有实连证据。

### 链状态显形:账户表「阶段」列(用户定,随 ek-ctp 0.9.8)

用户口径:「这个链进行的状态可以留,改为事件日志推送(界面日志显示,账户列表里也显示)。但是只是推送这个状态,不涉及到前面的逻辑。」落法:

- **事实源 = 推送,不是轮询**:ek-ctp 0.9.8 每次查询阶段变化落一条 `[链] ` 前缀日志(前置已连接 / 授权通过 / 登录成功 / 结算单已确认 / 查询合约 第N次 / 合约已到手开始轮询 / 合约未到手本轮已停止 / 会话已复位)。界面在 eLog 分支里按前缀认出来,存进 `AccountRow::chain_note`(去前缀),账户表多一栏显示。**状态口不为此新增字段** —— 链上没有任何消费方读旧的 `td_rotation_armed`,那个字段随 0.9.8 撤除。
- **它只是显示**:`apply_status` 那套在线/查询断供判据不读它,下单判据也不读它(钉 `u4i`)。日志窗零改动 —— 状态行本来就进日志窗,这一栏只是让它同时在账户行显形。
- **不新增事件线**:走已有的 `eLog.` 一条线,不给总线加按账户按秒的流量(与 D-STATUS「不新增被动掉线事件」同一条纪律)。
- **代价与边界**:状态行是文本,不是状态机 —— 界面不解析它做任何判断;`None` 显示「—」(网关还没推过 = 没推过,不是「一切正常」)。

**BD-UI-74 前缀单源 —— 已结(随 ek-ctp 0.9.8 发布)**:`state::CHAIN_STATE_PREFIX` 原先是本地副本(当时 ek-ui 钉 ek-ctp 0.9.7,那上面没有这个常量)。0.5.0 抬 pin 到 0.9.8 之后改成 `pub use ek_ctp::driver::CHAIN_STATE_PREFIX;` —— 前缀的唯一出处在网关那一侧,界面跟着它编译,不再有两处字面量。

## 背压刀勘误注记(2026-10-27,记档 L 项:施工单 v2 自纠两条)

- **「时序空窗」讲过头**:0.4.x 时代的记录把「合约未到手 → 回报进不来」写成了一段宽窗口。
  实情(0.7.0 甲 + 0.9.8 阶段机之后):回报入账**不依赖合约在册**(D-REPORT-FACTS),
  被挡的只有「合约乘数富化」与「持仓查询相位门」两小段。屏幕上的表现是「行先在场、
  名字与乘数后补」,不是「行看不见」。
- **「每拍 4 次 oms 全量问」不再是现行账**:0.4.3 的写法在 0.5 的事件化之后已退役
  (启动播种一次 + 事件驱动回灌)。本轮第 3 刀把**状态口轮询**也退役(改一次性播种),
  读代码时不要把这两条旧账当现行口径。

---

## 0.8.0 — 配色双档 + 表格全左对齐(BD-UI-61,裁定 8 改判)

用户口径(原话摘要):「现在的配色特别刺眼睛 … 参考这个里面的 … 白色和黑色两款 … 菜单上也要放一个
切换的按钮 … 仿照它这个 http://127.0.0.1:8080/#monitor … 另外我们表格上表头和表的内容对齐方式不一致,
我们全部用左对齐。」方案与逐条裁定见 `docs/ek-ui-theme-alignment-plan.md`,施工步骤见
`docs/ek-ui-theme-alignment-devplan.md`。

**BD-UI-46 的改判(记死,免得读代码的人以为漏了)**:0.2.30 那条「只留一套长相」随本次裁定作废。
0.8.0 起 `Theme` 枚举回来了,但不是当年那五套,而是**两档**:`Dark`(缺省)与 `Light`,
两档各一支 `Palette` + 一支 `Semantic` + 一支 `AlertSkin`,全在 `tokens.rs`(单源纪律不变,
`u2` 机械钉不变:除 `tokens.rs` 外源码里不得出现 `Color32::from_rgb*`)。

**五裁定(落地的就是这五条)**

| 裁定 | 内容 | 落点 |
|---|---|---|
| A | 深色档按参考站(shadcn nova)重调,**换**掉 qdark 那套底/文字值 | `PALETTE_DARK` 字面量 + `u1`/`u9` 钉 |
| B | 缺省 = 深色档(没点过钮 = 现行为零变化) | `Theme::default()` + `u1` 第③半 |
| C | 引入 `TEXT2`(次正文) | 三层文字阶梯,`u9` 钉方向随档 |
| D | 表格**全部左对齐** + 数字走等宽 | 六张表 35 个右对齐格位退役(6 支 `RIGHT_COLS` 常量删除)、`ui_ext::num_cell` 退役、`tokens::num_text` 上齐 34 处(实做基线)、表头吃同一支 `CELL_INSET_X` |
| E | 强调色:浅档蓝 `#405de6` / 深档现中性 `#e5e5e5` | `accent4` 两档各一支 |

**主题这条线怎么落(状态 → 画面 → 盘)**

1. 状态:`UiShared::theme`(`AtomicU8`)+ `toggle_theme()`(翻转 + 置落盘标记 + `rev()` 上拍)。
2. 画面:`apply_ui_style(ctx, theme)` 把档写进 `ctx.data()`,渲染侧一律走
   `tokens::rise/fall/warn/error/ok/info/muted(ctx)`、`palette(ctx)`、`theme_of(ctx)` 取当前档;
   `Chrome::of(visuals, theme)` 的表头文字取 `palette.text3`。
3. 盘:`ns="ui" key="theme"`,只在**用户手点**之后由命令线程写(`take_theme_persist_due()`
   一次性标记 → `refresh()` 里落盘);读在 `on_start` 与启动器构造处各一次(不在每帧读)。
   口径抄参考站:「只有点击才存 localStorage」。

**明码记下的三条代价与破例(不记就是坑)**

- **R7(裁定 D 的代价)**:`format::price` 剥尾零(`101.0→"101"`、`99.7`、`12345.678`),宽度天生不等
  → **左对齐下小数点不在同一竖线上**。等宽只保证「字符宽度一致、位数可数」。判据 `t3`/`t8` 因此
  钉的是**左沿 + 等宽**,不再钉右沿(旧钉钉右沿的那套机制已撤,留着就是假判据)。
- **R11(选中底必须比 hover 再亮一档,BD-UI-57 的阶梯)**:原计划深档选中底 `#20252a` 比 hover
  `#2a3036` **更暗**,把阶梯写反了 → 改的是**色板**不是判据:`ACCENT2 = #343b42`(= `BG6`,
  保持 `widgets.active == selection` 的等式),并把深档语义色整体提亮
  (涨 `#f07575` / 跌 `#42bb46` / 警 `#e08810` / 错 `#dc6060` / 成 `#54ac5c` / 信 `#9aa8c0` / 弱 `#949494`),
  让选中底那一档上的对比度 ≥ 4.0(`u10` 的 T3 层;错/弱两支显式落在 ≥ 3.0 层)。
- **R12(方案 §4 漏的一处落点 = 分隔条)**:`splitters::quad` 原先直写深档那三支常量,浅档下它是
  界面里唯一一块深档 —— `s89` 在浅档帧里抓到两条深档分隔条(2268×6 与 6×614)实锤。改走
  `palette(ui.ctx())`;`V5` 钉随改判:必须出现 `crate::tokens::palette(ui.ctx())`、
  必须出现 `pal.border`/`pal.accent4`,**禁止**再出现深档常量串。
- **范围豁免(裁 D 的作用面)**:等宽只上在**原先右对齐的那 35 个格位 + 盘口**上。成交表
  「时间」列(第 9 列)不在右对齐集合里,**故意没有**改等宽:实撞记录是「等宽 15px 把该列撑宽 →
  挤出视口 → `w6` 的时间标签判据读不到」,这一条写进代码注释与判据注释,读代码时不要当漏。

**判据改动清单(白名单,逐条对过)**:改向 5 条 = `u1`(两文件)、`t3`、`t8`、`V5`、`u4` 的退役清单;
新增 8 条 = `u9`–`u13`(色板字面量/阶梯/对比度 tiers/色相守恒/编码往返/主题状态)、
`s89`–`s93`(双档落屏/带底与描边不互吃/逐列左沿/等宽落屏/K 线跟档);
`w6` 换宽夹具(表头吃内边距 → 12 列成交表在 1280 宽夹具里把「时间」列推出裁剪,不是缺陷);
`c15` 修的是判据自身(按字节切中文注释窗口会 panic 在 char boundary 上,改按字符切,判据不变)。
其余钉一律不许动:包括 `!v.striped`、`visuals().selection.bg_fill` 源码扫描、阶梯钉、
`s3/s8/s23` 全列钉。

**参考站抄了什么、没抄什么**:抄 = 两档 + `background/muted-foreground/border/accent` 的 token 用法
+ 表头与表体同内边距 + 数字等宽 +「点击才持久化」。不抄 = Outfit 字体、box-shadow、badge tint 底、
uppercase 表头、`radius: 0.625rem`(我们的圆角 10/8 是既有口径,BD-UI-32 字号/几何不退)。
`egui_charts` 的 `ChartConfig` 读自己的 RON 令牌 → 双档必须逐支喂(涨/跌/底/网格/文字五支),
判据 `s10` 钉源码形状、`s93` 钉落屏。

**执行轮补的第二处落点(自审抓到)**:`ui_ext::muted_label` 原先直写深档 `TEXT3` 常量 —— 浅档上
就是一块灰不溜秋的次级文字。改走 `palette(ui.ctx()).text3`。同类自查口径记牢:**`shadcn::` 前缀
只许出现在 `tokens.rs`**(注释除外);巡回 C25 / 收编 C25 钉的等宽基线是 34 处(方案那个 37 是
把跨行调用重复数了,以实做为准)。

---

## 0.8.2 — 三层文字的落点补齐 + 行线独立成支(BD-UI-61 续,用户复看)

用户口径(原话):「总感觉风格不对…**表头和表格里的字的颜色就不对**。」
参考站值与逐条对表见 `docs/ek-ui-theme-alignment-plan.md` §12(R16/R17/R18)。

**屏幕量出来的事实(改之前)**:用 kittest 读**本帧文字的着色**,表头「最新价」「交易所」= `text1`,
表体数字「1234」= `text1`,两档皆然 → 裁定 C 的三层在屏幕上塌成一层。三条成因:

1. `visuals_of` 把 `widgets.noninteractive/hovered/active/open/inactive` 的 `fg_stroke` **全设成 `text1`**,
   而 egui 普通 `ui.label` 的颜色源 = `widgets.noninteractive.fg_stroke`(`style.rs:1066`)
   → 六张表的表体打的比参考站 `td`(ink-soft)亮一档。
2. 六张表的表头文字由我们的 delegate `header_cell_ui` 自绘,只写了 `.strong().small()` 没写颜色
   → 取 `strong_text_color()` = `text1`。`Chrome.header_text = text3` 当时只喂给 Grid 短表覆盖带与 tab。
3. 参考站描边**两支**(`th` = `--st-line`,`td` = `--st-line-soft`),我们 0.8.0 一支通吃 → 行线偏重。

**改后的取色口径(两档同一套规则,值随档)**

| 层 | 唯一取色路径 | 深档 | 浅档 | 参考站 |
|---|---|---|---|---|
| 标题 / 强文字 | `visuals().strong_text_color()` = `widgets.active.fg_stroke` | `#eef1f3` | `#221e1f` | `--st-ink` |
| 表体(普通 label、数字格) | `widgets.noninteractive.fg_stroke` = `stroke_body`(= `p.text2`) | `#c7cdd3` | `#303030` | `--st-ink-soft` |
| 次级说明 / 表头文字 | `palette(ctx).text3`(表头在画字处显式上色) | `#8e979f` | `#5b6169` | `--st-ink-muted` |
| 行线 | `Chrome.line_soft` = `palette.border_soft` | `#22272c` | `#eeeeee` | `--st-line-soft` |
| 表头带下边线 / 窗框 / 控件描边 | `Chrome.line` = `palette.border` | `#2a3036` | `#d5dde8` | `--st-line` |

`section_title` 从「吃 `noninteractive.fg_stroke`」改吃 `strong_text_color()`:那一支现在是表体色,
标题跟着走 = 三层塌一层。

**这一轮的判据教训(写死)**:颜色的判据必须钉**落屏的那颗字**。0.8.0 里色板钉(`u9`/`u10`)、
结构体字段钉(`Chrome.header_text == p.text3` 且 `!= p.text1`)、对比度钉全绿,屏幕仍是错的 ——
因为钉的是"值存在",不是"值被用上"。新增:
- `s94`(落屏):双档各测「表头 = text3 / 表体数字 = text2 / 强文字 = text1」且三支两两不等;
- `u14`(源码形状):`stroke_body = p.text2` 必须接在 `widgets.noninteractive` 上、表头画字处必须
  显式 `.color(theme_of(ui.ctx()).palette().text3)`、`ui_ext` 不得再从 `noninteractive.fg_stroke` 取色、
  行线必须吃 `ch.line_soft`、`border_soft` 必须是色板明码两支;
- 巡回 `C26` / 收编 `C26`(现场派生半边)。

随判据对象改向(白名单,无一条删除或放宽):`u1`(文字由"一律 TEXT1"变"两层:noninteractive=表体色、
控件与强文字=正文色)、`s72`/`s75`/`s89`/`s76`(行线的 needle 由 `BORDER` 换 `BORDER_SOFT`,
数线条数、间距、"列线为 0"的任意色反向钉原样保留)。

**0.8.2 收尾追加(R19 → 0.8.3)**:第一次完整巡回(前两轮开窗被取消)抓到巡回侧孪生钉
(`C5f`/`C6`/`C22`/`C18`)没吃到 0.8.1 收编侧的公平口径;修法与四轮实测收口详见 CHANGELOG
0.8.3(20/20 PASS + 7 SKIP,库零改动)。记两条工程教训:①孪生判据改口径必须两边同步落;
②判据的"采证时点"必须比**会淹掉证据的那波推送**更早 —— 环只有 512 行,交易状态广播风暴
就把 A 的 [链] 从环头挤出,连"C1 后立即读"都已经是迟到读数,A/B 登录期 pump 循环逐拍采峰值
才是第一手。

## 0.8.5 — 六表字段按对照 python/CTP 拍板落定(2026-10-28;显示层为主)

对账基线:`enkeep_trade` 六个 Monitor 列集(widget.py:1291 委托 16 / 1246 成交 12 /
1384 持仓 16 / 1560 资金 5 / 合约 ContractManager 9 / 行情 :833-:1000)+ CTP 原始语义
(D-AVAIL 等)。逐表裁定与落地:

- **行情 14→16**:成交额/持仓量进表(字段与填充早就在,纯显示);「今开/行情源」措辞保留。
- **委托 17→16**:「操作」撤单钮列删。撤单三条路全在:行**双击**(Python OrderMonitor
  itemDoubleClicked 等价)、面板「全撤」、账户窗「先撤 N 笔」;判据改向见 CHANGELOG 0.8.5。
- **持仓 16→11**:五个网关不填的死列撤(可用/在途/市值/最新价/盈亏比)——0.4.5 的
  BD-UI-68「加工列持平」挂账与 BD-UI-71/73 的名称纠缠就此**以撤列收案**:不再变现、
  不再留空列。「名称」列保留(名称富化与 C22 骨架照旧;note_position 的 name 覆盖
  语义也因列保留而无变化,BD-UI-71 的"补名称"半边继续挂等裁定)。
- **资金**:「余额」→「权益」(CTP Balance = 盯市权益);`available()` 派生语义不动
  (D-AVAIL 锁测试原样)。
- **合约 5→6**:勾选框列撤,**行点 = 勾选**(行内任一格单击翻转 + 记行选,两支存储
  合一);接口列不做;补合约分类/最小委托量(ContractManager 列集的其余部分)。
- **判据地形的两条经验**:①撤一列会挪邻居的几何 —— f1 的表体-表头 x 窗以前靠
  "身体首格 −60 VIII"的松量,列一变邻居表头漏进窗(recv 2 处同名),改成**吃该表
  自己的 BG4 表头带**为窗(身份面取窗,不靠列数巧合);②C25 的 num_text
  计数基线是逐格加的,列变 = 基线变(34→32,明细在判定句里逐表列清)。


---

## 0.9.2「插件即 App」波记档(2026-10-28 深夜,BD-APP 系列)

用户裁定(2026-10-28 23:59):「每个插件 = 一个 App,带自己的配置参数 + 自己的界面;
有界面时自动挂出,无界面时不加载界面码;参数含 tq/风控/dm/converter;持久化在配置文件」。

- **BD-APP-1 参数面(插件 crate 自带)**:dm(`intervals/real_time/size`)、
  oms(`rate_limit` + 风控五键 `max_volume_per_order/max_position_per_symbol/
  price_band/require_contract/max_notional`)、tq(桥参 + 账户引用键)、
  converter(转换写参数:ns 自持键走视图形态)。全部经 `ek_core::Configurable`
  (ek-core 0.5.1 `cfg_face.rs`)落 `<ns>.ini`(ek-cfg 托底根)。
- **BD-APP-2 界面码 = 插件自带、feature `ui` 缺省关**:dm/oms/tq 各有
  `src/ui_face.rs`(render(ui, plugin, host:&dyn CfgHost, &mut 面态)),
  `[features] ui=["dep:egui"]` 缺省关 → daemon/库构建零 egui 依赖,判据「无界面
  时不加载界面码」= 编译面成立;门禁 lint 吃特性面(界面码坏了不许进包)。
- **BD-APP-3 挂载表归 ek-ui(faces.rs)**〔0.9.10 退役,见文末 BD-APP-13〕:装配名单驱动,逐类型试取
  (`MainEngine::get_typed`),dm/oms/tq 吃自带面、ctp 走视图驱动通用窗口
  (pluginwin.rs);`ui/cfg/因子` 等无配置面成员不出现。插件永不 import 兄弟插件。
- **BD-APP-4 写档单口**:插件面经 `CfgHost`(ek-ui `CfgBridge` 实现)交回宿主,
  存 = ek-cfg 写段 + **立即喂回 `apply_config`**(即时生效键当场生效,待重启键
  显形「待重启」);口令只到 `secret://` 引用,改动引导 `ekapp add-account`(stdin)。
- **BD-APP-5 风控闸运行期重建**:`ek-risk → ek-oms` 单向依赖不成环 ——
  ek-oms 侧放纯数据 `RiskSpec` + 注入 `GateRebuild` 闭包;映射
  (RiskSpec→ek-risk 四闸)住 ek-station(`factory.rs::oms_gate_builder`),
  **ek-risk 零改动**。
- **BD-APP-6 档案包**〔0.9.10 退役,见文末 BD-APP-13〕:导出 = 段文件整批复制(密码在 Windows 凭据管理器,不随包走);
  导入 = 逐段收编 + 过受理面显形(「受理 采纳N/待重启M」)。
- **BD-APP-7 判据**:dm 5 钉、oms 5 钉(含 O-R5 未注入重建器保持装配期形态)、
  ek-ui `tests/ui_pluginwin.rs` 5 钉(挂载表/派发/即时生效/档案包往返/退化形态),
  全打临时根(`Store::with_root`),真实档案零触碰。

## BD-APP-8..12(2026-10-28「启动阶段机 + 装配锁只读」波,裁 B 全链)

- **BD-APP-8 两相**:`runtime::StageApp{Setup,Running}` —— ekapp 桌面开窗**不装配不启动**,
  只出配置面(勾选/装配/参数面/档案包/导出启动清单);「启动运行」才 `render_body` 出主界面。
  无界面面(ek-station daemon)零变化 —— 阶段机是 ek-ui 宿主侧的产品规矩,**底层不写死**(用户裁定)。
- **BD-APP-9 装配锁**:`UiShared::assembly_locked` 纯界面旗(svctl/插件本体不读它)。置位 =
  启动后全壳 `Running`;覆盖面 = 勾选器 + 运维窗摘/注钮(`add_enabled` + 水印「已锁定(运行中)」);
  **不锁参数面**(参数运行期可改裁定不变);**启动失败不锁**(主界面照出,运维面留修路)。
- **BD-APP-10 消重**:ekapp bin 的 boot/start/窗口副本删除,窗口面单源 `runtime::run_desktop`;
  `RunOpts.auto_boot`(缺省 true)保 launcher 协议(ready 文件 + 自动装配启动)。
- **BD-APP-11 导出清单**:只导无界面工厂认得的行(与 `default_kinds` 同源),跳过项显形 ——
  `ui`/`tq` 是 ek-ui 自带工厂,混进 `--from` 会让无界面面直接报错。
- **BD-APP-12 取壳路(裁 B)**:装配壳遮身份面 → `faces` 改走 `MainEngine::plugin_arc`
  (ek-core 0.5.3)+ 壳转发 `as_any`(ek-station 0.4.5;`as_configurable` 早自 0.3.0 就转发)。
  上一波判据用手搭引擎测,破口只在**产品装配路线**上现形 → 本波判据一律打产品取壳路
  (`faces::apply_section`/`faces::dm_intervals`),判据面与产品面同形。

## BD-APP-13(2026-10-29「一钮收口 + 参数面/档案包连根铲」波,用户裁定)

- **一钮两义**:配置面按钮只剩「启动运行」= 未装配先按勾选装配、装配成功才起跑;
  **装配失败 = 正常提示报错、留在配置相等用户**(勾可改、钮可再点;不起跑不锁)。
  「装配(按勾选)/参数面(改参数)/档案包(导入参数)/导出启动清单」四钮全部撤除。
- **连根铲**(用户裁定「功能代码要连根铲」):`src/faces.rs`(挂载表 + CfgBridge +
  render_face/apply_section)、`src/pluginwin.rs`(逐插件配置窗 + 档案包导出导入)、
  `UiShared::{pluginwin_open,bundle_open}`、菜单「参数面/档案包」钮、桌面导出清单口
  —— 整链删除。**BD-APP-3/4/6 与 0.9.5「参数面可改」面随之废止**;插件参数落档
  (BD-APP-1 `Configurable` 写 `<ns>.ini`)与档案托底根本体**不受影响**,参数编辑路
  回归「改档案文件」的既有纪律。
- **判据随动**:`tests/ui_pluginwin.rs` 整文件退役;U-S1c/U-S2b/U-S2c/U-S4a 随功能退场;
  U-S1a/U-S1b/U-S3a/U-S4b 迁移到一钮新脸(U-S4b 顺带修 `set_selected` 对 ui 行的
  隐藏写死 bug:旧写法任何调用都把 ui 行写 false = 数据口能勾掉主界面,现恒勾短路)。
- **不受影响的**:`--pick` 选窗/`--from` 清单/ready 标记(launcher 守护协议)、
  ek-station 工厂表、`Configurable` 受理面、账户档案/密库链路。

## BD-APP-14..17(2026-10-29 配置体系升级「IronClaw 同构」波,四处语义)

> 前排旧文的 `<ns>.ini` 说法(BD-APP-1/13)与 BD-CFG-3/4 的文件形属于**换底前**口径;
> 步骤 4–6 之后存储与秘面整体换成「单库 + 加密入库」,以本节四条为准。

- **BD-APP-14 单库单写手**:全局持久面唯一 = `${config_dir}/ek/cfg/cfg.db`(libSQL 单文件:
  `cfg_items` 段行 + `secret_blobs` 密文行 + `meta` rev)。界面侧口径:
  ① 主题/别名/自动连接位(`ui` 段)经 `ui_cfg_store()` 走 `Store::save`(显名写口);
  ② 账户档读写全走 `appconfig`(`cfg_save_at` / `Store::load`),不再有 `ctp.<gw>.ini` 文件;
  ③ 裸 `Store::store` / `clear_ns` 已收 `pub(crate)` —— 界面仓**编译期**就没有裸写路;
  ④ `ekapp remove-account` = `Store::purge`(段行 + 该段密文一次抹净,无第二份文件可残留)。
- **BD-APP-15 加密与主密钥**:秘面后端 = 加密入库(逐行盐 HKDF-SHA256 → AES-256-GCM,
  AAD 绑 `(ns,key)`);主密钥 `${config_dir}/ek/master.key`(32B,弱钥拒收,缺席 fail-closed)。
  界面侧两条纪律:① 面板只持有引用串(`secret://ek/<ns>/<key>`),明文只在「输入框 → 落库」
  与「解密 → 交给驱动」两处短暂存在;② `ekapp export-bundle/import-bundle` 的包内**不含**
  主密钥,口令一律走 stdin(不进 argv)。
- **BD-APP-16 内存卫生**:口令/密钥一律 `SecretString`(`Debug` 全掩码、`Drop` 擦零、
  取值必须显名 `expose()`);账户编辑面组装 `ConfigureReq` 时不再把裸 `String` 常驻内存。
- **BD-APP-17 装配序定死**:`UiPlugin::deps() = ["cfg"]`,宿主拓扑序保证 `cfg` 最先
  `ePlugUp`;装配清单**必含** `cfg`(`runtime::ui_kinds` 与 `main.rs` 双处注入,
  缺席 = 装配硬失败)。理由:界面首帧要按档还原主题/别名/账户行,配置晚到 = 首帧空。


## BD-APP-18(2026-11-01「插件界面 = 一模块一菜单一窗」波,用户裁定统一框架)

> 本篇**复审并取代 BD-APP-13 的一条结论**:BD-APP-13 把「插件参数编辑」判成
> 回归"改档案文件";本波改为"模块窗内编辑,经 `CfgHost` 落档"。理由:用户要的是
> Python 版那样的插件界面,而不是没有插件界面。

- **BD-APP-18 挂载单位 = 模块**:一个模块(`Plugin::kind()`,缺省 = `name()`)
  = **一个菜单项 = 一扇窗**。菜单项数只随模块数变,不随实例数变
  (`ctp_278153` / `ctp_220380` 共用一项「交易网关」)。窗内怎么分(表 / tab /
  分栏)由模块自己决定。这一条取代 BD-APP-3 的"一实例一面"。
- **BD-APP-19 派发 = 枚举 + match**:`ek_ui::mount::{Shape, Body, ModuleWin, Registry}`;
  新增插件面 = 加一个 `Body` 变体 + `body_of` 一行 + `MODULES` 一行。**不引 trait、
  不建契约新 crate**(用户裁定"原规则、就一个界面包")。`WindowShape::Single` 缺省;
  `PerInstance` 只留字段(逃生门,本期不实现拆菜单)。
- **BD-APP-20 关窗不销毁**:窗开合在挂载表里,窗体态(编辑串、回执)常驻
  (= Python `self.widgets` 字典);再点直接复用,不重建实例。
- **BD-APP-21 交易网关 = 宿主模块**:`ctp` 不再是"每实例一扇配置窗",而是**一扇
  「交易网关」表窗**,一行一个账户(四组列:身份 / 运行期参数 / 运行状态 / 操作)。
  **未装配也恒在**(lways):先建档案再连接是 CTP 的正常用法。身份键**只有这一处
  编辑面**(编辑行 → 保存并连接);收录按行保存 = 读该行段 → 改 → 整段写回,**只碰
  该 `ctp.{gw}` 段**(不做"保存全部",跨段无事务)。
- **BD-APP-22 宿主自有窗并入同一张表**:运维 / 查询合约 / K线 也走 Body::Host,
  
ender_windows 里不再有硬编码 egui::Window::new("运维窗")。开合事实源仍是
  UiShared 的老旗子(挂载表只是视图)。
- **BD-APP-23 分节走元数据**:ConfigField.group(缺省空 = 常规);oms 六键分
  「提交节流」「风控闸」。界面不硬编码键名。
- **BD-APP-24 判据与门禁**:	ests/ui_module_mount.rs m1–m19;scripts/check_plugin_faces.ps1
  = G2(有 ui_face.rs 必有 Body::* 分支,**孤儿面必红**);gate.ps1 加 G1
  (daemon 依赖图零 egui)。**Gate 的存在理由**:2026-10-29 铲掉插件窗时零判据变红。
## BD-APP-25(2026-11-01 用户实测反馈:模块菜单属于「框架」,两相都在)

- **错在哪**:0.9.34 把挂载表菜单只挂进 `render_body`(运行相)。桌面产品从
  **Setup 相**起步(BD-APP-8),于是开窗第一屏顶栏空白 —— 用户看不到
  「运维/查询合约/交易网关/…」,菜单要等点「启动运行」才出现。报告把运行相布局
  当成开窗第一屏,属**陈述性错误**(不是代码没写,是位置放错 + 判据没覆盖)。
- **规矩(本条)**:**一模块一菜单一窗是框架级约定,不随阶段消失**。Setup 相与
  Running 相都必须画同一张 `Registry::menu` + `Registry::windows`;两相差的只是
  中央内容(配置面 ↔ 主界面)。装配前在册的只有 `always` 四扇(交易网关/运维/
  查询合约/K线),插件模块随装配出现 —— 这是"模块在册与否",不是"菜单在不在"。
- **量尺纪律**:`setup_fit_once` 的自然尺寸必须含菜单带(否则菜单比内容宽时被窄窗
  裁掉,用户看到的仍是"没有菜单")。
- **判据纪律**:判「用户第一眼看到什么」的判据**必须驱动真帧**(`Harness::new_eframe`
  → `StageApp::update`),不能直接调 `render_setup_panel` —— 后者只测面本体,
  漏掉帧层组合(0.9.35 `u_s1c` 就是这条的样板)。
- **0.9.36 补的两条硬前提**(「有界面的插件全上菜单」要成立,缺一不可):
  ① 引擎句柄必须在 `StageApp::new_setup` 里就进共享面(`install_engine`)——
  挂载表靠 `engine_arc()` 认模块,句柄缺席 = `remount_modules()` 看到空引擎,
  菜单永远只有 always 四扇;② 装配成功(`do_assemble`)与起跑(`do_start`,
  含半运行态)都要 `remount_modules()` —— 菜单刷新只认这两个时机。
  判据:`u_s1d`(真帧:启动后六项各恰好一项)/ `m21`(七项齐全、两实例不多项)。
- **0.9.37 再补两条**(都是「入口什么时候存在」的问题,用户第二轮实测):
  ① **有界面的插件常驻菜单**:`MODULES` 里 dm/oms/tq 改 `always: true` ——
  菜单项不许等装配才出现(插件缺席时窗口体显示「未装配」,但入口必须在);
  ② **档案在构造时清点**:`StageApp::new_setup` 就用传入 Store 复原一次 ——
  否则配置相打开「交易网关」是空表,用户以为账户丢了(档案一直在盘上)。
  另:配置相不画「连接/断开/移除」(指令要等 ui-cmd 线程存在),操作列只留
  「启动运行后可连」;`UiShared::in_setup()` 记相标记。判据:`u_s1c`(七项)、
  `u_s1e`(起跑前档案在屏幕上)、`m1`(常驻语义)。
- **0.9.39 账户表空白的显形纪律**(用户实测「账户登录上去也没有了」):档案读不出来时,
  网关窗**顶部必须挂橙条**「⚠ 档案读不出来,账户表为空(数据未丢)」(悬停给完整错误),
  运维日志同步带处置建议;**空表不许无声**。真因在本机环境(安全软件拒绝本进程在
  `%APPDATA%\ek\cfg` 建文件 → SQLite 建不出 `cfg.db-wal`),不在代码;`ek-cfg 0.5.8`
  加了 WAL→MEMORY 退档。判据:`u1_store_error_is_visible_in_gateway_window`。
- **0.9.36 补的两条硬前提**(「有界面的插件全上菜单」要成立,缺一不可):
  ① 引擎句柄必须在 `StageApp::new_setup` 里就进共享面(`install_engine`)——
  挂载表靠 `engine_arc()` 认模块,句柄缺席 = `remount_modules()` 看到空引擎,
  菜单永远只有 always 四扇;② 装配成功(`do_assemble`)与起跑(`do_start`,
  含半运行态)都要 `remount_modules()` —— 菜单刷新只认这两个时机。
  判据:`u_s1d`(真帧:启动后六项各恰好一项)/ `m21`(七项齐全、两实例不多项)。

- **0.9.37 再补两条**(都是「入口什么时候存在」的问题,用户第二轮实测):
  ① **有界面的插件常驻菜单**:`MODULES` 里 dm/oms/tq 改 `always: true` ——
  菜单项不许等装配才出现(插件缺席时窗口体显示「未装配」,但入口必须在);
  ② **档案在构造时清点**:`StageApp::new_setup` 就用传入 Store 复原一次 ——
  否则配置相打开「交易网关」是空表,用户以为账户丢了(档案一直在盘上)。
  另:配置相不画「连接/断开/移除」(指令要等 ui-cmd 线程存在),操作列只留
  「启动运行后可连」;`UiShared::in_setup()` 记相标记。判据:`u_s1c`(七项)、
  `u_s1e`(起跑前档案在屏幕上)、`m1`(常驻语义)。

- **0.9.39 账户表空白的显形纪律**(用户实测「账户登录上去也没有了」):档案读不出来时,
  网关窗**顶部必须挂橙条**「⚠ 档案读不出来,账户表为空(数据未丢)」(悬停给完整错误),
  运维日志同步带处置建议;**空表不许无声**。判据:`u1_store_error_is_visible_in_gateway_window`。

- **0.9.40 两条**:① 主界面出来即 `Maximized(true)`(一次性,之后用户接管);
  ② `remount_modules()` 的引擎扫描必须**在挂载表锁外**做完(旧写法持表锁扫引擎,
  插件起停 2~4 秒期间点菜单 = 卡死)。判据:有头实跑 24/24 + 帧推进探针。