ek-ui 0.2.22

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
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
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
//!
//! 布局 v5(BD-UI-29 面板化,官方面板组合;蓝本 = Python 主窗 mainwindow.py):
//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单侧) →
//! CentralPanel(2×2 监控格) → Window 弹窗**,两带物理分离。
//! - 行情带(TopBottomPanel::resizable = Python 分带 splitter 语义,缺省 240):
//!   交易所二级 tab(只列有 tick 的所)+ tick 表(唯一源 = `eTick.`
//!   订阅流,不从 bar 回);行内任意格单击 = 带入下单面板(ek 增值,方案钉;
//!   盘口四列带方向:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
//!   方向保持默认——0.2.1 `import_tick`),行内任意格双击 = 弹 K线窗。
//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
//!   D7 口径)。
//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
//!   流控查询钮随 sim 退场撤除)、
//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
//!   kittest 走回退面断言)。
//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
//!
//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。

use ek_data::constant::{Direction, Offset, OrderType};
use ek_station::wire::PlugRow;

use crate::state::{
    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
};

/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
pub const BTN_CLEAR_LOGS: &str = "清空日志";
/// 取消待确认按钮标签。
pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
/// 下单提交按钮标签(v4 = Python「委托」钮)。
pub const BTN_SEND_ORDER: &str = "委托";
/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
pub const BTN_CANCEL_ALL: &str = "全撤";
/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
pub const BTN_CANCEL_ORDER: &str = "撤单";
/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
pub const BTN_ACCOUNT_ADD: &str = "添加账户";
/// 菜单项:运维(弹运维窗)。
pub const MENU_OPS: &str = "运维";
/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
pub const MENU_ACCOUNTS: &str = "账户";
/// 菜单项:帮助。
pub const MENU_HELP: &str = "帮助";
/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
pub const MENU_CONTRACTS: &str = "查询合约";
/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";

/// F4 口径句(0.2.20,D-UI-F4-SCOPE):账户面板底部与配置弹窗**共用这一条常量**。
/// 两处各写一遍字面量 = 将来不同式的坑(s57 数的是"两处都在")。
pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";

fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
    if armed_on == Some(name) {
        format!("确认移除 {name} ?")
    } else {
        format!("移除 {name}")
    }
}

/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
///
/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
fn subscribe_target(shared: &UiShared) -> Result<String, String> {
    let md = shared.md_source();
    if md.is_empty() {
        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
    }
    Ok(md)
}

fn state_text(r: &PlugRow) -> String {
    r.state.clone()
}

/// DtNs → HH:MM:SS(**北京时间 +08**;数据面时间戳统一存 UTC 纳秒 —— CTP
/// 回报的自然日本就是北京钟面,入账按 +08 换算存 UTC,展示面换回来,表内
/// 时间列与交易员作息同钟面)。
fn fmt_dt(dt: Option<i64>) -> String {
    dt.map(|ns| {
        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
            .with_timezone(&beijing_tz())
            .format("%H:%M:%S")
            .to_string()
    })
    .unwrap_or_else(|| "-".to_string())
}

/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
fn beijing_tz() -> chrono::FixedOffset {
    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
}

// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----

/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
        return;
    };
    let hovered = ui
        .input(|i| i.pointer.interact_pos())
        .is_some_and(|p| union.expand(3.0).contains(p));
    if selected {
        ui.painter().rect_filled(
            union.expand(3.0),
            3.0,
            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
        );
    } else if hovered {
        ui.painter().rect_filled(
            union.expand(3.0),
            3.0,
            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
        );
    }
}

/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
fn order_active(status: ek_data::constant::Status) -> bool {
    ek_data::constant::ACTIVE_STATUSES.contains(&status)
}

/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
}

/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
    let mut f = shared.order_form();
    f.symbol = t.symbol.clone();
    f.exchange = exchange.to_string();
    match col {
        8 => {
            f.direction = Direction::SHORT;
            f.price = format!("{:.1}", t.bid_price_1);
        }
        9 => f.direction = Direction::SHORT,
        10 => {
            f.direction = Direction::LONG;
            f.price = format!("{:.1}", t.ask_price_1);
        }
        11 => f.direction = Direction::LONG,
        _ => {}
    }
    shared.set_order_form(f);
}

/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
    if symbol.is_empty() {
        return None;
    }
    shared.tick_rows().into_iter().find(|t| {
        t.symbol == symbol
            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
    })
}

/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
pub fn apply_ui_style(ctx: &egui::Context) {
    apply_ui_style_for(ctx, crate::state::Theme::default());
}

/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
    ctx.style_mut(|style| {
        style
            .text_styles
            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
        style
            .text_styles
            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
        style
            .text_styles
            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
        style
            .text_styles
            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
        style
            .text_styles
            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
    });
    ctx.set_visuals(theme_visuals(theme));
}

/// 四套主题视觉(BD-UI-34)。金融深色 = egui dark 骨架换深海军蓝底
/// (交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6 Fusion 迁移用。
fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
    use egui::Color32;
    match t {
        crate::state::Theme::EguiDark => egui::Visuals::dark(),
        crate::state::Theme::EguiLight => egui::Visuals::light(),
        crate::state::Theme::QtClassic => {
            let mut v = egui::Visuals::light();
            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
            v
        }
        crate::state::Theme::FinanceDark => {
            let mut v = egui::Visuals::dark();
            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
            v.widgets.noninteractive.bg_stroke =
                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
            v
        }
    }
}

/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
///
/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
/// Window 挂在所有面板之后。
pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
    apply_ui_style_for(ctx, shared.theme());
    // ---- 菜单栏(顶层面板,最先 = 最外) ----
    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
        render_menu_bar(ui, shared);
    });

    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
    egui::TopBottomPanel::top("market_band")
        .resizable(true)
        .default_height(240.0)
        .show(ctx, |ui| {
            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
            egui::ScrollArea::both()
                .id_salt("market_band_scroll")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    render_market_band(ui, shared);
                });
        });

    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
    egui::SidePanel::left("order_side")
        .resizable(true)
        .default_width(250.0)
        .show(ctx, |ui| {
            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
            egui::ScrollArea::vertical()
                .id_salt("order_side_scroll")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    render_order_panel(ui, shared);
                    ui.separator();
                    render_book_frame(ui, shared);
                });
        });

    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
    egui::CentralPanel::default().show(ctx, |ui| {
        let avail = ui.available_size();
        let sp = ui.spacing().item_spacing;
        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
        // Frame+ScrollArea 在预留矩形内才吃满。
        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
        ui.horizontal(|ui| {
            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
                render_topleft_group(ui, shared);
            });
            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
                render_trades_group(ui, shared);
            });
        });
        ui.horizontal(|ui| {
            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
                render_positions_group(ui, shared);
            });
            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
                render_bottomright_group(ui, shared);
            });
        });
    });

    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
    render_windows(ctx, shared);
}

/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
    egui::Frame::group(ui.style()).show(&mut child, |ui| {
        egui::ScrollArea::both()
            .id_salt(id)
            .auto_shrink([false, false])
            .show(ui, |ui| {
                ui.vertical(content);
            });
    });
}

/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
/// = 双滚动条且表头失去固定意义。
/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
fn group_frame_plain(
    ui: &mut egui::Ui,
    _id: &str,
    w: f32,
    h: f32,
    content: impl FnOnce(&mut egui::Ui),
) {
    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
    egui::Frame::group(ui.style()).show(&mut child, |ui| {
        ui.vertical(content);
    });
}

/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
///   request_repaint → kittest max_steps 爆)。
/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
///
/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
fn pinned_table(
    ui: &mut egui::Ui,
    grid_id: &str,
    num_columns: usize,
    headers: &[&str],
    body: impl FnOnce(&mut egui::Ui),
) {
    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
        std::cell::RefCell::new(Vec::new());
    let resp = egui::ScrollArea::both()
        .id_salt(format!("{grid_id}_sc"))
        .auto_shrink([false, false])
        .show(ui, |ui| {
            egui::Grid::new(grid_id)
                .striped(true)
                .num_columns(num_columns)
                .show(ui, |ui| {
                    for h in headers {
                        let r = ui.strong(*h);
                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
                    }
                    ui.end_row();
                    body(ui);
                });
        });
    let heads = heads_cell.borrow();
    if heads.is_empty() {
        return;
    }
    let area = resp.inner_rect;
    let head_h = heads[0].0.height();
    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
    let m = ui.visuals().clip_rect_margin;
    let strip = egui::Rect::from_min_max(
        egui::pos2(area.min.x - m, area.min.y - m),
        egui::pos2(
            area.max.x + m,
            area.min.y + head_h + ui.spacing().item_spacing.y,
        ),
    );
    ui.scope_builder(
        egui::UiBuilder::new()
            .id_salt(format!("{grid_id}_hdr_ov"))
            .max_rect(strip),
        |ui| {
            ui.set_clip_rect(strip);
            let painter = ui.painter();
            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
            let color = ui.visuals().strong_text_color();
            for (rect, text) in heads.iter() {
                painter.text(
                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
                    egui::Align2::LEFT_CENTER,
                    text.as_str(),
                    font.clone(),
                    color,
                );
            }
        },
    );
}

// ================= 菜单栏 =================

fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
    ui.horizontal(|ui| {
        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
        if ui.button(MENU_OPS).clicked() {
            shared.set_ops_window_open(true);
        }
        if ui.button(MENU_ACCOUNTS).clicked() {
            shared.set_accounts_window_open(true);
        }
        if ui.button(MENU_CONTRACTS).clicked() {
            shared.set_contracts_window_open(true);
        }
        ui.menu_button(MENU_HELP, |ui| {
            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
        });
        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
        if ui
            .button(format!("主题:{}", shared.theme().label()))
            .clicked()
        {
            let t = shared.cycle_theme();
            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
        }
    });
}

// ================= 行情带 =================

fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
    let groups = shared.tick_groups();
    if groups.is_empty() {
        ui.label("(等待 eTick 订阅)");
        return;
    }
    // 二级 tab:只列有 tick 的所
    let sel = shared.selected_exchange();
    let active = if groups.iter().any(|(e, _)| *e == sel) {
        sel.clone()
    } else {
        groups[0].0.clone()
    };
    ui.horizontal(|ui| {
        for (ex, _) in &groups {
            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
                shared.set_selected_exchange(ex.clone());
            }
        }
    });
    let ticks = groups
        .iter()
        .find(|(e, _)| *e == active)
        .map(|(_, v)| v.clone())
        .unwrap_or_default();
    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
    // 不需要,行情带 tab + 表格即全部)。
    pinned_table(
        ui,
        "tick_grid",
        14,
        &[
            "代码",
            "名称",
            "最新价",
            "涨跌幅",
            "成交量",
            "今开",
            "最高",
            "最低",
            "买1价",
            "买1量",
            "卖1价",
            "卖1量",
            "时间",
            "行情源",
        ],
        |ui| {
            for t in &ticks {
                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
                let row_sel = shared.order_form().symbol == t.symbol;
                let mut cells = Vec::with_capacity(14);
                let mut dbl = false;
                let resp = ui.button(t.symbol.clone());
                if resp.clicked() {
                    shared.set_selected_exchange(active.clone());
                    import_tick(shared, t, &active, 0);
                }
                dbl |= resp.double_clicked();
                cells.push(resp.rect);
                macro_rules! cell {
                    ($base:expr, $col:expr) => {{
                        let base = $base;
                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
                        if r.clicked() {
                            shared.set_selected_exchange(active.clone());
                            import_tick(shared, t, &active, $col);
                        }
                        dbl |= r.double_clicked();
                        cells.push(base.rect);
                    }};
                }
                cell!(
                    ui.label(if t.name.is_empty() {
                        "-".to_string()
                    } else {
                        t.name.clone()
                    }),
                    1
                );
                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
                cell!(
                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
                        if t.pct_change < 0.0 {
                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                        } else {
                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                        },
                    )),
                    3
                );
                cell!(ui.label(format!("{:.0}", t.volume)), 4);
                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
                cell!(
                    ui.label(
                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                    ),
                    8
                );
                cell!(
                    ui.label(
                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                    ),
                    9
                );
                cell!(
                    ui.label(
                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                    ),
                    10
                );
                cell!(
                    ui.label(
                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                    ),
                    11
                );
                cell!(
                    ui.label(if t.update_time.is_empty() {
                        fmt_dt(Some(t.datetime))
                    } else {
                        t.update_time.clone()
                    }),
                    12
                );
                cell!(ui.label(t.gateway_name.clone()), 13);
                if dbl {
                    shared.open_kline(t.symbol.clone());
                }
                paint_row_bg(ui, &cells, row_sel);
                ui.end_row();
            }
        },
    );
}

// ================= 下单面板(常驻) =================

fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
    let mut f = shared.order_form();
    let before = f.clone();
    let mut err: Option<String> = None;

    // 随价勾选:价格框随该合约最新 tick(勾选态)
    if f.follow_price {
        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
            if t.last_price > 0.0 {
                f.price = format!("{:.1}", t.last_price);
            }
        }
    }

    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
    let mut ex_opts: Vec<String> = shared
        .tick_groups()
        .iter()
        .map(|(e, _)| e.clone())
        .collect();
    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
        if !ex_opts.iter().any(|x| x == e) {
            ex_opts.push(e.to_string());
        }
    }
    if !ex_opts.contains(&f.exchange) {
        ex_opts.insert(0, f.exchange.clone());
    }
    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
    let ts = shared.trade_snap();
    let mut gw_opts: Vec<String> = Vec::new();
    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
    // 进了下拉,选它下单只会撞 `unknown:gateway`。
    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
    let known_gws = account_gateways(shared);
    for g in shared
        .accounts()
        .iter()
        .filter(|a| a.connected)
        .map(|a| a.gateway.clone())
        .chain(
            ts.orders
                .iter()
                .map(|o| o.gateway_name.clone())
                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
        )
    {
        if !g.is_empty() && !gw_opts.contains(&g) {
            gw_opts.push(g);
        }
    }
    if !gw_opts.contains(&f.gateway)
        && !f.gateway.is_empty()
        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
    {
        gw_opts.insert(0, f.gateway.clone());
    }
    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
        f.gateway = shared.default_gateway().unwrap_or_default();
    }
    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
    if f.gateway.is_empty() {
        if let Some(def) = shared.default_gateway() {
            f.gateway = def;
        }
    }
    let no_accounts = gw_opts.is_empty();

    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
    egui::Grid::new("order_form_grid")
        .num_columns(2)
        .show(ui, |ui| {
            ui.label("交易所");
            egui::ComboBox::from_id_salt("of_exchange")
                .selected_text(f.exchange.clone())
                .show_ui(ui, |ui| {
                    for e in &ex_opts {
                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
                    }
                });
            ui.end_row();
            ui.label("代码");
            let sym_resp = ui.add(
                egui::TextEdit::singleline(&mut f.symbol)
                    .hint_text("cu2709")
                    .desired_width(150.0),
            );
            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
            // has_focus 口径回车订阅从不触发)。
            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                let s = f.symbol.trim().to_string();
                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
                let bare = match s.split_once('.') {
                    Some((_, b)) => b.trim().to_string(),
                    None => s,
                };
                if !bare.is_empty() {
                    f.symbol = bare.clone();
                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
                    match subscribe_target(shared) {
                        Ok(gw) => {
                            shared.push_cmd(UiCmd::CtpSubscribe {
                                gateway: gw.clone(),
                                symbols: vec![bare.clone()],
                            });
                            shared
                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
                        }
                        Err(e) => shared.log_line(format!("[订阅] {e}")),
                    }
                }
            }
            ui.end_row();
            ui.label("名称");
            ui.label(
                tick_of(shared, &f.exchange, &f.symbol)
                    .map(|t| {
                        if t.name.is_empty() {
                            "-".to_string()
                        } else {
                            t.name
                        }
                    })
                    .unwrap_or_else(|| "-".to_string()),
            );
            ui.end_row();
            ui.label("方向");
            egui::ComboBox::from_id_salt("of_direction")
                .selected_text(f.direction.as_str())
                .show_ui(ui, |ui| {
                    for d in [Direction::LONG, Direction::SHORT] {
                        ui.selectable_value(&mut f.direction, d, d.as_str());
                    }
                });
            ui.end_row();
            ui.label("开平");
            ui.horizontal(|ui| {
                egui::ComboBox::from_id_salt("of_offset")
                    .selected_text(f.offset.as_str())
                    .show_ui(ui, |ui| {
                        for o in [
                            Offset::OPEN,
                            Offset::CLOSE,
                            Offset::CLOSETODAY,
                            Offset::CLOSEYESTERDAY,
                        ] {
                            ui.selectable_value(&mut f.offset, o, o.as_str());
                        }
                    });
                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
                ui.checkbox(&mut f.auto_split, "自动开平");
            });
            ui.end_row();
            ui.label("类型");
            egui::ComboBox::from_id_salt("of_order_type")
                .selected_text(f.order_type.as_str())
                .show_ui(ui, |ui| {
                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
                    }
                });
            ui.end_row();
            ui.label("价格");
            ui.horizontal(|ui| {
                ui.add(
                    egui::TextEdit::singleline(&mut f.price)
                        .hint_text("107660")
                        .desired_width(90.0),
                );
                ui.checkbox(&mut f.follow_price, "随价");
            });
            ui.end_row();
            ui.label("数量");
            ui.add(
                egui::TextEdit::singleline(&mut f.volume)
                    .hint_text("1")
                    .desired_width(90.0),
            );
            ui.end_row();
            ui.label("接口");
            egui::ComboBox::from_id_salt("of_gateway")
                .selected_text(if f.gateway.is_empty() {
                    "(未选账户)".to_string()
                } else {
                    f.gateway.clone()
                })
                .show_ui(ui, |ui| {
                    for g in &gw_opts {
                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
                    }
                });
            ui.end_row();
            ui.label("操作");
            ui.horizontal(|ui| {
                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
                let send = ui
                    .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
                    .inner
                    .clicked();
                if send {
                    match f.to_request() {
                        Ok(req) => {
                            let fallback = shared.default_gateway();
                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
                                Ok(gateway) => {
                                    shared.push_cmd(UiCmd::SendOrder {
                                        req,
                                        gateway,
                                        mode: if f.auto_split {
                                            ek_oms::LockMode::Auto
                                        } else {
                                            ek_oms::LockMode::None
                                        },
                                    });
                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
                                }
                                Err(e) => {
                                    shared.log_line(format!("[下单] {e}"));
                                    err = Some(e);
                                }
                            }
                        }
                        Err(e) => {
                            shared.log_line(format!("[下单] {e}"));
                            err = Some(e);
                        }
                    }
                }
                if ui.button(BTN_CANCEL_ALL).clicked() {
                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
                    let mut n = 0usize;
                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
                        let gw = if !o.gateway_name.is_empty() {
                            o.gateway_name.clone()
                        } else {
                            shared.default_gateway().unwrap_or_default()
                        };
                        if gw.is_empty() {
                            continue;
                        }
                        shared.push_cmd(UiCmd::CancelOrder {
                            orderid: o.orderid.clone(),
                            gateway: gw,
                        });
                        n += 1;
                    }
                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
                }
            });
            ui.end_row();
            ui.label("代码回车 = 订阅");
            ui.label("");
            ui.end_row();
        });
    if let Some(e) = err {
        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
    }
    if no_accounts {
        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
        ui.label(
            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
        );
    }
    if f != before {
        shared.set_order_form(f.clone());
    }
}

/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
/// 行情带跳色行指认,盘口帧不重复报幕)。
fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
    let f = shared.order_form();
    match tick_of(shared, &f.exchange, &f.symbol) {
        Some(t) => {
            for i in (1..=5).rev() {
                let (p, v) = match i {
                    1 => (t.ask_price_1, t.ask_volume_1),
                    2 => (t.ask_price_2, t.ask_volume_2),
                    3 => (t.ask_price_3, t.ask_volume_3),
                    4 => (t.ask_price_4, t.ask_volume_4),
                    _ => (t.ask_price_5, t.ask_volume_5),
                };
                ui.label(
                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                );
            }
            for i in 1..=5 {
                let (p, v) = match i {
                    1 => (t.bid_price_1, t.bid_volume_1),
                    2 => (t.bid_price_2, t.bid_volume_2),
                    3 => (t.bid_price_3, t.bid_volume_3),
                    4 => (t.bid_price_4, t.bid_volume_4),
                    _ => (t.bid_price_5, t.bid_volume_5),
                };
                ui.label(
                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                );
            }
        }
        None => {
            ui.label("(无 tick)");
        }
    }
}

// ================= 2×2 监控格 =================

fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
    let tab = shared.topleft_tab();
    ui.horizontal(|ui| {
        if ui
            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
            .clicked()
        {
            shared.set_topleft_tab(TopLeftTab::Orders);
        }
        if ui
            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
            .clicked()
        {
            shared.set_topleft_tab(TopLeftTab::Active);
        }
    });
    render_orders_table(ui, shared, tab == TopLeftTab::Active);
}

/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
/// 活动行撤单钮 + 双击撤单,终态行只读)。
fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
    let rows: Vec<_> = ts
        .orders
        .iter()
        .filter(|o| !active_only || order_active(o.status))
        .collect();
    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    const ORDER_HEADERS: [&str; 17] = [
        "代码",
        "名称",
        "交易所",
        "类型",
        "方向",
        "开平",
        "价格",
        "总量",
        "已成",
        "成交均价",
        "状态",
        "状态描述",
        "时间",
        "委托号",
        "来源",
        "接口",
        "操作",
    ];
    if rows.is_empty() {
        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
        pinned_table(
            ui,
            if active_only {
                "active_grid"
            } else {
                "order_grid"
            },
            17,
            &ORDER_HEADERS,
            |ui| {
                ui.label(if active_only {
                    "(无未成交)"
                } else {
                    "(无委托:等网关 send_order)"
                });
            },
        );
        return;
    }
    pinned_table(
        ui,
        if active_only {
            "active_grid"
        } else {
            "order_grid"
        },
        17,
        &ORDER_HEADERS,
        |ui| {
            for o in &rows {
                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
                let key = format!("{}.{}", o.gateway_name, o.orderid);
                let row_sel = shared.row_selected("orders", &key);
                let mut cells = Vec::with_capacity(17);
                let mut dbl = false;
                let sel = ui.selectable_label(row_sel, o.symbol.clone());
                if sel.clicked() {
                    shared.select_row("orders", &key);
                }
                dbl |= sel.double_clicked();
                cells.push(sel.rect);
                macro_rules! cell {
                    ($base:expr, $col:expr) => {{
                        let base = $base;
                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
                        if r.clicked() {
                            shared.select_row("orders", &key);
                        }
                        dbl |= r.double_clicked();
                        cells.push(base.rect);
                    }};
                }
                cell!(
                    ui.label(if o.name.is_empty() {
                        "-".to_string()
                    } else {
                        o.name.clone()
                    }),
                    1
                );
                cell!(ui.label(o.exchange.as_str().to_string()), 2);
                cell!(ui.label(o.order_type.as_str().to_string()), 3);
                cell!(
                    ui.label(
                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
                            if o.direction == Some(Direction::SHORT) {
                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                            } else {
                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                            },
                        ),
                    ),
                    4
                );
                cell!(ui.label(o.offset.as_str().to_string()), 5);
                cell!(ui.label(format!("{:.1}", o.price)), 6);
                cell!(ui.label(format!("{:.0}", o.volume)), 7);
                cell!(ui.label(format!("{:.0}", o.traded)), 8);
                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
                cell!(
                    ui.label(egui::RichText::new(o.status.as_str()).color(
                        if o.status == ek_data::constant::Status::REJECTED {
                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                        } else if o.status == ek_data::constant::Status::ALLTRADED {
                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                        } else {
                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
                        },
                    )),
                    10
                );
                cell!(ui.label(o.status_msg.clone()), 11);
                cell!(ui.label(fmt_dt(o.datetime)), 12);
                cell!(ui.label(o.orderid.clone()), 13);
                cell!(
                    ui.label(if o.reference.is_empty() {
                        "-".to_string()
                    } else {
                        o.reference.clone()
                    }),
                    14
                );
                cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
                if dbl && order_active(o.status) {
                    // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
                    let gw = if !o.gateway_name.is_empty() {
                        o.gateway_name.clone()
                    } else {
                        shared.default_gateway().unwrap_or_default()
                    };
                    if !gw.is_empty() {
                        shared.push_cmd(UiCmd::CancelOrder {
                            orderid: o.orderid.clone(),
                            gateway: gw,
                        });
                    }
                }
                let hr = ui.horizontal(|ui| {
                    if order_active(o.status)
                        && ui
                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
                            .clicked()
                    {
                        // 撤单跟随委托的来路网关(行级 gateway_name;
                        // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
                        let gw = if !o.gateway_name.is_empty() {
                            o.gateway_name.clone()
                        } else {
                            shared.default_gateway().unwrap_or_default()
                        };
                        if gw.is_empty() {
                            shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
                        } else {
                            shared.push_cmd(UiCmd::CancelOrder {
                                orderid: o.orderid.clone(),
                                gateway: gw,
                            });
                        }
                    }
                });
                cells.push(hr.response.rect);
                paint_row_bg(ui, &cells, row_sel);
                ui.end_row();
            }
        },
    );
}

/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("成交");
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    const TRADE_HEADERS: [&str; 12] = [
        "成交号",
        "代码",
        "名称",
        "交易所",
        "方向",
        "开平",
        "价格",
        "数量",
        "成交额",
        "时间",
        "委托号",
        "接口",
    ];
    if ts.trades.is_empty() {
        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
        pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
            ui.label("(无成交)");
        });
        return;
    }
    pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
        for t in &ts.trades {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
            let key = format!("{}.{}", t.gateway_name, t.tradeid);
            let row_sel = shared.row_selected("trades", &key);
            let mut cells = Vec::with_capacity(12);
            let sel = ui.selectable_label(row_sel, t.tradeid.clone());
            if sel.clicked() {
                shared.select_row("trades", &key);
            }
            cells.push(sel.rect);
            macro_rules! cell {
                ($base:expr, $col:expr) => {{
                    let base = $base;
                    let r = click_cell(ui, base.rect, ("trd", &key, $col));
                    if r.clicked() {
                        shared.select_row("trades", &key);
                    }
                    cells.push(base.rect);
                }};
            }
            cell!(ui.label(t.symbol.clone()), 1);
            cell!(
                ui.label(if t.name.is_empty() {
                    "-".to_string()
                } else {
                    t.name.clone()
                }),
                2
            );
            cell!(ui.label(t.exchange.as_str().to_string()), 3);
            cell!(
                ui.label(
                    egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
                        if t.direction == Some(Direction::SHORT) {
                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                        } else {
                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                        },
                    ),
                ),
                4
            );
            cell!(ui.label(t.offset.as_str().to_string()), 5);
            cell!(ui.label(format!("{:.1}", t.price)), 6);
            cell!(ui.label(format!("{:.0}", t.volume)), 7);
            cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
            cell!(ui.label(fmt_dt(t.datetime)), 9);
            cell!(ui.label(t.orderid.clone()), 10);
            cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
            paint_row_bg(ui, &cells, row_sel);
            ui.end_row();
        }
    });
}

/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
/// Python mainwindow.py:276 等价,不开 K线)。
fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("持仓");
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    const POS_HEADERS: [&str; 16] = [
        "代码",
        "名称",
        "交易所",
        "方向",
        "数量",
        "昨仓",
        "可用",
        "冻结",
        "在途",
        "市值",
        "成本",
        "均价",
        "最新价",
        "盈亏",
        "盈亏比",
        "接口",
    ];
    if ts.positions.is_empty() {
        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
        pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
            ui.label("(无持仓)");
        });
        return;
    }
    pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
        for p in &ts.positions {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
            // 不开 K线;方向随持仓方向,Python 同口径)。
            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
            let row_sel = shared.row_selected("positions", &key);
            let mut cells = Vec::with_capacity(16);
            let mut dbl = false;
            let sel = ui.selectable_label(row_sel, p.symbol.clone());
            if sel.clicked() {
                shared.select_row("positions", &key);
            }
            dbl |= sel.double_clicked();
            cells.push(sel.rect);
            macro_rules! cell {
                ($base:expr, $col:expr) => {{
                    let base = $base;
                    let r = click_cell(ui, base.rect, ("pos", &key, $col));
                    if r.clicked() {
                        shared.select_row("positions", &key);
                    }
                    dbl |= r.double_clicked();
                    cells.push(base.rect);
                }};
            }
            cell!(
                ui.label(if p.name.is_empty() {
                    "-".to_string()
                } else {
                    p.name.clone()
                }),
                1
            );
            cell!(ui.label(p.exchange.as_str().to_string()), 2);
            cell!(
                ui.label(egui::RichText::new(p.direction.as_str()).color(
                    if p.direction == ek_data::constant::Direction::SHORT {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    },
                )),
                3
            );
            cell!(ui.label(format!("{:.0}", p.volume)), 4);
            cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
            cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
            cell!(ui.label(format!("{:.0}", p.frozen)), 7);
            cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
            cell!(ui.label(format!("{:.1}", p.market_value)), 9);
            cell!(ui.label(format!("{:.2}", p.price)), 10);
            cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
            cell!(ui.label(format!("{:.2}", p.last_price)), 12);
            cell!(
                ui.label(
                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    }),
                ),
                13
            );
            cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
            cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
            if dbl {
                let mut f = shared.order_form();
                f.symbol = p.symbol.clone();
                f.exchange = p.exchange.as_str().to_string();
                f.direction = p.direction;
                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
                if !p.gateway_name.is_empty() {
                    f.gateway = p.gateway_name.clone();
                }
                shared.set_order_form(f);
                shared.log_line(format!(
                    "[ops] 持仓带入下单面板 {}(接口 {})",
                    p.symbol, p.gateway_name
                ));
            }
            paint_row_bg(ui, &cells, row_sel);
            ui.end_row();
        }
    });
}

fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
    let tab = shared.bottomright_tab();
    ui.horizontal(|ui| {
        if ui
            .selectable_label(
                tab == BottomRightTab::Account,
                BottomRightTab::Account.label(),
            )
            .clicked()
        {
            shared.set_bottomright_tab(BottomRightTab::Account);
        }
        if ui
            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
            .clicked()
        {
            shared.set_bottomright_tab(BottomRightTab::Logs);
        }
    });
    match tab {
        BottomRightTab::Account => {
            let ts = shared.trade_snap();
            if ts.accounts.is_empty() {
                ui.label("(无资金:等网关 eAccount.)");
                return;
            }
            egui::Grid::new("acct_grid")
                .striped(true)
                .num_columns(5)
                .show(ui, |ui| {
                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
                        ui.strong(h);
                    }
                    ui.end_row();
                    for a in &ts.accounts {
                        ui.label(a.accountid.clone());
                        ui.label(a.gateway_name.clone());
                        ui.label(format!("{:.2}", a.balance));
                        ui.label(format!("{:.2}", a.available()));
                        ui.label(format!("{:.2}", a.frozen));
                        ui.end_row();
                    }
                });
        }
        BottomRightTab::Logs => {
            render_logs(ui, shared);
        }
    }
}

fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
    ui.horizontal(|ui| {
        if ui.button(BTN_CLEAR_LOGS).clicked() {
            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
        }
    });
    let (_, spilled) = shared.logs_snapshot();
    if spilled > 0 {
        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
        ui.label(format!(
            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
            crate::state::RING_CAP
        ));
    }
    let lines = shared.logs_snapshot().0;
    egui::ScrollArea::vertical()
        .id_salt("log_scroll")
        .max_height(180.0)
        .stick_to_bottom(true)
        .show(ui, |ui| {
            for line in lines {
                ui.monospace(line);
            }
        });
}

// ================= 独立窗面(BD-UI-32/33)=================

/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
struct WinGeom {
    inner: [f32; 2],
    min_inner: [f32; 2],
    maximized: bool,
}

/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
///
/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
///   context.rs「never used this pass」)。
/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
///   回退面关闭 = embedded 闭包返回 true。
/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
///   Embedded 回退面无此概念 = 忽略)。
/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
///   键事件按子窗焦点路由,主窗按键不会误关)。
///
/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
fn show_independent_window(
    ctx: &egui::Context,
    id_salt: impl std::hash::Hash,
    title: &str,
    geom: &WinGeom,
    embedded: impl Fn(&egui::Context) -> bool,
    body: impl Fn(&mut egui::Ui) -> bool,
) -> bool {
    let closed = std::cell::Cell::new(false);
    let mut builder = egui::ViewportBuilder::default()
        .with_title(title)
        .with_inner_size(geom.inner)
        .with_min_inner_size(geom.min_inner)
        .with_minimize_button(true)
        .with_maximize_button(true)
        .with_close_button(true);
    if geom.maximized {
        builder = builder.with_maximized(true);
    }
    ctx.show_viewport_immediate(
        egui::ViewportId::from_hash_of(id_salt),
        builder,
        |ctx2, class| {
            if class == egui::ViewportClass::Embedded {
                closed.set(embedded(ctx2));
                return;
            }
            if ctx2.input(|i| i.viewport().close_requested()) {
                closed.set(true);
                return;
            }
            egui::CentralPanel::default().show(ctx2, |ui| {
                if body(ui) {
                    closed.set(true);
                }
            });
        },
    );
    closed.get()
}

fn render_windows(ctx: &egui::Context, shared: &UiShared) {
    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
    if shared.ops_window_open() {
        let closed = show_independent_window(
            ctx,
            "ops_win",
            "运维窗",
            &WinGeom {
                inner: [680.0, 420.0],
                min_inner: [420.0, 260.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.ops_window_open();
                egui::Window::new("运维窗")
                    .id(egui::Id::new("ops_win"))
                    .default_pos([80.0, 320.0])
                    .default_size([640.0, 380.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_ops_window(ui, shared);
                    });
                shared.set_ops_window_open(open);
                !open
            },
            |ui| {
                render_ops_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_ops_window_open(false);
        }
    }

    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
    if shared.accounts_window_open() {
        let closed = show_independent_window(
            ctx,
            "account_win",
            "账户面板",
            &WinGeom {
                inner: [760.0, 520.0],
                min_inner: [520.0, 320.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.accounts_window_open();
                egui::Window::new("账户面板")
                    .id(egui::Id::new("account_win"))
                    .default_pos([640.0, 320.0])
                    .default_size([720.0, 470.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_accounts_window(ui, shared);
                    });
                shared.set_accounts_window_open(open);
                !open
            },
            |ui| {
                render_accounts_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_accounts_window_open(false);
        }
    }

    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
    if shared.account_edit_open() {
        let closed = show_independent_window(
            ctx,
            "account_edit_win",
            "账户配置",
            &WinGeom {
                inner: [680.0, 340.0],
                min_inner: [480.0, 240.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.account_edit_open();
                let title = match shared.editing_account() {
                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
                    None => "账户配置 — 添加".to_string(),
                };
                egui::Window::new(title)
                    .id(egui::Id::new("account_edit_win"))
                    .default_pos([700.0, 380.0])
                    .default_size([660.0, 320.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_account_edit_window(ui, shared);
                    });
                if !open {
                    shared.end_account_edit();
                }
                !open
            },
            |ui| {
                render_account_edit_window(ui, shared);
                false
            },
        );
        if closed && shared.account_edit_open() {
            shared.end_account_edit();
        }
    }

    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
    if shared.contracts_window_open() {
        let closed = show_independent_window(
            ctx,
            "contracts_win",
            "查询合约窗",
            &WinGeom {
                inner: [620.0, 460.0],
                min_inner: [420.0, 260.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.contracts_window_open();
                egui::Window::new("查询合约窗")
                    .id(egui::Id::new("contracts_win"))
                    .default_pos([420.0, 300.0])
                    .default_size([560.0, 420.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_contracts_window(ui, shared);
                    });
                shared.set_contracts_window_open(open);
                !open
            },
            |ui| {
                render_contracts_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_contracts_window_open(false);
        }
    }

    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
    // 键按子窗焦点路由,主窗按键不误关)。
    let mut closed: Vec<String> = Vec::new();
    for sym in shared.kline_symbols() {
        let is_closed = show_independent_window(
            ctx,
            format!("kline_win:{sym}"),
            &format!("K线 — {sym}"),
            &WinGeom {
                inner: [560.0, 360.0],
                min_inner: [360.0, 220.0],
                maximized: true,
            },
            |ctx2| {
                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
                let resp = egui::Window::new(format!("K线 — {sym}"))
                    .id(egui::Id::new(("kline_win", sym.clone())))
                    .default_pos([300.0, 340.0])
                    .default_size([540.0, 330.0])
                    .show(ctx2, |ui| {
                        let cell = shared
                            .market()
                            .cells()
                            .into_iter()
                            .find(|c| c.symbol == sym);
                        render_kline_cell(ui, cell.as_ref());
                    });
                esc || resp.is_none()
            },
            |ui| {
                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
                    return true;
                }
                let cell = shared
                    .market()
                    .cells()
                    .into_iter()
                    .find(|c| c.symbol == sym);
                render_kline_cell(ui, cell.as_ref());
                false
            },
        );
        if is_closed {
            closed.push(sym);
        }
    }
    for s in closed {
        shared.close_kline(&s);
    }
}

/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("插件");
    let snap = shared.snapshot();
    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
    let account_gateways: std::collections::HashSet<String> = shared
        .accounts()
        .iter()
        .map(|a| a.gateway.clone())
        .collect();
    egui::Grid::new("plug_grid")
        .striped(true)
        .num_columns(6)
        .show(ui, |ui| {
            ui.strong("名称");
            ui.strong("状态");
            ui.strong("版本");
            ui.strong("依赖");
            ui.strong("统计");
            ui.strong("操作");
            ui.end_row();
            if snap.rows.is_empty() {
                ui.label("(快照空:等 eTimer 首拉)");
                ui.end_row();
            }
            for r in &snap.rows {
                if is_quarantined(&r.state) {
                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
                    ui.label(
                        egui::RichText::new(state_text(r))
                            .color(egui::Color32::RED)
                            .strong(),
                    );
                } else {
                    ui.label(r.name.clone());
                    ui.label(state_text(r));
                }
                ui.label(r.version.clone());
                ui.label(format!("deps:[{}]", r.deps.join(",")));
                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
                ui.horizontal(|ui| {
                    // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
                    // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
                    // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
                    // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
                    // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
                    if account_gateways.contains(&r.name) {
                        ui.label(
                            egui::RichText::new("起停归『账户』面板(带撤单守卫)")
                                .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
                        );
                    } else {
                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
                            // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
                            // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
                            // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
                            shared.push_cmd(UiCmd::Start {
                                factory: r.name.clone(),
                                name: String::new(),
                            });
                        }
                        let armed = shared.pending_confirm();
                        let armed_here = ui
                            .small_button(remove_button_label(&r.name, armed.as_deref()))
                            .clicked()
                            && shared.confirm_step(&r.name) == ConfirmStep::Fired;
                        if armed_here {
                            shared.push_cmd(UiCmd::Remove {
                                name: r.name.clone(),
                            });
                        }
                    }
                });
                ui.end_row();
            }
        });

    if let Some(name) = shared.pending_confirm() {
        ui.horizontal(|ui| {
            ui.label(
                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
            );
            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
                shared.confirm_cancel();
            }
        });
    }

    ui.label("服务");
    let flat: Vec<String> = snap
        .services
        .iter()
        .flat_map(|(n, ids)| {
            if ids.is_empty() {
                vec![format!("{n} 服务"), "-".to_string()]
            } else {
                ids.iter()
                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
                    .collect()
            }
        })
        .collect();
    if flat.is_empty() {
        ui.label("(无服务目录)");
    } else {
        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
        egui::Grid::new("svc_grid")
            .striped(true)
            .num_columns(6)
            .show(ui, |ui| {
                for r in 0..rows {
                    for c in 0..3 {
                        let i = c * rows * 2 + r * 2;
                        if i < flat.len() {
                            ui.label(flat[i].clone());
                            ui.label(flat[i + 1].clone());
                        } else {
                            ui.label("");
                            ui.label("");
                        }
                    }
                    ui.end_row();
                }
            });
    }

    ui.label("依赖");
    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
        ui.label("(全部无依赖)");
    }
    for r in &snap.rows {
        if !r.deps.is_empty() {
            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
        }
    }
}

/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
    let accounts = shared.accounts();
    let locked = shared.accounts_locked();
    let md = shared.md_source();
    let snap = shared.trade_snap();

    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
    let row_label: std::collections::BTreeMap<String, String> = accounts
        .iter()
        .map(|a| {
            let d = a.display_name();
            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
        })
        .collect();

    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
    ui.horizontal(|ui| {
        ui.label(
            egui::RichText::new(format!(
                "当前行情源 = {}",
                if md.is_empty() {
                    "(未指定)".to_string()
                } else {
                    md.clone()
                }
            ))
            .strong(),
        );
        ui.label(format!(
            "账户 {}/{}",
            accounts.len(),
            crate::state::MAX_ACCOUNTS
        ));
        if shared.can_add_account() {
            if ui.button(BTN_ACCOUNT_ADD).clicked() {
                shared.begin_account_edit(None);
            }
        } else {
            ui.label(
                egui::RichText::new(format!(
                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
                    crate::state::MAX_ACCOUNTS
                ))
                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
            );
        }
    });

    const ACCOUNT_HEADERS: [&str; 7] = [
        "网关(派生名)",
        "别名",
        "账号",
        "状态",
        "行情源",
        "收录(勾→实)",
        "操作",
    ];
    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面。
    egui::Grid::new("account_grid")
        .striped(true)
        .num_columns(7)
        .show(ui, |ui| {
            for h in &ACCOUNT_HEADERS {
                ui.strong(*h);
            }
            ui.end_row();
        if accounts.is_empty() {
            ui.label("(无账户:上方『添加账户』开配置弹窗)");
            for _ in 0..6 {
                ui.label("");
            }
            ui.end_row();
        }
        for a in &accounts {
            let is_md = a.gateway == md;
            let name = row_label
                .get(&a.gateway)
                .cloned()
                .unwrap_or_else(|| a.gateway.clone());
            ui.label(a.gateway.clone());
            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
            ui.label(if a.alias.trim().is_empty() {
                "—".to_string()
            } else {
                a.alias.clone()
            });
            ui.label(a.account.clone());
            // 状态列(0.2.19 起两段):装配态(svctl 在场位)+ **登录态**(`{gw}.status` 读数)。
            // 登录成败不再靠猜:ek-ctp 0.9.0 的 `{gw}.status` 是事实源(规划 P3 / 裁定 3),
            // 没有读数时仍写「已装配·登录态未知」,不得写成「已连接」(规划 U5 口径不变)。
            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
            // 会把一行撑成十几行高(s39 快照实锤过)。
            match a.stage {
                crate::state::AccountStage::AssembleFailed => {
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new(format!(
                                "装配失败:{}",
                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
                            ))
                            .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
                        )
                        .wrap_mode(egui::TextWrapMode::Extend),
                    );
                }
                _ => {
                    let color = match a.login {
                        crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
                        crate::state::LoginState::LoginFailed
                        | crate::state::LoginState::FrontDown => {
                            egui::Color32::from_rgb(0xC0, 0x30, 0x30)
                        }
                        crate::state::LoginState::QueryStarved => {
                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
                        }
                        // Unknown / Stopped:灰 —— 没有证据与主动停会话都不该着色成"出事"
                        _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
                    };
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new(a.status_label()).color(color),
                        )
                        .wrap_mode(egui::TextWrapMode::Extend),
                    );
                }
            }
            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
            ui.horizontal(|ui| {
                if is_md {
                    ui.label(egui::RichText::new("● 行情源").strong());
                }
                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
                    ui.small_button(if is_md {
                        "已是行情源"
                    } else {
                        "设为行情源"
                    })
                });
                if resp.inner.clicked() {
                    match shared.set_md_source(&a.gateway) {
                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
                        Err(e) => shared.log_line(format!("[账户] {e}")),
                    }
                }
            });
            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
            ui.vertical(|ui| {
                ui.add(
                    egui::Label::new(egui::RichText::new(format!(
                        "勾:{}",
                        classes_label(&a.form.product_classes())
                    )))
                    .wrap_mode(egui::TextWrapMode::Extend),
                );
                ui.add(
                    egui::Label::new(egui::RichText::new(format!(
                        "实:{}",
                        if a.effective_classes.is_empty() {
                            "—".to_string()
                        } else {
                            classes_label(&a.effective_classes)
                        }
                    )))
                    .wrap_mode(egui::TextWrapMode::Extend),
                );
            });
            // 操作:连接/重试(四态分离的『连接』入口)/ 断开(0.2.19 停会话)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
            ui.horizontal(|ui| {
                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
                let idle = a.stage == crate::state::AccountStage::NotAssembled;
                // 会话被主动停掉(『断开』)但实例还在:恢复入口仍是这一颗『连接』
                // (走 `UiCmd::Connect` → svctl 回「已在场」→ 插件侧改走重投凭据,规划裁定 1)。
                let stopped = a.login == crate::state::LoginState::Stopped;
                if failed || idle || stopped {
                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
                    // (旧口径"必须先移除才能重试"作废)。
                    let label = if failed {
                        format!("重试 {name}")
                    } else {
                        format!("连接 {name}")
                    };
                    if ui.small_button(label).clicked() {
                        push_connect(shared, &a.gateway, &a.form);
                    }
                }
                // 该账户的活跃委托数(『断开』与『移除』共用同一道守卫,见下)。
                let open = snap
                    .orders
                    .iter()
                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
                    .count();
                // 『断开』(0.2.19,规划裁定 1「断开 = 停会话,移除 = 停实例」):
                // 只对**已装配、会话还活着、且没有活跃委托**的行开。
                // - 未装配没有会话可停(规划 Q21:口不存在时不留死钮;现在口已实现,钮才允许出现);
                // - 有活跃委托时不给断开 —— 会话一停就撤不了单、也不再回报,与『移除』
                //   要挡的是同一个观测黑洞(规划 §5.B),出口同样是旁边那句『先撤 N 笔』。
                // `&&` 短路即"条件不成立就不画这颗钮",不是画出来再禁用。
                if a.stage == crate::state::AccountStage::Assembled
                    && a.login != crate::state::LoginState::Stopped
                    && open == 0
                    && ui.small_button(format!("断开 {name}")).clicked()
                {
                    shared.push_cmd(UiCmd::Disconnect {
                        gateway: a.gateway.clone(),
                    });
                    shared.log_line(format!(
                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
                        gateway = a.gateway
                    ));
                }
                if ui.small_button(format!("编辑 {name}")).clicked() {
                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
                    shared.begin_account_edit(Some(&a.gateway));
                }
                let has_pos = snap
                    .positions
                    .iter()
                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
                if open > 0 {
                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
                    ui.label(
                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
                            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
                    );
                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
                        for o in snap
                            .orders
                            .iter()
                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
                        {
                            shared.push_cmd(UiCmd::CancelOrder {
                                orderid: o.orderid.clone(),
                                gateway: a.gateway.clone(),
                            });
                        }
                        shared.log_line(format!(
                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
                            a.gateway, open
                        ));
                    }
                } else {
                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
                    let armed = shared.pending_confirm();
                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
                    let fired = ui
                        .small_button(remove_button_label(
                            &name,
                            if armed_here { Some(name.as_str()) } else { None },
                        ))
                        .clicked()
                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
                    if fired {
                        shared.push_cmd(UiCmd::Remove {
                            name: a.gateway.clone(),
                        });
                        shared.remove_account(&a.gateway);
                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
                        shared.end_account_edit_if_editing(&a.gateway);
                        if has_pos {
                            shared.log_line(format!(
                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
                                a.gateway
                            ));
                        }
                    }
                }
            });
            ui.end_row();
        }
    });

    if let Some(gw) = shared.pending_confirm() {
        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
        ui.horizontal(|ui| {
            ui.label(
                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
            );
            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
                shared.confirm_cancel();
            }
        });
    }

    ui.label(if locked {
        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
    } else {
        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
    });
    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
    ui.label(
        egui::RichText::new(format!(
            "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
            crate::state::STATUS_POLL_TICKS
        ))
        .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
    );
    // F4 口径显形(0.2.20):多账户下「谁查谁过滤」此前只能靠猜 —— 实连 S14b 里
    // 账户 B 把勾选改成「期货+期权」,公用合约表仍是 852 条、期权 0 条,用户没有任何
    // 办法判断是自己没生效、还是网关没查到。这里把口径写死成一句(不靠界面猜):
    // 勾选只管**这个网关自己**收录并上抛的部分,公用表是在场各网关的并集。
    ui.label(
        egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
            .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
    );
    // 状态细节(0.2.19):凡是有话可说的行都给一句(带次数、带秒数、带原因)。
    // 为什么不在状态列里带:Grid 列宽实测会被长文案挤成竖排(s39 实锤)。
    // 为什么不只给告警行:「状态口问不到」与「纯行情账户」也是事实 ——
    // 灰色徽标不给原因,用户就只能猜「是没问到还是没登录」。
    // 只给已装配的行:会话读数属于那个实例,行都「未装配」了还印「掉线 2 次」
    // 就是在替一个不存在的会话说话(状态侧由 `sync_accounts_connected` 一并作废,
    // 这里是不对称时刻——快照还没刷——的第二道闸)。
    // 健康行没有细节(`count_note(…, 0) = None`),所以这一段不会随账户数无谓变长。
    for a in accounts
        .iter()
        .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
    {
        let note = a.status_note.clone().unwrap_or_default();
        let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
        let color = if alarm {
            egui::Color32::from_rgb(0xC0, 0x30, 0x30)
        } else {
            egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
        };
        ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
    }
    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
    if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
        if src.form.md_addr.trim().is_empty() {
            ui.label(
                egui::RichText::new(format!(
                    "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
                    src.gateway,
                    row_label
                        .get(&src.gateway)
                        .cloned()
                        .unwrap_or_else(|| src.account.clone())
                ))
                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
            );
        }
    }
}

/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
    let editing: Option<crate::state::AccountRow> =
        shared.editing_account().and_then(|g| shared.account(&g));
    let editing_on = editing.is_some();
    let md = shared.md_source();
    let locked = shared.accounts_locked();
    let mut form: crate::state::CtpForm = shared.ctp_form();
    let mut alias = shared.edit_alias();
    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
    if let Some(e) = shared.edit_error() {
        ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
    }
    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
    ui.horizontal(|ui| {
        ui.label("别名");
        if ui
            .add(
                egui::TextEdit::singleline(&mut alias)
                    .hint_text("显示用,可空")
                    .desired_width(120.0),
            )
            .changed()
        {
            shared.set_edit_alias(alias.clone());
        }
        ui.label("broker");
        ui.add_enabled_ui(!editing_on, |ui| {
            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
        });
        ui.label("td 前置");
        ctp_edit(
            ui,
            shared,
            &mut form.td_addr,
            "tcp://host:40001",
            CtpField::TdAddr,
        );
        ui.label("td 用户");
        ui.add_enabled_ui(!editing_on, |ui| {
            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
        });
    });
    if let Some(r) = &editing {
        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
        ui.label(
            egui::RichText::new(format!(
                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
                r.gateway
            ))
            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
        );
    }
    ui.horizontal(|ui| {
        ui.label("td 密码");
        ctp_edit(
            ui,
            shared,
            &mut form.td_password,
            "密码",
            CtpField::TdPassword,
        );
        ui.label("AppID");
        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
        ui.label("AuthCode");
        ctp_edit(
            ui,
            shared,
            &mut form.td_auth_code,
            "授权码",
            CtpField::AuthCode,
        );
    });
    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 三格置灰。
    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
    let md_editable = match &editing {
        None => true,
        Some(r) => !locked || r.gateway == md,
    };
    ui.horizontal(|ui| {
        ui.label("md 前置");
        ui.add_enabled_ui(md_editable, |ui| {
            ctp_edit(
                ui,
                shared,
                &mut form.md_addr,
                "tcp://host:40011",
                CtpField::MdAddr,
            );
            ui.label("md 用户");
            ctp_edit(
                ui,
                shared,
                &mut form.md_user,
                "空=td 回落",
                CtpField::MdUser,
            );
            ui.label("md 密码");
            ctp_edit(
                ui,
                shared,
                &mut form.md_password,
                "空=td 回落",
                CtpField::MdPassword,
            );
        });
        if !md_editable {
            ui.label(
                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
            );
        }
    });
    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
    ui.horizontal(|ui| {
        ui.label("收录勾选:");
        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
        if fut || opt {
            shared.set_ctp_form(form.clone());
        }
        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
    });
    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
    if let Some(r) = &editing {
        ui.horizontal(|ui| {
            ui.label("网关实际收录(只读回带):");
            ui.label(if r.effective_classes.is_empty() {
                "—(尚未装配或无回带)".to_string()
            } else {
                classes_label(&r.effective_classes)
            });
            ui.label("装配/登录状态(只读):");
            ui.label(format!(
                "{}{}",
                r.status_label(),
                r.fail_note
                    .as_ref()
                    .map(|n| format!(" —— {n}"))
                    .unwrap_or_default()
            ));
        });
    }
    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
    if shared.account_edit_dirty() {
        ui.label(
            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
        );
    }
    ui.horizontal(|ui| {
        if ui.button("保存").clicked() {
            match save_account_draft(shared, &form, &alias, false) {
                Ok(()) => shared.end_account_edit(),
                Err(e) => {
                    shared.log_line(format!("[账户] 保存被拦:{e}"));
                    shared.set_edit_error(Some(e));
                }
            }
        }
        if ui.button("保存并连接").clicked() {
            match save_account_draft(shared, &form, &alias, true) {
                Ok(()) => shared.end_account_edit(),
                Err(e) => {
                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
                    shared.set_edit_error(Some(e));
                }
            }
        }
        if ui.button("取消").clicked() {
            shared.end_account_edit();
        }
        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
    });
}

/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
    shared
        .accounts()
        .iter()
        .map(|a| a.gateway.clone())
        .collect()
}

/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
///
/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
/// 用户会把已摘除账户的委托当成在场账户的。
/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
        gateway.to_string()
    } else {
        format!("{gateway}(已摘除)")
    }
}

/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
fn classes_label(classes: &[String]) -> String {
    if classes.is_empty() {
        return "—".to_string();
    }
    classes
        .iter()
        .map(|c| match c.as_str() {
            "1" => "期货",
            "2" => "期权",
            other => other,
        })
        .collect::<Vec<_>>()
        .join("+")
}

/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
///
/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
    shared.push_cmd(UiCmd::Connect {
        gateway: gateway.to_string(),
        form: form.clone(),
    });
    shared.note_connect_sent(gateway);
    shared.log_line(format!(
        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
        gateway
    ));
}

/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
///
/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
///
/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
fn save_account_draft(
    shared: &UiShared,
    form: &crate::state::CtpForm,
    alias: &str,
    connect: bool,
) -> Result<(), String> {
    form.validate()?;
    if let Some(gw) = shared.editing_account() {
        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
        if shared.account(&gw).is_none() {
            return Err(format!(
                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
            ));
        }
        shared.update_account_form(&gw, form.clone());
        if !shared.set_account_alias(&gw, alias) {
            return Err(format!(
                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
            ));
        }
        shared.log_line(format!(
            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
        ));
        if connect {
            push_connect(shared, &gw, form);
        }
        return Ok(());
    }
    let mut row = crate::state::AccountRow::derive(form)?;
    row.alias = alias.trim().to_string();
    let gateway = row.gateway.clone();
    let account = row.account.clone();
    shared.add_account(row.clone())?;
    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
    let md = shared.md_source();
    let mut f = row.form.clone();
    if md.is_empty() && !shared.accounts_locked() {
        match shared.set_md_source(&gateway) {
            Ok(()) => {
                if f.md_addr.trim().is_empty() {
                    shared.log_line(format!(
                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
                    ));
                } else {
                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
                }
            }
            Err(e) => {
                shared.log_line(format!("[账户] {e}"));
                f.md_addr.clear();
                f.md_user.clear();
                f.md_password.clear();
            }
        }
    } else {
        f.md_addr.clear();
        f.md_user.clear();
        f.md_password.clear();
        if md.is_empty() {
            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
        }
    }
    shared.update_account_form(&gateway, f.clone());
    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
    shared.log_line(format!(
        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
    ));
    if connect {
        push_connect(shared, &gateway, &f);
    }
    Ok(())
}

/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
    let mut q = shared.contract_query();
    ui.horizontal(|ui| {
        ui.label("代码或交易所(留空 = 全部):");
        if ui
            .add(
                egui::TextEdit::singleline(&mut q)
                    .hint_text("cu 或 SHFE")
                    .desired_width(200.0),
            )
            .changed()
        {
            shared.set_contract_query(q.clone());
        }
    });
    let rows = shared.contracts(&q);
    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
    if rows.is_empty() {
        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
        });
        return;
    }
    let selected = shared.selected_contracts();
    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
    // 行溢出才出滚动条。
    ui.horizontal(|ui| {
        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
            if selected.is_empty() {
                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
            } else {
                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
                let symbols: Vec<String> = selected
                    .iter()
                    .map(|k| {
                        k.split_once('.')
                            .map(|(_, s)| s.to_string())
                            .unwrap_or_else(|| k.clone())
                    })
                    .collect();
                match subscribe_target(shared) {
                    Ok(gw) => {
                        shared.push_cmd(UiCmd::CtpSubscribe {
                            gateway: gw.clone(),
                            symbols,
                        });
                        shared.log_line(format!(
                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
                            selected.len(),
                            gw
                        ));
                    }
                    Err(e) => shared.log_line(format!("[订阅] {e}")),
                }
            }
        }
    });
    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
        for c in &rows {
            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
            let mut on = selected.contains(&key);
            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
            // 行选中(动作归行,不归某一列)。
            let row_sel = shared.row_selected("contracts", &key);
            let mut cells = Vec::with_capacity(5);
            let ck = ui.checkbox(&mut on, key.clone());
            if ck.changed() {
                shared.toggle_contract_selection(&key);
                shared.select_row("contracts", &key);
            }
            cells.push(ck.rect);
            macro_rules! cell {
                ($base:expr, $col:expr) => {{
                    let base = $base;
                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
                    if r.clicked() {
                        shared.select_row("contracts", &key);
                    }
                    cells.push(base.rect);
                }};
            }
            cell!(ui.label(c.exchange.as_str().to_string()), 1);
            cell!(ui.label(c.name.clone()), 2);
            cell!(ui.label(format!("{:.0}", c.size)), 3);
            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
            paint_row_bg(ui, &cells, row_sel);
            ui.end_row();
        }
    });
}

/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
enum CtpField {
    Broker,
    TdAddr,
    TdUser,
    TdPassword,
    AppId,
    AuthCode,
    MdAddr,
    MdUser,
    MdPassword,
}

/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
    let is_secret = matches!(
        field,
        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
    );
    let mut w = egui::TextEdit::singleline(val)
        .hint_text(hint)
        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
        .desired_width(210.0);
    if is_secret {
        w = w.password(true);
    }
    if ui.add(w).changed() {
        let mut f = shared.ctp_form();
        match field {
            CtpField::Broker => f.broker_id = val.clone(),
            CtpField::TdAddr => f.td_addr = val.clone(),
            CtpField::TdUser => f.td_user = val.clone(),
            CtpField::TdPassword => f.td_password = val.clone(),
            CtpField::AppId => f.td_app_id = val.clone(),
            CtpField::AuthCode => f.td_auth_code = val.clone(),
            CtpField::MdAddr => f.md_addr = val.clone(),
            CtpField::MdUser => f.md_user = val.clone(),
            CtpField::MdPassword => f.md_password = val.clone(),
        }
        shared.set_ctp_form(f);
    }
}

/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
///
/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
    let Some(c) = cell else {
        ui.label("(K线无格:等 dm 合成)");
        return;
    };
    if c.bars.is_empty() {
        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
        return;
    }
    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
    let mut data = egui_charts::model::bar::BarData::new();
    for b in &c.bars {
        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
            .with_timezone(&beijing_tz())
            .with_timezone(&chrono::Utc);
        data.push(egui_charts::model::bar::Bar::new(
            dt,
            b.open_price,
            b.high_price,
            b.low_price,
            b.close_price,
            b.volume,
        ));
    }
    ui.label(format!(
        "K线 {}@{}s({} 根)",
        c.symbol,
        c.interval,
        c.bars.len()
    ));
    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
    chart.set_symbol(&c.symbol);
    chart.set_timeframe_label(&format!("{}s", c.interval));
    chart.enable_tracking_mode();
    let _ = chart.show(ui);
}

#[cfg(test)]
mod tests {
    use super::*;

    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
    #[test]
    fn fmt_dt_shows_beijing_wall_clock() {
        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
            ek_data::time::civil_to_nanos(ek_data::time::Civil {
                year: 2026,
                month: 4,
                day: 16,
                hour: 1,
                minute: 30,
                second: 1,
                nanos: 0,
            }),
        );
        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
        let beijing = expect.with_timezone(&beijing_tz());
        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
            year: 2026,
            month: 4,
            day: 15,
            hour: 16,
            minute: 0,
            second: 30,
            nanos: 0,
        });
        assert_eq!(fmt_dt(Some(ns)), "00:00:30");
        // None → 占位
        assert_eq!(fmt_dt(None), "-");
    }
}