ek-ui 0.8.1

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
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
//! 共享观测状态:`UiPlugin`(总线面)写、`render_body`(窗口面)读。
//!
//! 线程形貌:总线侧在**插件 worker 线程**写;窗口侧在 **eframe 主线程**读;
//! 锁粒度 = 每次 `lock().clone()/snapshot()`,不跨帧持锁。

use std::collections::{BTreeMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use ek_data::bar::BarData;
use ek_dm::wire::BarsFrame;
use ek_station::wire::PlugRow;

/// 日志环形容量(S1b 门面;溢出不丢累计计数,见 [`LogRing::spilled`])。
pub const RING_CAP: usize = 512;

/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
/// 0.2.27 起从 render 侧提到 state(原 `render.rs` 那份随之删除):日志时间戳
/// 在入环时就要打,而打戳与表格时间列(`fmt_dt`)必须是同一个口径,
/// 不能一边 +08 一边本机时区。
pub(crate) fn beijing_tz() -> chrono::FixedOffset {
    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
}

/// 一行日志(BD-UI-41:Python 侧 LogMonitor 是带「时间/信息/接口」表头的表格
/// (`widget.py:1167-1180`),我们原来只存正文串、渲染侧没有时间可排)。
/// 时间戳在入环那一刻打,北京墙钟 `%H:%M:%S`(与 `fmt_dt` 同口径)。
#[derive(Debug, Clone)]
pub struct LogEntry {
    /// 北京墙钟 `HH:MM:SS`。
    pub at: String,
    /// 正文。
    pub text: String,
}

/// 环形日志:push 满则挤掉最旧一条,溢出计数单调(页面显示"已丢弃 N 条")。
#[derive(Debug)]
pub struct LogRing {
    lines: VecDeque<LogEntry>,
    cap: usize,
    spilled: u64,
}

impl Default for LogRing {
    fn default() -> Self {
        Self::new(RING_CAP)
    }
}

impl LogRing {
    /// 定容环形(测试可传小容量逼溢出)。
    pub fn new(cap: usize) -> Self {
        Self {
            lines: VecDeque::with_capacity(cap),
            cap,
            spilled: 0,
        }
    }

    /// 追加一行;满则挤旧 + spilled+1。
    pub fn push(&mut self, line: impl Into<String>) {
        if self.cap == 0 {
            self.spilled += 1;
            return;
        }
        if self.lines.len() == self.cap {
            self.lines.pop_front();
            self.spilled += 1;
        }
        let at = chrono::Utc::now()
            .with_timezone(&beijing_tz())
            .format("%H:%M:%S")
            .to_string();
        self.lines.push_back(LogEntry {
            at,
            text: line.into(),
        });
    }

    /// 当前长度。
    pub fn len(&self) -> usize {
        self.lines.len()
    }

    /// 是否空行。
    pub fn is_empty(&self) -> bool {
        self.lines.is_empty()
    }

    /// 累计已丢弃条数(只有溢出才涨)。
    pub fn spilled(&self) -> u64 {
        self.spilled
    }

    /// 只读快照(正文;测试与巡回取证用它,不关心时间)。
    pub fn snapshot(&self) -> Vec<String> {
        self.lines.iter().map(|e| e.text.clone()).collect()
    }

    /// 带时间戳的快照(渲染侧表格用:时间列 + 正文列)。
    pub fn entries(&self) -> Vec<LogEntry> {
        self.lines.iter().cloned().collect()
    }

    /// 全清;spilled 保留(历史溢出事实不抹)。
    pub fn clear(&mut self) {
        self.lines.clear();
    }
}

/// 一次 eTimer 拉取的服务/插件快照(渲染直接可用)。
#[derive(Debug, Clone, Default)]
pub struct BusSnap {
    /// svctl.list 全行(svctl 独裁班;状态原文)。
    pub rows: Vec<PlugRow>,
    /// 逐插件服务目录(id 清单;无服务为空)。
    pub services: Vec<(String, Vec<String>)>,
}

/// UI 操作指令(窗口面 → 总线面;eTimer 节拍被 ui 插件请辞)。
#[derive(Debug, Clone, PartialEq)]
pub enum UiCmd {
    /// svctl.start(工厂名 + 实例注册名;`name` 空 = 旧行为,工厂名即注册名)。
    /// 多账号面:`factory="ctp"` + `name=ctp_<broker>_<账号>` 建第 N 个网关实例。
    Start { factory: String, name: String },
    /// svctl.remove(插件名)。
    Remove { name: String },
    /// dm.reload 回灌(S4;push=true 逐条广播,行情面板实时见 Rows)。
    Reload {
        interval: i64,
        symbol: String,
        bars: Vec<BarData>,
    },
    /// 下单(S5;网关受理即 SUBMITTING 单上总线)。
    /// S6 起网关可选(`gateway.send_order`,空 = ctp)。
    /// 0.1.1(BD-UI-30):改走 `oms.submit_order` 五段管线(拆单/风控/逐腿
    /// 发送/记账,parent 聚合);`mode` = 拆单四态(勾选「自动开平」= Auto,
    /// 取消勾选 = None 原样旁路)。
    SendOrder {
        req: ek_data::order_request::OrderRequest,
        gateway: String,
        mode: ek_oms::LockMode,
    },
    /// 撤单(S5;携三段号所需的 orderid;S6 起网关同选)。
    /// 0.1.1(BD-UI-30):改走 `oms.cancel_order`(守卫 4000 不可绕过;
    /// symbol/exchange 由 oms 订单缓存解,不再随令携带)。
    CancelOrder { orderid: String, gateway: String },
    /// `{gw}.configure` 账户配置(多账号面:网关名随令携带,不再硬编码 `ctp`;
    /// 出队时 `to_configure_req` 抬成线上契约)。
    /// 0.2.18 起界面侧不再用它装配账户(装配走 `Connect`);保留作低层口与测试面。
    CtpConfigure { gateway: String, form: CtpForm },
    /// **四态分离的「连接」(0.2.18,规划裁定:添加 ≠ 连接)**:
    /// 一次做完 `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
    /// (`Assembling → Assembled / AssembleFailed`)。界面『连接』『重试』钮走这一条,
    /// 『添加账户』只写清册(不再一键装配 → 失败不会留下"注定被拒"的半死行)。
    Connect { gateway: String, form: CtpForm },
    /// `{gw}.subscribe` 订阅行情(多账号面:一律路由到**行情元账号**,
    /// 从账号没有 MD 会话,发给它只会静默成功 —— 见规划 §5.C / §9.4-21)。
    CtpSubscribe {
        gateway: String,
        symbols: Vec<String>,
    },
    /// **`{gw}.disconnect` 停会话(0.2.19,规划裁定 1:断开 ≠ 移除)**:
    /// 网关实例、账号配置、订阅集、状态计数全保留,『连接/重试』可恢复(重登自动重订阅)。
    /// 『移除』走的 `UiCmd::Remove`(svctl.remove)才是停实例 —— 两个动作不得混成一个。
    Disconnect { gateway: String },
    /// 配置托底:段目录刷新(`cfg.list`;0.6.0 环节8,BD-CFG-17 零值面)。
    CfgList,
    /// 配置托底:清段(`cfg.delete`,`ns` + 空整段语义;运维窗操作,与文件回缺省)
    CfgDelete { ns: String },
    /// 配置托底:重存段(`cfg.save`;从明面重读 → serialize → store,
    /// **值不变** —— 修 rev/格式,持久挂久节奏验证面;0.6.0 环节8)。
    CfgResave { ns: String },
}

/// 行情缓存单格容量(防涨;丢头保尾,bar 流单调)。
pub const KLINE_CELL_CAP: usize = 4096;

/// 账户软上限(0.2.18 落地既有开放项:达上限『添加账户』入口禁用并提示,
/// 界面侧不再无限长行)。
pub const MAX_ACCOUNTS: usize = 8;

/// 「查询断供」判据之一:资金/持仓查询**连续**被拒达此数(规划 Q10)。
///
/// 阈值住在界面,不住在网关:ek-ctp 只给 `td_query_reject_streak` 这个原料,
/// 「连续几次算断供」是界面口径(它才知道自己多久刷一次)。
pub const QUERY_REJECT_STREAK_LIMIT: u32 = 3;

/// 「查询断供」判据之二:距最后一次**资金回报**超过这么多秒(规划 Q10)。
///
/// 阈值对照的是 ek-ctp 0.9.8 的轮询密度:1 秒闸 + 秒针抖动 → 资金与持仓每路 2~4 秒一次,
/// 30 秒 = 最坏也有 7 次机会没见到资金,足够避开「刚登录还没轮到」与「合约阶段内本来不问
/// 资金」的正常空窗,又不至于把 F2 那种断供拖很久才显形。
pub const ACCOUNT_STALE_SECS: i64 = 30;

/// 链状态行的前缀(0.5.0,用户定「链进行的状态改为事件日志推送」)—— **单源:引用网关那一份**。
///
/// 前缀的唯一出处是 ek-ctp 0.9.8 的 `ek_ctp::driver::CHAIN_STATE_PREFIX`(驱动落每一条 `[链] `
/// 状态行、插件落会话复位行,用的都是它)。本 crate 0.5.0 抬 pin 到 0.9.8,就不再自己抄一份 ——
/// 抄一份 = 网关改前缀而界面静默不认。测试钉 `u4i` 读的是这个再导出。
pub use ek_ctp::driver::CHAIN_STATE_PREFIX;

/// 管制面快照(`svctl.list`)轮询节拍(0.5 M3:事件驱动后唯一保留的拍级轮询)。
///
/// 4 拍 ≈ 4 秒(第一拍必问,界面刚起来不该空着):这一口给的是「谁在场 + 服务目录 +
/// 邮箱溢出计数」,不是交易数据。0.4 之前它每拍一次,是因为交易四表搭在同一拍上;
/// 四表改吃推送后,它降到自己那一档。
pub const LIST_POLL_TICKS: u32 = 4;

/// `{gw}.status` 每拍最多问几个账户(0.5 M3:状态轮询摊平)。
///
/// 摊平而不是降拍:8 个账户同拍全问,单拍上限就是 8 × 2s;每拍只问 2 个,单拍上限
/// 就是 4s,而 8 个账户 4 拍走完一轮 —— 与快照节拍同档,且不随账户数线性长。
pub const STATUS_ASKS_PER_TICK: usize = 2;

/// 成交账本上限(0.5 偏离基线③,用户裁定接)。
///
/// 基线的成交表只插不删、无上限(`widget.py:1242/1267` 的 `data_key = ""`);长跑一天
/// CTP 成交可上万行,而渲染每帧要把它们喂进表。触顶丢最旧,并在第一次触顶时弹一次告警。
pub const TRADE_CAP: usize = 5000;

/// 水位阈下限(0.7.0 背压刀):积压越过软顶的 1/16 且不少于此数 → 黄。
pub const PENDING_WARN_FLOOR: u64 = 4_096;
/// 水位阈下限(0.7.0 背压刀):积压越过软顶的 1/4 且不少于此数 → 红。
pub const PENDING_CRIT_FLOOR: u64 = 32_768;

/// 某插件的水位阈(0.7.0:**吃这一行自己的软顶**,比例 + 下限)。
///
/// 0.5 M6 时代界面自带一张「谁覆写了容量」的登记表(`mailbox_cap_of`)——
/// ek-station 0.4.0 起 `PlugRow.soft_cap` 把这条事实随快照带到界面上,
/// 登记表就是第二份事实源(两处会飘),**退役**。
/// 阈值口径:比例随软顶缩放(1/16 黄、1/4 红),下限取旧口径的定数
/// (4096 / 32768),避免小软顶的插件刚起步就满屏红。
#[must_use]
pub fn pending_thresholds(soft_cap: u64) -> (u64, u64) {
    let warn = (soft_cap / 16).max(PENDING_WARN_FLOOR);
    let crit = (soft_cap / 4).max(PENDING_CRIT_FLOOR);
    (warn.min(soft_cap.max(1)), crit.min(soft_cap.max(1)))
}

/// 二次确认步进:Armed = 首点待确认;Fired = 同名二点已发令;Switched = 目标迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmStep {
    Armed,
    Fired,
    Switched,
}

/// 隔离态判定(svctl.list 状态原文含 Quarantin → 红;ePlugQDown.<名> 是定向
/// 广播,未知名无法预订阅 —— 快照行扫描是唯一闭环面,BD-UI-17)。
#[must_use]
pub fn is_quarantined(state: &str) -> bool {
    state.contains("Quarantin")
}

// ================= S4:行情库(eBar./eBarUpdate. 上屏地面) =================

/// 单格 = (symbol, interval) → bar 升序序列(丢头保尾;datetime 单调)。
#[derive(Debug, Default)]
pub struct MarketStore {
    cells: Mutex<BTreeMap<(String, i64), Vec<BarData>>>,
    /// eBar. 帧接收计数(帧统计,刷新不丢帧观感面)。
    frames: AtomicU64,
    /// eBarUpdate. 接收计数。
    updates: AtomicU64,
    /// dm.reload 成功计数(回灌确认面)。
    reloads: AtomicU64,
}

/// 一格的可读视图(渲染/测试用)。
#[derive(Debug, Clone, PartialEq)]
pub struct CellView {
    pub symbol: String,
    pub interval: i64,
    pub bars: Vec<BarData>,
}

impl MarketStore {
    /// eBar. 收帧:逐 bar 并格(同 dt 替尾;更旧丢弃;更晚追加,溢出丢头)。
    pub fn on_frame(&self, frame: &BarsFrame) {
        let mut cells = self.cells.lock().unwrap();
        for b in &frame.bars {
            let key = (b.symbol.clone(), frame.interval);
            let cell = cells.entry(key).or_default();
            match cell.last() {
                Some(last) if b.datetime < last.datetime => continue,
                Some(last) if b.datetime == last.datetime => {
                    let slot = cell.last_mut().expect("len>1");
                    *slot = b.clone();
                }
                _ => {
                    cell.push(b.clone());
                    if cell.len() > KLINE_CELL_CAP {
                        cell.remove(0);
                    }
                }
            }
        }
        self.frames.fetch_add(1, Ordering::AcqRel);
    }

    /// eBarUpdate. 收单 bar 快照(实时门;同 dt 替尾,新 dt 追加 = in-flight bar)。
    pub fn on_update(&self, b: &BarData) {
        let mut cells = self.cells.lock().unwrap();
        let iv = b
            .interval
            .unwrap_or(ek_data::constant::Interval::MINUTE)
            .seconds();
        let key = (b.symbol.clone(), iv);
        let cell = cells.entry(key).or_default();
        match cell.last() {
            Some(last) if b.datetime == last.datetime => {
                let slot = cell.last_mut().expect("len>1");
                *slot = b.clone();
            }
            Some(last) if b.datetime < last.datetime => {}
            _ => {
                cell.push(b.clone());
                if cell.len() > KLINE_CELL_CAP {
                    cell.remove(0);
                }
            }
        }
        self.updates.fetch_add(1, Ordering::AcqRel);
    }

    /// dm.reload 回灌登记(RELOAD OK 计数;dm 侧行为独立)。
    pub fn note_reload(&self) {
        self.reloads.fetch_add(1, Ordering::AcqRel);
    }

    /// 全格只读快照(键序 = BTreeMap 序,symbol/interval 稳定)。
    pub fn cells(&self) -> Vec<CellView> {
        self.cells
            .lock()
            .unwrap()
            .iter()
            .map(|((sym, iv), bars)| CellView {
                symbol: sym.clone(),
                interval: *iv,
                bars: bars.clone(),
            })
            .collect()
    }

    /// 计算(Ai) 帧统计。
    pub fn counts(&self) -> (u64, u64, u64) {
        (
            self.frames.load(Ordering::Acquire),
            self.updates.load(Ordering::Acquire),
            self.reloads.load(Ordering::Acquire),
        )
    }
}

/// 回灌表单(v4:界面面已退役,总线口 `UiCmd::Reload` 保留;解析器留作
/// 测试与脚本面)。
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ReloadForm {
    pub interval: String,
    pub symbol: String,
    pub csv: String,
}

/// 票据网关选择子解析(多账号面)。
///
/// 0.2.15 起**不再兜底成字面量 `"ctp"`**:多账号下"空网关"必须显式落到
/// `fallback`(= [`UiShared::default_gateway`] 的阶梯:行级带入 → 上次选择 →
/// 第一个已连接账户);一个账户都没有时回 `Err`,界面据此禁用下单钮。
/// 单账号旧行为仍成立(fallback 就是那个账户的派生名)。
pub fn parse_gateway(raw: &str, fallback: Option<&str>) -> Result<String, String> {
    let g = raw.trim();
    if !g.is_empty() {
        if g.chars().any(|c| c.is_whitespace()) {
            return Err(format!("网关解析: 含空白:{raw:?}"));
        }
        return Ok(g.to_string());
    }
    match fallback.map(|f| f.trim()).filter(|f| !f.is_empty()) {
        Some(f) => Ok(f.to_string()),
        None => Err("网关解析: 未选择账户(先在账户面板添加账户)".to_string()),
    }
}

/// CTP 网关配置表单(S6 测试面;界面手工输入源 → UiCmd::CtpConfigure →
/// ask `ctp.configure`。零配置口径:界面不读文件,值全部人敲;密码位掩码)。
#[derive(Debug, Clone, PartialEq)]
pub struct CtpForm {
    pub broker_id: String,
    pub td_addr: String,
    pub td_user: String,
    pub td_password: String,
    pub td_auth_code: String,
    pub td_app_id: String,
    pub md_addr: String,
    pub md_user: String,
    pub md_password: String,
    /// 订阅标的(逗号分隔;`订阅行情` 钮解析)。
    pub symbols: String,
    /// 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。
    /// 缺省只勾期货 —— 期权默认不勾,因为默认口径与改动前一致(只期货上抛)。
    pub accept_futures: bool,
    pub accept_options: bool,
}

/// 手写 `Default`(不能 derive:`accept_futures` 必须为 `true`,derive 会给 `false`,
/// 而两个都 false 在语义上是「什么都不收录」)。
impl Default for CtpForm {
    fn default() -> Self {
        Self {
            broker_id: String::new(),
            td_addr: String::new(),
            td_user: String::new(),
            td_password: String::new(),
            td_auth_code: String::new(),
            td_app_id: String::new(),
            md_addr: String::new(),
            md_user: String::new(),
            md_password: String::new(),
            symbols: String::new(),
            accept_futures: true,
            accept_options: false,
        }
    }
}

impl CtpForm {
    /// 勾选 → CTP 产品类码表(期货 `'1'`、期权 `'2'`)。
    /// 两个都不勾交空表给插件:插件侧归一化一律回落默认期货 —— **空不等于全丢**
    /// (共享合约表为空会让网关的持仓查询整条被跳过)。
    #[must_use]
    pub fn product_classes(&self) -> Vec<String> {
        let mut codes = Vec::new();
        if self.accept_futures {
            codes.push("1".to_string());
        }
        if self.accept_options {
            codes.push("2".to_string());
        }
        codes
    }

    /// 表单 → 线上契约(字段直映;md 回落由插件侧裁决,表单不预处理)。
    pub fn to_configure_req(&self) -> ek_ctp::plugin::ConfigureReq {
        ek_ctp::plugin::ConfigureReq {
            broker_id: self.broker_id.trim().to_string(),
            td_addr: self.td_addr.trim().to_string(),
            td_user: self.td_user.trim().to_string(),
            td_password: self.td_password.clone(),
            td_auth_code: self.td_auth_code.trim().to_string(),
            td_app_id: self.td_app_id.trim().to_string(),
            md_addr: self.md_addr.trim().to_string(),
            md_user: self.md_user.trim().to_string(),
            md_password: self.md_password.clone(),
            product_classes: self.product_classes(),
        }
    }

    /// 订阅位解析(逗号/空白分隔;空表 = Err)。
    pub fn parse_symbols(&self) -> Result<Vec<String>, String> {
        let list: Vec<String> = self
            .symbols
            .split([',', ',', ' '])
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if list.is_empty() {
            return Err("订阅解析: 标的位空".to_string());
        }
        Ok(list)
    }

    /// 添加/连接前的形制校验(规划 U7:旧口径是"前置为空照样建账户并下发两令")。
    ///
    /// 判据:`tcp://host:port` 形制(host 非空、端口为 1..=65535 的十进制)、
    /// broker/账号非空、AppID/AuthCode 非空(CTP 登录必需,空值只会换来一次注定失败的登录)。
    /// **md 前置允许为空** = 纯 TD 账户(单源纪律:非行情源不建 MD 会话)。
    ///
    /// 返回 `Err(第一条原因)` —— 界面红字呈现,不当『添加』按钮的硬拦截前置条件之外的东西
    /// (校验只在『添加』『连接』两条入口上跑,低层口 `add_account` 不重复校验)。
    pub fn validate(&self) -> Result<(), String> {
        fn front(name: &str, v: &str) -> Result<(), String> {
            let v = v.trim();
            let Some(rest) = v.strip_prefix("tcp://") else {
                return Err(format!("{name} 形制应为 tcp://host:port(当前 {:?})", v));
            };
            let Some((host, port)) = rest.rsplit_once(':') else {
                return Err(format!("{name} 缺端口:{name} {:?}", v));
            };
            if host.trim().is_empty() {
                return Err(format!("{name} 主机位空:{}", v));
            }
            let port: u32 = port
                .parse()
                .map_err(|_| format!("{name} 端口不是数字:{}", v))?;
            if port == 0 || port > 65535 {
                return Err(format!("{name} 端口越界(1..=65535):{}", v));
            }
            Ok(())
        }
        if self.broker_id.trim().is_empty() {
            return Err("broker 位空".to_string());
        }
        if self.td_user.trim().is_empty() {
            return Err("交易账号位空".to_string());
        }
        front("td 前置", &self.td_addr)?;
        if !self.md_addr.trim().is_empty() {
            front("md 前置", &self.md_addr)?;
        }
        if self.td_app_id.trim().is_empty() {
            return Err("AppID 位空(CTP 登录必需)".to_string());
        }
        if self.td_auth_code.trim().is_empty() {
            return Err("AuthCode 位空(CTP 登录必需)".to_string());
        }
        Ok(())
    }
}

/// 账户装配四态(0.2.18,规划 §3.3「添加 ≠ 连接 ≠ 断开 ≠ 移除」)。
///
/// 口径边界(诚实):这一层只说**实例装配**,不说登录 —— 登录成败/掉线 ek-ctp 无状态口
/// 可查(开放项 `ctp.status`,规划裁定 3:状态机等终态口,不做 eLog 自建过渡)。
/// 因此 `Assembled` 的界面文案必须是「已装配·登录态未知」(短形:状态列宽有限,
/// 长文案会被 Grid 挤成竖排 —— s39 快照实锤过),不得写成「已连接」。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AccountStage {
    /// 未装配:添加后未连接、实例已摘、或装配失败后未重试。
    #[default]
    NotAssembled,
    /// 装配中:`UiCmd::Connect` 已入列,svctl 还没把这个注册名带回在场表。
    Assembling,
    /// 实例已装配(登录态未知)。
    Assembled,
    /// 装配失败:`svctl.start` 被拒/超时,或 `{gw}.configure` 被拒。可重试。
    AssembleFailed,
}

impl AccountStage {
    /// 界面文案(状态列 / 徽标)。
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            AccountStage::NotAssembled => "未装配",
            AccountStage::Assembling => "装配中…",
            // 规划 §3.5 缓解:不让「已装配」被读成「在线」。
            // (文案长度有实测约束:账户面板是 Grid,状态列文案过长会被挤成竖排换行。)
            AccountStage::Assembled => "已装配·登录态未知",
            AccountStage::AssembleFailed => "装配失败",
        }
    }
}

/// 登录态(0.2.19;事实源 = ek-ctp 0.9.0 `{gw}.status`,规划 P3 / 裁定 3)。
///
/// 与 [`AccountStage`] 是**两件事**:stage 说「实例在不在」,这一档说「会话活着没有」。
/// 状态列把两者拼起来(`AccountRow::status_label`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginState {
    /// 还没读到读数,或读到了但没有任何登录证据。
    ///
    /// 网关的「句柄已建」(**`td_handle_up`**)**不等于在线** —— 掉线时它仍是真
    /// (ek-ctp DESIGN D-STATUS ①),所以没有 `logged_in` 就只有这一档,不得显示「在线」。
    #[default]
    Unknown,
    /// 网关回报 `td_logged_in`(纯行情账户看 `md_logged_in`)—— 在线的唯一硬证据。
    Online,
    /// 最近一次登录失败(网关在成功后会清零这一位,所以它显形 = 现在就是登不上)。
    LoginFailed,
    /// 柜台断过线且当前未登录(`td_disconnects > 0`;计数跨重连保留)。
    FrontDown,
    /// 登录着但查询连续被拒 / 资金回报超时(规划 Q10 两判据,阈值在界面)。
    QueryStarved,
    /// 用户主动『断开』(规划裁定 1):会话已停,实例与订阅还在,『连接』可恢复。
    Stopped,
}

impl LoginState {
    /// 状态列短文案(账户面板是 Grid,长文案会被挤成竖排 —— s39 快照实锤过)。
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            LoginState::Unknown => "登录态未知",
            LoginState::Online => "在线",
            LoginState::LoginFailed => "登录失败",
            LoginState::FrontDown => "掉线",
            LoginState::QueryStarved => "查询断供",
            LoginState::Stopped => "已断开",
        }
    }

    /// 这一档要不要在状态列上着色(红/橙)。
    #[must_use]
    pub fn is_alarm(self) -> bool {
        matches!(self, LoginState::LoginFailed | LoginState::FrontDown)
    }
}

/// 「掉线 N 次」这类细节:N = 0 回 `None` —— 没有发生过就不该占状态列的位置。
fn count_note(label: &str, n: u32) -> Option<String> {
    (n > 0).then(|| format!("{label} {n} 次"))
}

/// 柜台错误原句 → **短原因**(账户行状态列用)。原句形状照 ek-ctp `map::error_text`
/// (`map.rs:332-333`)= `{消息},代码:{id},信息:{正文}`。
///
/// 取「信息」的正文(去掉 CTP 侧的 `CTP:` 前缀)并把代码括在后面 = `不合法的登录(3)`。
/// 形状对不上(网关没回带原因、或不是这条格式)就回 `None` —— 状态列宁可只写「登录失败」,
/// 也不编一个原因出来。
fn brief_counter_reason(full: &str) -> Option<String> {
    let tail = full.rfind("信息:").map(|i| &full[i + "信息:".len()..])?;
    let body = tail.trim().trim_start_matches("CTP:").trim();
    if body.is_empty() {
        return None;
    }
    let code = full
        .rfind("代码:")
        .map(|i| {
            let rest = &full[i + "代码:".len()..];
            let end = rest.find(',').unwrap_or(rest.len());
            rest[..end].trim().to_string()
        })
        .filter(|c| !c.is_empty() && c.parse::<i64>().is_ok());
    Some(match code {
        Some(c) => format!("{body}({c})"),
        None => body.to_string(),
    })
}

/// 账户行(多账号面;规划 §5.B + 0.2.18 四态)。
///
/// 界面是账户清单的**唯一事实源**(不新增"账户注册表"插件):
/// 网关实例的在场由 `svctl.list` 回填,身份与凭据留在这一行,**每账户一份 form**
/// (旧「全界面共享一张表单」已退役 → 消 U4 凭据串味 / U6 取消编辑不清空)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AccountRow {
    /// 派生注册名(`ctp_<broker>_<账号>`;**寻址唯一用这个**,别名不参与)。
    pub gateway: String,
    /// 用户别名(显示用,可空 = 回退账号;唯一性由 `add_account` 校验,规划 Q3)。
    pub alias: String,
    pub broker_id: String,
    pub account: String,
    /// 是否行情元账号(**全局单源**——用户裁定"单数据源";已装配后锁定,规划 D3)。
    pub md_source: bool,
    /// 该账户自己的表单留档(编辑弹窗回填用;密码只存内存,裁定 2:不持久化)。
    pub form: CtpForm,
    /// 装配四态(规划 §3.3)。
    pub stage: AccountStage,
    /// 装配失败原因(`AssembleFailed` 时留显形;成功即清空)。
    pub fail_note: Option<String>,
    /// **网关实际收录回带值**(`ConfigureReply.product_classes`,规划 U9)。
    ///
    /// 与 `form.accept_*`(用户勾选)并排显示:勾选是意图,这一栏是事实。
    /// 空 = 尚未装配过或旧版网关未回带。
    pub effective_classes: Vec<String>,
    /// 在场位(由 `svctl.list` 快照回填:该注册名在不在 = 实例已装配)。
    /// 0.2.19 起登录成败另有事实源(`{gw}.status` → `login`),这一位只管实例。
    pub connected: bool,
    /// **登录态(0.2.19)**:`{gw}.status` 读数折出来的,与 `stage`/`connected` 正交。
    ///
    /// 默认 `Unknown`:装配成功本身不构成任何登录证据(ek-ctp D-STATUS ①)。
    pub login: LoginState,
    /// 状态细节(「掉线 2 次」「查询连续被拒 3 次」「资金回报已 45s 未更新」)。
    /// `None` = 没有可说的细节(不是「一切正常」——一切正常由 `login` 说)。
    pub status_note: Option<String>,
    /// **柜台原因的短标签(0.4.1 十期 BD-UI-63)**:登录失败/掉线时从 `td_last_error` 里
    /// 抠出「代码:N」与「信息:」的正文,给账户行的状态列用(红字后面直接跟着原因)。
    ///
    /// 为什么不直接把整句塞进状态列:柜台原句是「交易服务器登录失败,代码:3,信息:CTP:不
    /// 合法的登录」这种长句,Grid 状态列被它挤宽就会把别的列顶窄(s39 那口径)。整句留在
    /// `status_note` 里,窗下方「状态细节」那一行照旧给全。
    pub login_brief: Option<String>,
    /// **链状态(0.5.0,用户定「链进行的状态改为事件日志推送」)**:该网关最近一条 `[链] `
    /// 状态行的正文(去掉前缀)。
    ///
    /// 事实源是 ek-ctp 0.9.8 推送的日志(前置已连接 / 授权通过 / 登录成功 / 结算单已确认 /
    /// 查询合约 第N次 / 合约已到手开始轮询 / 合约未到手本轮已停止 / 会话已复位),不是轮询读数。
    /// `None` = 这一条会话还没推过状态行(显示「—」)。**它只是显示**:不参与 `apply_status`
    /// 那套在线/断供判据,也不参与任何下单判据。
    pub chain_note: Option<String>,
}

impl AccountRow {
    /// 由表单派生账户行(派生名唯一入口 = `ek_ctp::derived_gateway_name`)。
    ///
    /// 返回 `Err(原因)` = broker/账号非法或派生名超宿主 32 字符上限;
    /// 界面须红字呈现,**不得静默替换**(规划 §3)。
    pub fn derive(form: &CtpForm) -> Result<Self, String> {
        let broker = form.broker_id.trim();
        let account = form.td_user.trim();
        let gateway = ek_ctp::derived_gateway_name(broker, account).ok_or_else(|| {
            "账户: broker/账号 不能为空且只能含字母数字(且派生名 ≤32)".to_string()
        })?;
        Ok(Self {
            gateway,
            alias: String::new(),
            broker_id: broker.to_string(),
            account: account.to_string(),
            md_source: false,
            form: form.clone(),
            stage: AccountStage::NotAssembled,
            fail_note: None,
            effective_classes: Vec::new(),
            connected: false,
            login: LoginState::Unknown,
            status_note: None,
            login_brief: None,
            chain_note: None,
        })
    }

    /// 状态列文案(装配态 + 登录态拼一起)。
    ///
    /// 「已装配」后面必须跟着登录态,不能光写「已装配」——0.2.18 那条「不得读成在线」
    /// 的口径(规划 U5)现在有了真读数才真正立住:没有证据时写「已装配·登录态未知」。
    /// 装配段一律走 `AccountStage::label()`,不在这里重抄一遍文案(两处不同式就是坑)。
    #[must_use]
    pub fn status_label(&self) -> String {
        if self.stage == AccountStage::Assembled {
            match self.login {
                LoginState::Unknown => "已装配·登录态未知".to_string(),
                other => format!("已装配·{}", other.label()),
            }
        } else {
            self.stage.label().to_string()
        }
    }

    /// 把 `{gw}.status` 读数折成界面登录态 + 细节(0.2.19;规划 P3 / Q10)。
    ///
    /// 判据顺序写明(每条都有理由):
    /// 1. **登录失败先判** —— 网关在登录成功后会清零 `td_login_failed`,所以它显形就是现在登不上;
    /// 2. **登录着** → 才谈「查询断供」,三条判据:连续被拒达阈 / 距最后一次资金回报超时 /
    ///    **登录后资金回报从未到手且已超过同一个 stale 阈值**(0.2.21,实连 S15f 逼出来的第三条);
    ///    都没中 = 在线;
    /// 3. **没登录** → 用户主动『断开』优先(那是自己按的钮,比柜台掉线计数更贴近用户意图),
    ///    其次才是柜台掉线(`disconnects > 0`),最后才是「未知」;
    /// 4. **纯行情账户**(td 前置留档为空)看 MD 半边 —— 看 TD 半边永远是「未知」,那是假信息。
    ///
    /// 「从未发生」= `None` 不当 0 用:网关没回报过资金时刻 ≠ 「0 秒前刚回报过」。
    pub fn apply_status(
        &mut self,
        st: &ek_ctp::plugin::GatewayStatus,
        now_ms: i64,
        reject_limit: u32,
        stale_secs: i64,
    ) {
        let td_side = !self.form.td_addr.trim().is_empty();
        let (login, note) = if td_side {
            if st.td_login_failed && !st.td_logged_in {
                (
                    LoginState::LoginFailed,
                    st.td_last_error
                        .clone()
                        .or_else(|| Some("登录失败(网关未回带原因)".to_string())),
                )
            } else if st.td_logged_in {
                if st.td_query_reject_streak >= reject_limit {
                    (
                        LoginState::QueryStarved,
                        Some(format!("查询连续被拒 {} 次", st.td_query_reject_streak)),
                    )
                } else if let Some(last) = st.td_last_account_ms {
                    let idle_ms = now_ms.saturating_sub(last);
                    if idle_ms > stale_secs.saturating_mul(1_000) {
                        (
                            LoginState::QueryStarved,
                            Some(format!("资金回报已 {}s 未更新", idle_ms / 1_000)),
                        )
                    } else {
                        (LoginState::Online, count_note("掉线", st.td_disconnects))
                    }
                } else {
                    // 登录成功但资金一次都没到手。轮转第一拍本就要 2~6 秒(ek-ctp 0.7.0 口径),
                    // 所以刚登录那几秒只算「尚未到手」不算断供;但**"从未到手"不能永远算在线** ——
                    // 2026-10-22 实连 S15f 实测:账户 B `logged_in=true`、查询被拒 0 次、
                    // 资金时刻 `None` 持续 390 秒以上,界面上却一直显示「在线 + 资金回报尚未到手」。
                    // 超过同一个 stale 阈值就翻「查询断供」:判据原料齐了(登录时刻 + 读数时刻),
                    // 不需要网关多给任何东西。
                    let since_login_ms = st.td_last_login_ok_ms.map(|ok| now_ms.saturating_sub(ok));
                    match since_login_ms {
                        Some(ms) if ms > stale_secs.saturating_mul(1_000) => (
                            LoginState::QueryStarved,
                            Some(format!("资金回报从未到手(登录已 {}s)", ms / 1_000)),
                        ),
                        _ => (LoginState::Online, Some("资金回报尚未到手".to_string())),
                    }
                }
            } else if self.login == LoginState::Stopped {
                let extra = if st.td_disconnects > 0 {
                    format!("(此前掉线 {} 次)", st.td_disconnects)
                } else {
                    String::new()
                };
                (
                    LoginState::Stopped,
                    Some(format!("会话已停{extra};『连接』可恢复")),
                )
            } else if st.td_disconnects > 0 {
                (
                    LoginState::FrontDown,
                    Some(format!("掉线 {} 次", st.td_disconnects)),
                )
            } else {
                (LoginState::Unknown, st.td_last_error.clone())
            }
        } else if st.md_logged_in {
            (
                LoginState::Online,
                count_note("掉线", st.md_disconnects)
                    .or_else(|| Some("纯行情账户(无 TD 会话)".to_string())),
            )
        } else if self.login == LoginState::Stopped {
            (
                LoginState::Stopped,
                Some("会话已停;『连接』可恢复".to_string()),
            )
        } else if st.md_disconnects > 0 {
            (
                LoginState::FrontDown,
                Some(format!("掉线 {} 次", st.md_disconnects)),
            )
        } else {
            (LoginState::Unknown, st.md_last_error.clone())
        };
        // 0.9.1 分项接上(随 ek-ctp 0.9.1):总数与连续串**不分路**(消费侧要的是
        // 「查询这条路连续被拒几次」),但用户要知道被拒的是哪一路 —— 合约一条没到手时,
        // 下一笔单会被 ContractGate 以 `contract:unknown` 拒掉,而状态列只写「在线」
        // 就是假信息。加在句尾而不是另开一行:Grid 列宽会被长文案挤成竖排(s39 实锤)。
        let note = if td_side && st.td_instrument_rejects > 0 {
            let add = format!("合约查询被拒 {} 次", st.td_instrument_rejects);
            match note {
                // 已经点名过合约查询的句子不重复加。
                Some(base) if base.contains("合约查询被拒") => Some(base),
                Some(base) => Some(format!("{base};{add}")),
                None => Some(add),
            }
        } else {
            note
        };
        self.login = login;
        self.status_note = note;
        // 告警态(登录失败 / 掉线)才配「柜台原因」短标签:在线、未知、已断开都不占状态列。
        // 这一位是账户行红字后面那段原因的**唯一来源**(BD-UI-63)。
        self.login_brief = if login.is_alarm() {
            st.td_last_error
                .as_deref()
                .or(st.md_last_error.as_deref())
                .and_then(brief_counter_reason)
        } else {
            None
        };
    }

    /// 显示名:别名优先,空则回退账号(寻址仍只用 `gateway`,规划 Q3)。
    #[must_use]
    pub fn display_name(&self) -> String {
        let a = self.alias.trim();
        if a.is_empty() {
            self.account.clone()
        } else {
            a.to_string()
        }
    }
}

/// 交易监控快照(渲染侧读的那一份;0.5 起它是四本 keyed 账的缓存视图,
/// 不再是「eTimer 拍 ask oms 全量查询组帧」的产物)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TradeSnap {
    pub orders: Vec<ek_data::order::OrderData>,
    pub trades: Vec<ek_data::trade::TradeData>,
    pub positions: Vec<ek_data::position::PositionData>,
    /// 资金帧(oms.get_all_accounts;CTP configure 登录后有数)。
    pub accounts: Vec<ek_data::account::AccountData>,
}

/// CSV(每行 `dt,open,high,low,close,volume`;dt 为 epoch 秒)→ BarData 批。
/// 错行报行号;空 csv = Err;升序不强制(dm.reload 自会逐行入窗)。
pub fn parse_reload_csv(form: &ReloadForm) -> Result<(i64, String, Vec<BarData>), String> {
    let interval: i64 = form
        .interval
        .trim()
        .parse()
        .map_err(|_| format!("回灌 解析: interval 非整数:{:?}", form.interval))?;
    let symbol = form.symbol.trim().to_string();
    if symbol.is_empty() {
        return Err("回灌解析: symbol 空".to_string());
    }
    if form.csv.trim().is_empty() {
        return Err("回灌解析: csv 空".to_string());
    }
    let mut bars = Vec::new();
    for (i, raw) in form.csv.lines().enumerate() {
        let line = raw.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let parts: Vec<&str> = line.split(',').map(str::trim).collect();
        if parts.len() != 6 {
            return Err(format!("回灌解析: 第 {} 列数 ≠ 6", i + 1));
        }
        let num = |s: &str| -> Result<f64, String> {
            s.parse::<f64>()
                .map_err(|_| format!("回灌解析: 第 {} 列数值坏:{:?}", i + 1, s))
        };
        let dt_s: i64 = parts[0]
            .parse()
            .map_err(|_| format!("回灌解析: 第 {} 列 dt 坏:{:?}", i + 1, parts[0]))?;
        bars.push(BarData {
            symbol: symbol.clone(),
            datetime: dt_s * 1_000_000_000,
            interval: Some(
                ek_dm::wire::interval_of_seconds(interval)
                    .unwrap_or(ek_data::constant::Interval::MINUTE),
            ),
            open_price: num(parts[1])?,
            high_price: num(parts[2])?,
            low_price: num(parts[3])?,
            close_price: num(parts[4])?,
            volume: num(parts[5])?,
            ..BarData::default()
        });
    }
    if bars.is_empty() {
        return Err("回灌解析: 无有效行".to_string());
    }
    Ok((interval, symbol, bars))
}

// ================= v4 布局面(带/组 tab / tick 库 / 下单表单;docs/ek-ui-layout-v2-plan.md) =================

/// 左上组 tab(Python [委托|未成交];审计修正:行情是带不是 tab,K线/运维/CTP
/// 是弹窗不是 tab,故不设全局 10 值 UiTab——那是 v2 残影)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TopLeftTab {
    #[default]
    Orders,
    Active,
}

impl TopLeftTab {
    pub fn label(self) -> &'static str {
        match self {
            Self::Orders => "委托",
            Self::Active => "未成交",
        }
    }
}

/// 右下组 tab(Python [资金|日志])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BottomRightTab {
    #[default]
    Account,
    Logs,
}

impl BottomRightTab {
    pub fn label(self) -> &'static str {
        match self {
            Self::Account => "资金",
            Self::Logs => "日志",
        }
    }
}

/// 数据区两刀的份额(0.4.1 BD-UI-54)。Python `right_splitter` = vertical 50:50,
/// 我们的左右刀与之正交;两值都夹在 `crate::splitters::clamp_frac` 界内。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DataSplit {
    /// 左右刀:左列占比(委托|未成交 在左)。
    pub col: f32,
    /// 上下刀:上行占比(委托/成交 在上)。
    pub row: f32,
}

impl Default for DataSplit {
    fn default() -> Self {
        Self {
            col: crate::splitters::DATA_SPLIT_DEFAULT,
            row: crate::splitters::DATA_SPLIT_DEFAULT,
        }
    }
}

/// 行情带六所固定 tab 序(0.4.1 BD-UI-54:六所各一格 = 用户裁定「每个交易所
/// 都要一个」;原 `crate::dock::MARKET_ORDER` 随 dock 拆除,常量原样搬到这里)。
pub const MARKET_ORDER: [ek_data::constant::Exchange; 6] = [
    ek_data::constant::Exchange::SHFE,
    ek_data::constant::Exchange::DCE,
    ek_data::constant::Exchange::CZCE,
    ek_data::constant::Exchange::CFFEX,
    ek_data::constant::Exchange::INE,
    ek_data::constant::Exchange::GFEX,
];

// 0.2.30(BD-UI-46,裁定 8):主题面整块删除 —— 只保留一套主题(当时 = Qt深色
// qdarkstyle 3.2.3 复刻;0.4.1 四期 BD-UI-57 换成 shadcn Nova 深色,「只有一套」这条
// 裁定不变,换的是值),视觉规格搬进 `crate::tokens`。原来的 `Theme` 五变体枚举、
// `label()`、`next()` 循环、`UiShared::theme()/set_theme()/cycle_theme()`、菜单循环钮
// 全部退役。这是一次**有意的公共面收缩**:`ek_ui::state::Theme` 不再存在。外部没有任何
// crate 依赖 ek-ui,所以收缩不触发联动;但它不可逆,记在 CHANGELOG 0.2.30。

/// tick 库(v2 行情 tab 唯一数据源):symbol → 最新 tick,逐 symbol 覆盖,
/// 有界 = 订阅面(ek-ctp 只发已订阅合约;无 bar 回落,用户裁定)。
#[derive(Debug, Default)]
pub struct TickStore {
    ticks: BTreeMap<String, ek_data::tick::TickData>,
}

impl TickStore {
    /// 逐合约覆盖(审计修正:键 = en_symbol「交易所.合约」,防跨所同码相撞;
    /// 交易所分组取 tick 自带 exchange)。
    pub fn put(&mut self, t: &ek_data::tick::TickData) {
        self.ticks
            .insert(format!("{}.{}", t.exchange.as_str(), t.symbol), t.clone());
    }

    /// 只读视图(交易所 → 合约排序,tab 分组顺序稳定)。
    pub fn rows(&self) -> Vec<ek_data::tick::TickData> {
        let mut v: Vec<ek_data::tick::TickData> = self.ticks.values().cloned().collect();
        v.sort_by(|a, b| {
            (a.exchange.as_str().to_string(), a.symbol.clone())
                .cmp(&(b.exchange.as_str().to_string(), b.symbol.clone()))
        });
        v
    }

    /// 按交易所分组(tab 键只列有 tick 的所,不预置全枚举)。
    pub fn groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
        let mut out: Vec<(String, Vec<ek_data::tick::TickData>)> = Vec::new();
        for t in self.rows() {
            let ex = t.exchange.as_str().to_string();
            match out.last_mut() {
                Some((e, v)) if *e == ex => v.push(t),
                _ => out.push((ex, vec![t])),
            }
        }
        out
    }

    pub fn len(&self) -> usize {
        self.ticks.len()
    }

    pub fn is_empty(&self) -> bool {
        self.ticks.is_empty()
    }
}

/// 下单面板表单(v2 常驻右侧;Python TradingWidget 字段面等价;操作面非配置面)。
#[derive(Debug, Clone, PartialEq)]
pub struct OrderForm {
    /// 交易所代码(下拉;from_str 抬成枚举)
    pub exchange: String,
    /// 合约代码(纯 symbol,不带前缀)
    pub symbol: String,
    pub direction: ek_data::constant::Direction,
    pub offset: ek_data::constant::Offset,
    pub order_type: ek_data::constant::OrderType,
    /// 输入面保持字符串(同 Python QLineEdit),委托时解析
    pub price: String,
    pub volume: String,
    /// 网关(下拉源 = trade_snap gateway 去重集 ∪ CTP 表单现值)
    pub gateway: String,
    /// 价格随行情勾选(勾选时价格框随选中合约最新 tick)
    pub follow_price: bool,
    /// 「自动开平」勾选(0.1.1 BD-UI-30;默认开 = LockMode::Auto 走拆单器,
    /// 关 = LockMode::None 原样旁路——守卫不可绕,拆单可逐请求旁路)
    pub auto_split: bool,
}

impl Default for OrderForm {
    fn default() -> Self {
        Self {
            exchange: "SHFE".to_string(),
            symbol: String::new(),
            direction: ek_data::constant::Direction::LONG,
            offset: ek_data::constant::Offset::OPEN,
            order_type: ek_data::constant::OrderType::LIMIT,
            price: String::new(),
            volume: "1".to_string(),
            gateway: String::new(),
            follow_price: true,
            auto_split: true,
        }
    }
}

impl OrderForm {
    /// 表单 → OrderRequest(与票据解析同一出口;错误红字就地,不进总线)。
    pub fn to_request(&self) -> Result<ek_data::order_request::OrderRequest, String> {
        use std::str::FromStr;
        let symbol = self.symbol.trim();
        if symbol.is_empty() {
            return Err("下单: 代码为空".to_string());
        }
        if symbol.contains('.') {
            return Err("下单: 代码不带交易所前缀(交易所走下拉)".to_string());
        }
        let exchange = ek_data::constant::Exchange::from_str(self.exchange.trim())
            .map_err(|e| format!("下单: {e}"))?;
        let price: f64 = self
            .price
            .trim()
            .parse()
            .map_err(|_| format!("下单: 价格无效 {:?}", self.price))?;
        let volume: f64 = self
            .volume
            .trim()
            .parse()
            .map_err(|_| format!("下单: 数量无效 {:?}", self.volume))?;
        if self.order_type != ek_data::constant::OrderType::MARKET && price <= 0.0 {
            return Err("下单: 限价单价格须为正".to_string());
        }
        if volume <= 0.0 {
            return Err("下单: 数量须为正".to_string());
        }
        Ok(ek_data::order_request::OrderRequest {
            symbol: symbol.to_string(),
            exchange,
            direction: self.direction,
            order_type: self.order_type,
            volume,
            price,
            offset: self.offset,
            ..ek_data::order_request::OrderRequest::default()
        })
    }
}

/// 合约库(v4 查询合约窗数据源):en_symbol → ContractData。
///
/// 数据面 = ek-ctp 登录后自动 reqQryInstrument 回流(`eContract.` 裸键逐条 +
/// `eContractFinish` 完成线,driver.rs:779)——**无 ask 口,UI 只收不催**
/// (用户裁定:订阅不在网关配置定死,查询窗勾选订阅 / 下单代码回车)。
#[derive(Debug, Default)]
pub struct ContractStore {
    contracts: BTreeMap<String, ek_data::contract::ContractData>,
}

impl ContractStore {
    pub fn put(&mut self, c: &ek_data::contract::ContractData) {
        self.contracts
            .insert(format!("{}.{}", c.exchange.as_str(), c.symbol), c.clone());
    }

    /// Python ContractManager 查询口径:代码/交易所/名称模糊,留空 = 全部。
    pub fn query(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
        let p = pattern.trim();
        self.contracts
            .values()
            .filter(|c| {
                p.is_empty()
                    || c.symbol.contains(p)
                    || c.exchange.as_str().eq_ignore_ascii_case(p)
                    || c.name.contains(p)
            })
            .cloned()
            .collect()
    }

    pub fn len(&self) -> usize {
        self.contracts.len()
    }

    pub fn is_empty(&self) -> bool {
        self.contracts.is_empty()
    }
}

/// 插件面板与宿主观察线程共用的观测状态。
#[derive(Debug, Default)]
pub struct UiShared {
    logs: Mutex<LogRing>,
    snap: Mutex<Arc<BusSnap>>,
    market: MarketStore,
    ctp_form: Mutex<CtpForm>,
    /// 交易四本账(0.5 M1:事件驱动后的唯一事实源;键 = 基线 `data_key`)。
    /// 值带行序 `u64` —— 基线 `insert_new_row` 是 `insertRow(0)`(新事件在前)。
    trade_orders: Mutex<BTreeMap<String, (u64, ek_data::order::OrderData)>>,
    trade_trades: Mutex<VecDeque<(u64, ek_data::trade::TradeData)>>,
    /// 成交去重集(键 `en_tradeid` = `{网关}.{成交号}`)。
    ///
    /// 口径来自基线**引擎层** `BaseTradeEngine.trades_filter_dict`(`engine.py:1072-1080`,
    /// 注释写明它是为「账户重连时柜台重推同一笔成交」准备的),与 `ek-oms` 的
    /// `trades_filter`(`oms.rs:203 / 260-264`)同键同口径。成交表本身没有 `data_key`
    /// (基线 `TradeMonitor` :1267 只插不改),所以去重只能靠这张集,不能靠行键。
    /// 它跟着账本走:触顶被挤掉的行同时出集(那笔再推就还能上屏),摘网关时同网关一起清。
    trade_ids: Mutex<HashSet<String>>,
    trade_positions: Mutex<BTreeMap<String, (u64, ek_data::position::PositionData)>>,
    trade_funds: Mutex<BTreeMap<String, (u64, ek_data::account::AccountData)>>,
    /// 行序计数器(每次入账递增;快照按它降序排 = 新事件在前)。
    trade_seq: AtomicU64,
    /// 成交账本触顶丢弃计数(0.5 偏离③;>0 说明更早的成交已被丢掉)。
    trade_dropped: AtomicU64,
    /// 四本账 → 快照的重建标记(入账置真,`trade_snap()` 消费)。
    trade_dirty: AtomicBool,
    /// 渲染侧读的交易快照 = 四本账的缓存视图(脏时按帧重建一次)。
    trade_snap: Mutex<Arc<TradeSnap>>,
    tick_store: Mutex<TickStore>,
    order_form: Mutex<OrderForm>,
    /// 数据区 2×2 两刀的份额(0.4.1 BD-UI-54:拆 dock 后归薄 QSplitter;
    /// col = 左右刀的左占比,row = 上下刀的上占比,缺省 = Python right_splitter 50:50)。
    data_split: Mutex<DataSplit>,
    /// 行情带当前 tab(按所;六所固定 tab 条,谁在前台就是谁)。
    selected_exchange: Mutex<String>,
    /// 委托|未成交 组的当前 tab。
    topleft_tab: Mutex<TopLeftTab>,
    /// 资金|日志 组的当前 tab。
    bottomright_tab: Mutex<BottomRightTab>,
    ops_window: AtomicBool,
    /// 账户面板(多账号面;取代旧单账号 CTP 配置窗的入口 —— 旧窗已退役,
    /// 旧 `ctp_window` 位随之删除,不留死状态)。
    accounts_window: AtomicBool,
    contracts_window: AtomicBool,
    contract_store: Mutex<ContractStore>,
    contract_query: Mutex<String>,
    contract_selected: Mutex<Vec<String>>,
    kline_windows: Mutex<Vec<String>>,
    rev: AtomicU64,
    /// 关窗/测试结束信号(窗口面置位,宿主观察线程消费)。
    stop: AtomicBool,
    /// svctl 操作指令队列(窗口面入,ui 插件 eTimer 出)。
    cmds: Mutex<VecDeque<UiCmd>>,
    /// 待二次确认的移除目标(单槽;再次同名点 = 发令)。
    pending_confirm: Mutex<Option<String>>,
    /// 账户清单(多账号面;界面唯一事实源)。
    accounts: Mutex<Vec<AccountRow>>,
    /// 账户配置弹窗开合(0.2.18 Commit B:『添加』与『编辑』同一弹窗两种模式)。
    account_edit_window: AtomicBool,
    /// 弹窗模式:`Some(派生名)` = 编辑该账户;**`None` = 添加新账户**(草稿位仍是 `ctp_form`)。
    editing_account: Mutex<Option<String>>,
    /// 弹窗里的别名草稿(『保存』时才写回账户行)。
    edit_alias: Mutex<String>,
    /// 弹窗内的拦截原因(校验不过 / 装配被拒):弹窗**不关**,原因上屏(规划 U3 / Q26)。
    edit_error: Mutex<Option<String>>,
    /// 行情元账号的注册名(空 = 未选;连接后锁定,规划 D3)。
    md_source: Mutex<String>,
    /// 订阅集(裸代码;断开重连后按它重放订阅,规划 §5.C)。
    subscriptions: Mutex<Vec<String>>,
    /// 表格行选中(BD-UI-31):表 id → 选中行键(每表一行;键由渲染侧构造,
    /// 纯界面态,不发指令;悬停跳色是渲染侧即时判断,不入状态)。
    row_sel: Mutex<BTreeMap<String, String>>,
    /// 0.3.1(Wave D,BD-UI-49)表格排序键:`表 id -> Some((列序, 升序))`,`None` = 不排序。
    /// 排序键住在界面状态,不进数据模型(W2 的判据就是这条 -- Python `widget.py:396-419`
    /// 那个「更新途中重排」的病,根因是排序作用在正在被喂的模型上)。
    table_sorts: Mutex<BTreeMap<String, Option<(usize, bool)>>>,
    /// 「列边界可拖」的开关(0.3.1 右键菜单「调整列宽」)已在 0.4.1 五期(BD-UI-58)退役:
    /// 可拖 + 列线是全部表的固定形制,不再是可开关的东西 → 字段与访问器一并删。
    /// 状态轮询游标(0.5 M3 摊平:每拍从游标起环形取 `STATUS_ASKS_PER_TICK` 个)。
    /// 0.7.0 第3刀:周期轮询退役(状态改吃 `eStatus.` 推送),这里只服务
    /// 「网关上线后的一次性播种」(每网关运行期至多一次 ask)。
    status_cursor: AtomicUsize,
    /// 一次性状态播种队列(0.7.0 第3刀):网关上线 → 问一次 `{gw}.status` 拿初始快照,
    /// 之后常态全靠 `eStatus.` 推送(界面零轮询)。
    status_seed: Mutex<Vec<String>>,
    /// `svctl.list` 轮询节拍计数(每 `LIST_POLL_TICKS` 拍一次)。
    list_tick: AtomicU32,
    /// 管制面快照的「现在就问」标记(0.5 执行核查补的洞):插件上/下线与 svctl 起停
    /// 之后必须立刻重问一次,否则在场位与运维窗那一行最多要等 4 拍才动 —— 0.4 是每拍
    /// 一次,这一档降下来就把「点了移除、行 4 秒不动」变成了常态。
    list_due: AtomicBool,
    /// 「重播种一次四张表」标记(0.5 执行核查补的第三处):网关或 oms 上线之后重问一次
    /// 四口。摘除一个网关账户会作废它名下的委托 / 成交行,而 oms 那边那些行还在 ——
    /// 重新添加时不重问一次,界面就永久少了一段历史。消费方是命令线程,不是 eTimer 拍。
    seed_due: AtomicBool,
    /// `oms.pipeline_status` 待问标记(0.5 M3:改事件触发 —— 启动 / oms 上线 / 手动刷新钮)。
    pipeline_due: AtomicBool,
    /// 配置托底段目录待问标记(0.6.0 环节8;启用 = 运维窗「刷新段落目录」钮)。
    cfg_due: AtomicBool,
    /// 配置托底段目录读数(0.6.0;`None` = 没问过 → 运维窗按句提示,不是空表;
    /// `Some(vec)`:零值面,`NsBrief` 只有 key/形态标志,BD-CFG-17/18)。
    cfg_briefs: Mutex<Option<Vec<ek_cfg::wire::NsBrief>>>,
    /// 清段二次确认臂(0.6.0;防误触,与 svctl 移除的 ConfirmStep 同形但独立)。
    cfg_delete_arm: Mutex<Option<String>>,
    /// **当前界面档(0.8.0 配色双档,BD-UI-61)**:`0 = Dark`(缺省)、`1 = Light`。
    /// 缺省 = 深色(裁定 B:没点过切换钮时现行为零变化)。AtomicU8 而不是 `Mutex<Theme>`:
    /// 渲染线程每帧读、命令线程偶发读,`Acquire/Release` 就够,不给每帧加锁。
    theme: AtomicU8,
    /// 主题落盘请求(0.8.0;只有**用户手点**切换钮才置位,照参考站「只有点击才存」)。
    /// 消费方是**命令线程**(写 ek-cfg 存储根 `ns="ui" key="theme"`)—— 渲染线程零文件 IO。
    theme_persist_due: AtomicBool,
    /// 推送线载荷类型异常的计数(0.5 M1:线名 → 次数)。不静默丢。
    bad_payload: Mutex<BTreeMap<String, u64>>,
    /// oms 装配显形的最新读数(`pipeline_status()` 的字符串行)。
    /// 空 = 没问到 / oms 不在场 → 运维窗整节不画(不是「画一个空表」)。
    pipeline_lines: Mutex<Vec<String>>,
    /// 「查询断供」阈值覆盖 `Some((连续被拒次数, 资金超时秒))`;`None` = 用缺省常量。
    /// 阈值住在界面不在网关(ek-ctp D-STATUS),覆盖位是给测试与后续配置面留的口子。
    status_thresholds: Mutex<Option<(u32, i64)>>,
    /// 0.3.2(Wave E,BD-UI-50)攒着待灌进 toast 队列的告警(界面层产生,渲染帧收口)。
    pending_alerts: Mutex<Vec<(crate::toast::AlertLevel, String)>>,
    /// 常驻 toast 队列(住在界面状态,不是 render 的局部变量 —— 否则 8 秒自动关与
    /// 同屏上限都没有意义)。`None` = 还没按我们的口径建过。
    toasts: crate::toast::ToastsCell,
    /// 已经弹过 toast 的告警键(去重:同一账户同一状态只弹一次,照 Python AlertEngine 的去重)。
    alerted: Mutex<std::collections::BTreeSet<String>>,
    /// 0.3.1(Wave D,BD-UI-49)退出确认窗是否开着(照 Python `closeEvent`:关窗先问一句)。
    exit_confirm_open: AtomicBool,
    /// 用户已在确认窗里点过「确认退出」—— 之后不再拦关闭(否则确认完也关不掉)。
    exit_confirmed: AtomicBool,
}

impl UiShared {
    /// 空状态(快照空、日志空、rev 0)。
    pub fn new() -> Self {
        Self::default()
    }

    // ---- v2 布局面访问器(tick 库 / 表单 / 选中 / tab) ----

    /// 落一条 tick(eTick. 裸键上屏;逐 symbol 覆盖)。
    pub fn update_tick(&self, t: &ek_data::tick::TickData) {
        self.tick_store.lock().unwrap().put(t);
        self.bump();
    }

    /// tick 只读视图(排序后)。
    pub fn tick_rows(&self) -> Vec<ek_data::tick::TickData> {
        self.tick_store.lock().unwrap().rows()
    }

    /// tick 按交易所分组(行情 tab 二级 tab 键表)。
    pub fn tick_groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
        self.tick_store.lock().unwrap().groups()
    }

    /// tick 库格数。
    pub fn tick_count(&self) -> usize {
        self.tick_store.lock().unwrap().len()
    }

    /// 下单表单只读快照。
    pub fn order_form(&self) -> OrderForm {
        self.order_form.lock().unwrap().clone()
    }

    /// 下单表单写(渲染侧每控件改动即写)。
    pub fn set_order_form(&self, f: OrderForm) {
        *self.order_form.lock().unwrap() = f;
        self.bump();
    }

    /// 选中交易所(行情带当前 tab;不在六所内的取值由本口夹回首所 —— 空/脏值
    /// 都落到 SHFE,调用方永远拿到一个可用的 tab 名;行情行单击只带入下单面板,
    /// tab 归 tab 条管)。
    pub fn selected_exchange(&self) -> String {
        let s = self.selected_exchange.lock().unwrap().clone();
        if MARKET_ORDER.iter().any(|e| e.as_str() == s) {
            s
        } else {
            MARKET_ORDER[0].as_str().to_string()
        }
    }

    /// 选中交易所写。
    pub fn set_selected_exchange(&self, s: impl Into<String>) {
        *self.selected_exchange.lock().unwrap() = s.into();
        self.bump();
    }

    /// 左上组当前 tab。
    pub fn topleft_tab(&self) -> TopLeftTab {
        *self.topleft_tab.lock().unwrap()
    }

    /// 左上组 tab 写。
    pub fn set_topleft_tab(&self, t: TopLeftTab) {
        *self.topleft_tab.lock().unwrap() = t;
        self.bump();
    }

    /// 右下组当前 tab。
    pub fn bottomright_tab(&self) -> BottomRightTab {
        *self.bottomright_tab.lock().unwrap()
    }

    /// 右下组 tab 写。
    pub fn set_bottomright_tab(&self, t: BottomRightTab) {
        *self.bottomright_tab.lock().unwrap() = t;
        self.bump();
    }

    /// 数据区分刀份额读(渲染期)。
    pub fn data_split(&self) -> DataSplit {
        *self.data_split.lock().unwrap()
    }

    /// 数据区分刀份额写(分隔条拖拽回调;写入前就地夹取到可用区,QSplitter
    /// minimumSize 语义 —— 调用方传多少都不出界)。
    pub fn set_data_split(&self, s: DataSplit) {
        let s = DataSplit {
            col: crate::splitters::clamp_frac(s.col),
            row: crate::splitters::clamp_frac(s.row),
        };
        let mut g = self.data_split.lock().unwrap();
        if g.col != s.col || g.row != s.row {
            *g = s;
            self.bump();
        }
    }

    /// 「恢复布局」:数据区分刀回 Python right_splitter 口径(两刀各半),
    /// 三个 tab 态回缺省。dock 时代的「整树替换」不再存在 —— 布局骨架本来就是
    /// Python 等价的原生面板,没有树可换。
    pub fn reset_layout(&self) {
        *self.data_split.lock().unwrap() = DataSplit::default();
        *self.selected_exchange.lock().unwrap() = String::new();
        *self.topleft_tab.lock().unwrap() = TopLeftTab::default();
        *self.bottomright_tab.lock().unwrap() = BottomRightTab::default();
        self.bump();
    }

    // ---- v4 弹窗面(egui::Window 开关;kittest 可断言) ----

    /// 运维窗开合。
    pub fn ops_window_open(&self) -> bool {
        self.ops_window.load(Ordering::Acquire)
    }

    /// 运维窗开合写。
    /// 某张表当前的排序键(`None` = 按数据源原序)。
    pub fn table_sort(&self, table: &str) -> Option<(usize, bool)> {
        self.table_sorts
            .lock()
            .unwrap()
            .get(table)
            .copied()
            .flatten()
    }

    /// 表头点击:同列再点 = 翻向;异列 = 新列升序(与 Python QTableView 单击表头同口径)。
    pub fn toggle_table_sort(&self, table: &str, col: usize) {
        let mut m = self.table_sorts.lock().unwrap();
        let asc = match m.get(table).copied().flatten() {
            Some((c, a)) if c == col => !a,
            _ => true,
        };
        m.insert(table.to_string(), Some((col, asc)));
    }

    pub fn set_ops_window_open(&self, v: bool) {
        self.ops_window.store(v, Ordering::Release);
        self.bump();
    }

    // ---- 多账号面(规划 §5.B / §5.C) ----

    /// 账户面板开合。
    pub fn accounts_window_open(&self) -> bool {
        self.accounts_window.load(Ordering::Acquire)
    }

    /// 账户面板开合写。
    pub fn set_accounts_window_open(&self, v: bool) {
        self.accounts_window.store(v, Ordering::Release);
        self.bump();
    }

    /// 账户配置弹窗开合(0.2.18 Commit B)。
    pub fn account_edit_open(&self) -> bool {
        self.account_edit_window.load(Ordering::Acquire)
    }

    /// 当前弹窗模式:`Some(派生名)` = 编辑;`None` = 添加。
    pub fn editing_account(&self) -> Option<String> {
        self.editing_account.lock().unwrap().clone()
    }

    /// 打开配置弹窗并载入草稿:`Some(派生名)` = 编辑模式(草稿 = 该账户留档);
    /// `None` = 添加模式(草稿 = `draft_from_existing()`,凭据位空)。
    /// 账户不在清册 = `false`(不开一个编辑不存在账户的窗)。
    pub fn begin_account_edit(&self, gateway: Option<&str>) -> bool {
        match gateway {
            Some(gw) => {
                let Some(row) = self.account(gw) else {
                    return false;
                };
                *self.editing_account.lock().unwrap() = Some(gw.to_string());
                *self.edit_alias.lock().unwrap() = row.alias.clone();
                self.set_ctp_form(row.form.clone());
            }
            None => {
                *self.editing_account.lock().unwrap() = None;
                *self.edit_alias.lock().unwrap() = String::new();
                let draft = self.draft_from_existing();
                self.set_ctp_form(draft);
            }
        }
        *self.edit_error.lock().unwrap() = None;
        self.account_edit_window.store(true, Ordering::Release);
        self.bump();
        true
    }

    /// 关闭配置弹窗 = **放弃未保存**(规划 Q24:绝不静默写入清册留档)。
    pub fn end_account_edit(&self) {
        *self.editing_account.lock().unwrap() = None;
        *self.edit_alias.lock().unwrap() = String::new();
        *self.edit_error.lock().unwrap() = None;
        self.account_edit_window.store(false, Ordering::Release);
        let draft = self.draft_from_existing();
        self.set_ctp_form(draft);
        self.bump();
    }

    /// 摘除一行账户时的连带口径:若这行正被配置弹窗**编辑**,立即关弹窗放弃。
    ///
    /// 不这么做的话:弹窗是非 modal 的,用户可以在弹窗开着时点同一行的『移除』——
    /// 编辑目标消失后弹窗会静默退化成「添加模式」(只读句消失、字段全部可编辑),
    /// 此时点『保存』会拿旧凭据建出一个**新账户**。这是执行核查抓到的坑。
    pub fn end_account_edit_if_editing(&self, gateway: &str) {
        if self.editing_account().as_deref() == Some(gateway) {
            self.end_account_edit();
        }
    }

    /// 弹窗别名草稿读。
    pub fn edit_alias(&self) -> String {
        self.edit_alias.lock().unwrap().clone()
    }

    /// 弹窗别名草稿写。
    pub fn set_edit_alias(&self, v: impl Into<String>) {
        *self.edit_alias.lock().unwrap() = v.into();
        self.bump();
    }

    /// 弹窗拦截原因读(`None` = 无)。
    pub fn edit_error(&self) -> Option<String> {
        self.edit_error.lock().unwrap().clone()
    }

    /// 弹窗拦截原因写(`None` = 清)。
    pub fn set_edit_error(&self, e: Option<String>) {
        *self.edit_error.lock().unwrap() = e;
        self.bump();
    }

    /// 弹窗草稿与该账户留档是否不一致(dirty;添加模式恒为"有内容待保存")。
    pub fn account_edit_dirty(&self) -> bool {
        let editing = self.editing_account();
        let draft = self.ctp_form();
        match editing {
            Some(gw) => self.account(&gw).map(|a| a.form != draft).unwrap_or(false),
            None => draft != CtpForm::default() && !draft.td_user.trim().is_empty(),
        }
    }

    /// 账户清单只读视图(注册序)。
    pub fn accounts(&self) -> Vec<AccountRow> {
        self.accounts.lock().unwrap().clone()
    }

    /// 单行账户查询。
    pub fn account(&self, gateway: &str) -> Option<AccountRow> {
        self.accounts
            .lock()
            .unwrap()
            .iter()
            .find(|a| a.gateway == gateway)
            .cloned()
    }

    /// 新增一行账户(**只写清册,不装配** —— 规划裁定「添加 ≠ 连接」)。
    ///
    /// `Err(原因)` 三种:派生名已存在(同 broker+账号重复添加)、别名重名(规划 Q3)、
    /// 达账户软上限 `MAX_ACCOUNTS`。宿主侧还有第二道 `Duplicate` 守卫;界面侧先拦是为了
    /// 不建出一个"注定被拒"的请求。
    pub fn add_account(&self, row: AccountRow) -> Result<(), String> {
        let mut acc = self.accounts.lock().unwrap();
        if acc.iter().any(|a| a.gateway == row.gateway) {
            return Err(format!("账户 {} 已存在(同 broker + 账号)", row.gateway));
        }
        let alias = row.alias.trim();
        if !alias.is_empty()
            && acc
                .iter()
                .any(|a| a.alias.trim().eq_ignore_ascii_case(alias))
        {
            return Err(format!("别名 {alias:?} 已被其他账户占用(别名须唯一)"));
        }
        if acc.len() >= MAX_ACCOUNTS {
            return Err(format!(
                "账户已达软上限 {MAX_ACCOUNTS} 个(先移除一个再添加)"
            ));
        }
        acc.push(row);
        drop(acc);
        self.bump();
        Ok(())
    }

    /// 是否还能添加账户。
    #[must_use]
    pub fn can_add_account(&self) -> bool {
        self.accounts.lock().unwrap().len() < MAX_ACCOUNTS
    }

    /// 改一行账户的别名(按 gateway 主键;不存在 = false)。
    ///
    /// 别名只用于显示与选择,**不参与寻址**(寻址恒用派生注册名,规划 Q3)。
    /// 别名须**唯一**(与 `add_account` 同一守卫):空别名 = 不设别名(合法);
    /// 与他人重名 = false(调用方须显形,不得静默改成重名别名)。
    pub fn set_account_alias(&self, gateway: &str, alias: &str) -> bool {
        let alias = alias.trim().to_string();
        let mut acc = self.accounts.lock().unwrap();
        if !alias.is_empty()
            && acc
                .iter()
                .any(|a| a.gateway != gateway && a.alias.eq_ignore_ascii_case(&alias))
        {
            return false;
        }
        match acc.iter_mut().find(|a| a.gateway == gateway) {
            Some(a) => {
                a.alias = alias;
                drop(acc);
                self.bump();
                true
            }
            None => false,
        }
    }

    /// `UiCmd::Connect` 已入列 → 四态置「装配中」并清掉上一次的失败原因。
    ///
    /// 登录态一并退回 `Unknown`(0.2.19):新一轮装配开始时,上一轮的「在线/掉线」
    /// 不再是这一轮的事实 —— 留着会让用户在重连过程中看到「在线」。
    pub fn note_connect_sent(&self, gateway: &str) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            a.stage = AccountStage::Assembling;
            a.fail_note = None;
            a.login = LoginState::Unknown;
            a.status_note = None;
            a.login_brief = None;
            // 链状态同理作废(0.5.0):上一轮会话的「查询合约中」不是这一轮的事实。
            a.chain_note = None;
            drop(acc);
            self.bump();
        }
    }

    /// 装配结果回写:`Ok(())` → 「实例已装配」;`Err(原因)` → 「装配失败」+ 原因留显形。
    ///
    /// 失败**不回滚清册行**(规划 U3 定盘:行留着,给『重试』;旧口径"必须先移除才能重试"作废)。
    pub fn note_connect_result(&self, gateway: &str, result: Result<(), String>) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            match result {
                Ok(()) => {
                    a.stage = AccountStage::Assembled;
                    a.fail_note = None;
                }
                Err(e) => {
                    a.stage = AccountStage::AssembleFailed;
                    a.fail_note = Some(e);
                }
            }
            drop(acc);
            self.bump();
        }
    }

    /// 记录网关实际收录的产品类(`ConfigureReply.product_classes` 回带,规划 U9)。
    ///
    /// 只存不改用户勾选:勾选是意图,这一栏是事实,两者并排显示。
    pub fn note_effective_product_classes(&self, gateway: &str, classes: &[String]) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            a.effective_classes = classes.to_vec();
            drop(acc);
            self.bump();
        }
    }

    /// 链状态行落账(0.5.0,用户定):`note` = `[链] ` 前缀之后的正文,账户表「阶段」列显示它。
    ///
    /// 只认 ek-ctp 推的状态行(调用点在 `plugin.rs` 的 eLog 分支按前缀筛),普通日志不动这一栏。
    /// 没有对应账户行 = 什么都不做(网关还没进清册,状态行先到是正常时序)。
    pub fn note_chain(&self, gateway: &str, note: &str) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            if a.chain_note.as_deref() == Some(note) {
                return; // 同一条状态重复推(例如重连时同一阶段再来一次)不 bump、不刷帧
            }
            a.chain_note = Some(note.to_string());
            drop(acc);
            self.bump();
        }
    }

    // ---- 0.2.19 状态面(`{gw}.status` 轮询结果落账;规划 P3 / 裁定 3) ----

    /// 当前生效的「查询断供」阈值:`(连续被拒次数, 资金回报超时秒)`。
    #[must_use]
    pub fn status_thresholds(&self) -> (u32, i64) {
        self.status_thresholds
            .lock()
            .unwrap()
            .unwrap_or((QUERY_REJECT_STREAK_LIMIT, ACCOUNT_STALE_SECS))
    }

    /// 覆盖阈值(测试夹具把两判据收紧到「1 次 / 0 秒」,才好在没有真柜台的用例里显形)。
    pub fn set_status_thresholds(&self, reject_limit: u32, stale_secs: i64) {
        *self.status_thresholds.lock().unwrap() = Some((reject_limit, stale_secs));
        self.bump();
    }

    /// 管制面快照的轮询节拍:eTimer 每拍调一次。
    ///
    /// 三个条件任一为真就问:① 被标记(插件上/下线、svctl 起停);② 第一拍(界面刚
    /// 起来不该空着等 4 秒);③ 每 `LIST_POLL_TICKS` 拍一次。
    pub fn list_poll_due(&self) -> bool {
        let n = self.list_tick.fetch_add(1, Ordering::Relaxed) + 1;
        self.list_due.swap(false, Ordering::AcqRel) || n == 1 || n.is_multiple_of(LIST_POLL_TICKS)
    }

    /// 置「下一拍就重问管制面」(插件上下线、svctl 起停回执之后)。
    pub fn request_list_refresh(&self) {
        self.list_due.store(true, Ordering::Release);
    }

    /// 置「重播种一次四张表」(网关或 oms 上线之后)。消费方 = 命令线程。
    pub fn request_trade_reseed(&self) {
        self.seed_due.store(true, Ordering::Release);
    }

    /// 取走「重播种」标记(消费性,不是一档状态)。
    pub fn take_trade_reseed(&self) -> bool {
        self.seed_due.swap(false, Ordering::AcqRel)
    }

    /// 这一拍该问哪几个账户的 `{gw}.status`(0.5 M3 摊平)。
    ///
    /// 入参 = 当前「已装配且在场」的网关名;返回不超过 `STATUS_ASKS_PER_TICK` 个,
    /// 从游标起环形取 —— 8 个账户 4 拍走完一轮,单拍 ask 上限恒定。
    pub fn status_batch(&self, present: &[String]) -> Vec<String> {
        // 先按名去重:账户清册允许两行共用同一个网关(第二行 `svctl.start` 回「已在场」
        // 而行仍在清册里),而 `{gw}.status` 是按网关寻址的同一个服务口 —— 不去重就是
        // 同一拍里白问两次,而且把环形节律打乱(两个格子问同一个网关,别的网关没人问)。
        let mut uniq: Vec<String> = Vec::with_capacity(present.len());
        for gw in present {
            if !uniq.iter().any(|u| u == gw) {
                uniq.push(gw.clone());
            }
        }
        let n = uniq.len();
        if n == 0 {
            return Vec::new();
        }
        let start = self.status_cursor.fetch_add(1, Ordering::Relaxed) % n;
        let take = STATUS_ASKS_PER_TICK.min(n);
        let mut out = Vec::with_capacity(take);
        for i in 0..take {
            out.push(uniq[(start + i) % n].clone());
        }
        out
    }

    /// 记「该给这个网关播种一次状态」(0.7.0 第3刀;按名去重:同一网关只排一次)。
    pub fn request_status_seed(&self, gateway: &str) {
        let mut q = self.status_seed.lock().unwrap();
        if !q.iter().any(|g| g == gateway) {
            q.push(gateway.to_string());
        }
    }

    /// 取走全部待播种网关(排空;消费方是 eTimer 那一拍的 `seed_status`)。
    pub fn take_status_seeds(&self) -> Vec<String> {
        let mut q = self.status_seed.lock().unwrap();
        std::mem::take(&mut *q)
    }

    /// 状态变化事件入账(0.7.0 第3刀;`eStatus.` 一次到货三件事)。
    ///
    /// 1. **文案逐字进日志环**:文本由生产者(ek-ctp `describe()`)生成,界面不拼句子;
    /// 2. **状态列回写**:整份快照直接喂 `note_gateway_status`(在线/掉线/断供判据
    ///    吃的是同一份事实,不再回查);
    /// 3. **去重告警**:失败类六款(断开/认证失败/登录失败/查询停止/查询被拒/订阅被拒)
    ///    各弹一次;去重键带**会话标记**(`td_disconnects` / `md_disconnects`),
    ///    重连后的同一款算新事件(旧会话的账不替新会话挡告警)。成功类不弹(降噪:
    ///    文案与状态列已经说话)。
    pub fn note_status_event(&self, ev: &ek_ctp::GatewayStatusEvent) {
        let gw = ev.gateway.as_str();
        let text = ev.describe();
        self.log_line(format!("[INFO][{gw}] {text}"));
        let note = text
            .strip_prefix(CHAIN_STATE_PREFIX)
            .map(|s| s.trim().to_string())
            .unwrap_or_else(|| text.clone());
        self.note_chain(gw, &note);
        self.note_gateway_status(gw, &ev.status);
        let alarm_kind = matches!(
            ev.change,
            ek_ctp::GatewayChange::TdDisconnected
                | ek_ctp::GatewayChange::TdAuthFailed
                | ek_ctp::GatewayChange::TdLoginFailed
                | ek_ctp::GatewayChange::ContractQueryStopped
                | ek_ctp::GatewayChange::QueryRejected
                | ek_ctp::GatewayChange::MdDisconnected
                | ek_ctp::GatewayChange::MdLoginFailed
                | ek_ctp::GatewayChange::SubscribeRejected
        );
        if alarm_kind {
            // 会话标记:TD 侧款看 td 断开数,MD 侧款看 md 断开数(重连 → 数变 → 新事件)
            let session = if ev.change.kind().starts_with("md-") {
                ev.status.md_disconnects
            } else {
                ev.status.td_disconnects
            };
            let key = format!("estatus:{gw}:{}:{session}", ev.change.kind());
            if self.alert_once(key) {
                self.push_alert(
                    crate::toast::AlertLevel::Warning,
                    format!("账户 {gw}:{}", note.trim()),
                );
            }
        }
    }

    /// 置「该问一次 oms 装配」(0.5 M3:事件触发而不是节拍)。
    pub fn request_pipeline_refresh(&self) {
        self.pipeline_due.store(true, Ordering::Release);
    }
    /// 消费「该问一次 oms 装配」标记(取一次即清)。
    pub fn take_pipeline_due(&self) -> bool {
        self.pipeline_due.swap(false, Ordering::AcqRel)
    }

    /// 置「该问一次段落目录」(0.6.0 环节8;运维窗钮)。
    pub fn request_cfg_refresh(&self) {
        self.cfg_due.store(true, Ordering::Release);
    }

    /// 消费「该问一次段落目录」标记(取一次即清)。
    pub fn take_cfg_due(&self) -> bool {
        self.cfg_due.swap(false, Ordering::AcqRel)
    }

    /// 当前界面档(0.8.0 配色双档;缺省 = 深色)。
    pub fn theme(&self) -> crate::tokens::Theme {
        crate::tokens::Theme::from_u8(self.theme.load(Ordering::Acquire))
    }

    /// 装档(0.8.0)。**不置落盘标记** —— 启动读盘、夹具显式设档走这一支,
    /// 用户手点才走 `toggle_theme`(只有手点才落盘,照参考站口径)。
    pub fn set_theme(&self, theme: crate::tokens::Theme) {
        self.theme.store(theme.as_u8(), Ordering::Release);
        self.bump();
    }

    /// 用户点切换钮(0.8.0):切到另一档 + 立落盘标记,返回新档。
    pub fn toggle_theme(&self) -> crate::tokens::Theme {
        let next = self.theme().flipped();
        self.theme.store(next.as_u8(), Ordering::Release);
        self.theme_persist_due.store(true, Ordering::Release);
        self.bump();
        next
    }

    /// 消费「该把当前档落盘」标记(命令线程用;渲染线程零文件 IO)。
    pub fn take_theme_persist_due(&self) -> bool {
        self.theme_persist_due.swap(false, Ordering::AcqRel)
    }

    /// 段落目录回写(0.6.0)。
    pub fn set_cfg_briefs(&self, b: Vec<ek_cfg::wire::NsBrief>) {
        *self.cfg_briefs.lock().unwrap() = Some(b);
        self.bump();
    }
    /// 段落目录只读视图(0.6.0;`None` = 从未问过)。
    pub fn cfg_briefs(&self) -> Option<Vec<ek_cfg::wire::NsBrief>> {
        self.cfg_briefs.lock().unwrap().clone()
    }

    /// 清段二次确认臂(0.6.0;`Some(ns)` = 已点过一次,下一帧再点同一行才真发)。
    pub fn cfg_delete_armed(&self) -> Option<String> {
        self.cfg_delete_arm.lock().unwrap().clone()
    }
    pub fn arm_cfg_delete(&self, ns: Option<String>) {
        *self.cfg_delete_arm.lock().unwrap() = ns;
        self.bump();
    }

    /// `oms.pipeline_status` 读数回写(装配事实,不是业务事实)。
    pub fn set_pipeline_lines(&self, lines: Vec<String>) {
        *self.pipeline_lines.lock().unwrap() = lines;
        self.bump();
    }

    /// 最新一次的 oms 装配显形读数(空 = 运维窗不画这一节)。
    pub fn pipeline_lines(&self) -> Vec<String> {
        self.pipeline_lines.lock().unwrap().clone()
    }

    /// 某条推送线的载荷类型不对(0.5 M1)。
    ///
    /// 链上这几条线的载荷类型不是编译期保证:`ePosition.` 的载荷「由广发方确定」
    /// (ek-oms plugin.rs:329 同样只能 downcast 试)。收不进去就计数,并在**第一次**
    /// 显形一条日志 —— 静默丢等于「界面少了一路数据而没人知道」。
    pub fn note_bad_payload(&self, line: &str) {
        let mut book = self.bad_payload.lock().unwrap();
        let n = book.entry(line.to_string()).or_insert(0);
        *n += 1;
        if *n == 1 {
            self.log_line(format!(
                "[ui] {line} 载荷类型异常(第一次):这一路的行进不了账本"
            ));
        }
    }

    /// 某条推送线累计收不进去多少条(测试判据)。
    #[must_use]
    pub fn bad_payload_count(&self, line: &str) -> u64 {
        self.bad_payload
            .lock()
            .unwrap()
            .get(line)
            .copied()
            .unwrap_or(0)
    }

    /// 插件邮箱**水位与触顶**的显形(0.4.5 P7d 首发;0.7.0 背压刀改口径)。
    ///
    /// 新口径(随 ek-core 0.5.0):邮箱**无界默认不丢** → `spilled` 只在触到
    /// 该插件自己的**软顶**时才涨,所以「spilled > 0」不再意味着「丢过几条行情」,
    /// 而是**已经触顶**——一件红色大事。默认不丢之后,现场真正要看的是
    /// **积压多深**(消费侧滞后 = 刷新慢),所以这里从 0.5 的"溢出告警"扩成
    /// 三段读数,全部走快照逐行(数据源 = ek-station 0.4.0 的 `pending/peak/soft_cap`):
    /// - 积压越黄阈(软顶 1/16,下限 4096)→ Warning;
    /// - 积压越红阈(软顶 1/4,下限 32768)→ Error:再涨就要丢新帧了;
    /// - `spilled > 0`(已触顶)→ Error:此刻起每多一条就丢一条**新到**的事件。
    ///
    /// 去重口径照旧(告警是状态事实,不是渲染帧的副作用):每个插件每一级只弹一次,
    /// 持续状态由运维窗那一列**常显**(告警负责"发生",窗口负责"一直在")。
    pub fn note_plugin_spill(&self, rows: &[ek_station::wire::PlugRow]) {
        for r in rows {
            let (warn, crit) = pending_thresholds(r.soft_cap);
            if r.pending >= warn && self.alert_once(format!("pend-warn:{}", r.name)) {
                self.push_alert(
                    crate::toast::AlertLevel::Warning,
                    format!(
                        "插件 {}:邮箱积压 {} 帧(峰值 {};该插件软顶 {})——它的 worker 消化不过来,刷新会变慢",
                        r.name, r.pending, r.peak, r.soft_cap
                    ),
                );
            }
            if r.pending >= crit && self.alert_once(format!("pend-crit:{}", r.name)) {
                self.push_alert(
                    crate::toast::AlertLevel::Error,
                    format!(
                        "插件 {}:邮箱积压 {} 帧,越过软顶 {} 的四分之一——再涨就开始丢新到的事件",
                        r.name, r.pending, r.soft_cap
                    ),
                );
            }
            if r.spilled > 0 && self.alert_once(format!("spill:{}", r.name)) {
                self.push_alert(
                    crate::toast::AlertLevel::Error,
                    format!(
                        "插件 {}:已触软顶 {} 并丢弃新到事件累计 {} 条(峰值 {})——它的消化速度跟不上生产速度",
                        r.name, r.soft_cap, r.spilled, r.peak
                    ),
                );
            }
        }
    }

    /// `{gw}.status` 读数回写 → 该行登录态与细节重算。
    ///
    /// 0.3.2(Wave E,BD-UI-50)这里同时是**告警的唯一产生口**:轮询一拍问出告警态就灌 toast。
    /// 为什么住这儿而不是账户面板的渲染里(Python `AlertEngine` 的对照口径):
    /// 告警是「状态口问出来的事实」,不是「某张窗画出来的东西」——账户窗没开也必须知道掉线了。
    /// 去重键 = 网关 + 登录态:同一状态只弹一次,状态变了才算新告警(否则每轮轮询一条,
    /// 8 秒自动关与同屏上限 3 都没有意义)。
    pub fn note_gateway_status(&self, gateway: &str, st: &ek_ctp::plugin::GatewayStatus) {
        let (limit, stale) = self.status_thresholds();
        let now = ek_ctp::map::now_epoch_ms();
        // 先在账户锁里把「这一拍是不是告警态」读出来,出锁再发 toast
        // (accounts / alerted / pending_alerts 是三颗独立的锁,不嵌套持有)。
        let mut alarm: Option<(LoginState, String)> = None;
        let mut found = false;
        {
            let mut acc = self.accounts.lock().unwrap();
            if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
                found = true;
                a.apply_status(st, now, limit, stale);
                if a.login.is_alarm() || a.login == LoginState::QueryStarved {
                    alarm = Some((
                        a.login,
                        a.status_note
                            .clone()
                            .unwrap_or_else(|| a.login.label().to_string()),
                    ));
                }
            }
        }
        if found {
            self.bump();
        }
        if let Some((login, note)) = alarm
            && self.alert_once(format!("{gateway}|{}", login.label()))
        {
            self.push_alert(
                crate::toast::AlertLevel::Error,
                format!("账户 {gateway}:{note}"),
            );
        }
    }

    /// 状态口问不到(网关不在场 / ask 超时):登录态退回 `Unknown` 并写明原因。
    ///
    /// **不得保留上一轮的「在线」**:问不到就是没有证据,留着旧读数等于替网关谎称在线。
    pub fn note_status_unreachable(&self, gateway: &str, why: &str) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            a.login = LoginState::Unknown;
            a.status_note = Some(format!("状态口问不到:{why}"));
            a.login_brief = None;
            drop(acc);
            self.bump();
        }
    }

    /// 『断开』已发令 → 该行立刻显形「已断开」(不等下一轮轮询;用户按了就该马上看到)。
    pub fn mark_account_stopped(&self, gateway: &str) {
        let mut acc = self.accounts.lock().unwrap();
        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
            a.login = LoginState::Stopped;
            a.status_note = Some("会话已停;『连接』可恢复".to_string());
            a.login_brief = None;
            drop(acc);
            self.bump();
        }
    }

    /// 装配前把表单落到该账户留档,并按**全局单源纪律**裁决 md 位:
    /// 非行情源账户 → `md_addr` 就地清空(**留档与下发同一份**,消 U4 的"只清下发不清留档")。
    pub fn prepare_connect_form(&self, gateway: &str, form: CtpForm) -> CtpForm {
        let source = self.md_source();
        let mut f = form;
        if source.as_str() != gateway {
            f.md_addr.clear();
            f.md_user.clear();
            f.md_password.clear();
        }
        self.update_account_form(gateway, f.clone());
        f
    }

    /// 『添加账户』弹窗的草稿位:沿用当前在场账户的**非凭据字段**
    /// (broker / AppID / AuthCode / 收录勾选),凭据位(账号、密码、前置)一律清空
    /// (规划 Q20 的零风险缓解:少敲几遍,但绝不把上一家的密码带进下一家)。
    #[must_use]
    pub fn draft_from_existing(&self) -> CtpForm {
        let acc = self.accounts.lock().unwrap();
        let mut draft = CtpForm::default();
        if let Some(a) = acc.last() {
            draft.broker_id = a.form.broker_id.clone();
            draft.td_auth_code = a.form.td_auth_code.clone();
            draft.td_app_id = a.form.td_app_id.clone();
            draft.accept_futures = a.form.accept_futures;
            draft.accept_options = a.form.accept_options;
        }
        draft
    }

    /// 更新一行账户的表单留档(按 gateway 主键;不存在 = false)。
    pub fn update_account_form(&self, gateway: &str, form: CtpForm) -> bool {
        let mut acc = self.accounts.lock().unwrap();
        match acc.iter_mut().find(|a| a.gateway == gateway) {
            Some(a) => {
                a.form = form;
                drop(acc);
                self.bump();
                true
            }
            None => false,
        }
    }

    /// 摘除一行账户(界面侧清册;网关实例由 `UiCmd::Remove` 走 svctl 摘)。
    pub fn remove_account(&self, gateway: &str) -> bool {
        let mut acc = self.accounts.lock().unwrap();
        let before = acc.len();
        acc.retain(|a| a.gateway != gateway);
        let removed = acc.len() != before;
        if removed {
            // 元账号被摘 → 源位清空(等用户重新指定);订阅集保留(规划 §5.C)。
            let mut md = self.md_source.lock().unwrap();
            if md.as_str() == gateway {
                md.clear();
            }
            drop(acc);
            self.bump();
        }
        removed
    }

    /// 用 svctl 快照回填账户在场位与四态;回带**本轮新装配**的网关名(订阅重放用)。
    ///
    /// 四态口径:svctl 带回这个名字 = 实例已装配(清掉失败原因);
    /// 名字不在场且原先记着「已装配」= 回到「未装配」(被摘/宿主崩了);
    /// 「装配中」与「装配失败」是界面侧记的,**不被在场表覆盖**。
    ///
    /// 0.2.19 附带一条:实例不在场时**登录态读数一并作废** —— 会话读数属于那个实例,
    /// 实例没了还留着「在线/掉线 2 次」,就是在替一个不存在的会话说话。
    pub fn sync_accounts_connected(&self, rows: &[PlugRow]) -> Vec<String> {
        let mut newly = Vec::new();
        {
            let mut acc = self.accounts.lock().unwrap();
            for a in acc.iter_mut() {
                let up = rows.iter().any(|r| r.name == a.gateway);
                if up && !a.connected {
                    newly.push(a.gateway.clone());
                }
                a.connected = up;
                if up {
                    a.stage = AccountStage::Assembled;
                    a.fail_note = None;
                } else {
                    // 实例不在场 = 这一条会话没了:链状态同理作废(0.5.0「阶段」栏)。
                    // 留着就是替一个不存在的会话说话(与上面「登录态读数一并作废」同一条理由)。
                    a.chain_note = None;
                    if a.stage == AccountStage::Assembled {
                        a.stage = AccountStage::NotAssembled;
                        a.login = LoginState::Unknown;
                        a.status_note = None;
                        a.login_brief = None;
                    }
                }
            }
        }
        if !newly.is_empty() {
            self.bump();
        }
        newly
    }

    /// 行情元账号注册名(空 = 未选)。
    pub fn md_source(&self) -> String {
        self.md_source.lock().unwrap().clone()
    }

    /// 是否已有账户装配 —— 元账号锁定判定(规划 D3:连接后不允许切换)。
    pub fn accounts_locked(&self) -> bool {
        self.accounts.lock().unwrap().iter().any(|a| a.connected)
    }

    /// 指定行情元账号(仅配置阶段;任何账户已连接 = 拒,界面须提示"先断开")。
    pub fn set_md_source(&self, gateway: &str) -> Result<(), String> {
        {
            let acc = self.accounts.lock().unwrap();
            if acc.iter().any(|a| a.connected) {
                return Err("元账号已锁定:有账户在线,切换行情源需先断开该账户".to_string());
            }
            if !acc.iter().any(|a| a.gateway == gateway) {
                return Err(format!("账户 {gateway} 不在清册"));
            }
        }
        let mut acc = self.accounts.lock().unwrap();
        for a in acc.iter_mut() {
            a.md_source = a.gateway == gateway;
        }
        drop(acc);
        let mut md = self.md_source.lock().unwrap();
        *md = gateway.to_string();
        self.bump();
        Ok(())
    }

    /// 订阅集只读视图。
    pub fn subscriptions(&self) -> Vec<String> {
        self.subscriptions.lock().unwrap().clone()
    }

    /// 登记订阅(去重保序;断开重连后按它重放)。
    pub fn note_subscriptions(&self, symbols: &[String]) {
        {
            let mut subs = self.subscriptions.lock().unwrap();
            for s in symbols {
                let s = s.trim();
                if !s.is_empty() && !subs.iter().any(|x| x == s) {
                    subs.push(s.to_string());
                }
            }
        }
        self.bump();
    }

    /// 下单默认账户阶梯(替代旧"空 → 字面量 ctp"兜底,规划 §5.B;
    /// **0.2.18 四态后修正,规划 Q4**):
    /// 上次手动选择(须仍在清册且已装配)→ 第一个已装配账户;
    /// **未装配账户不得进默认** —— 旧阶梯的"退到第一个账户"会把只添加了还没连接的
    /// 账户选成下单归属,发出去只会撞 `unknown:gateway`。
    /// 一个都没有 = `None`(界面据此禁用下单钮)。
    pub fn default_gateway(&self) -> Option<String> {
        let chosen = {
            let f = self.order_form.lock().unwrap();
            f.gateway.trim().to_string()
        };
        if !chosen.is_empty() && self.account(&chosen).is_some_and(|a| a.connected) {
            return Some(chosen);
        }
        let acc = self.accounts.lock().unwrap();
        acc.iter().find(|a| a.connected).map(|a| a.gateway.clone())
    }

    /// 已开 K线窗合约表(多开,去重)。
    pub fn kline_symbols(&self) -> Vec<String> {
        self.kline_windows.lock().unwrap().clone()
    }

    /// 开 K线窗(去重;日志留痕可断言)。
    pub fn open_kline(&self, symbol: impl Into<String>) {
        let s = symbol.into();
        let mut v = self.kline_windows.lock().unwrap();
        if !v.contains(&s) {
            v.push(s.clone());
        }
        drop(v);
        self.log_line(format!("[kline] 打开 K线窗 {s}"));
    }

    /// 关 K线窗(窗关闭钮 → 出表)。
    pub fn close_kline(&self, symbol: &str) {
        self.kline_windows.lock().unwrap().retain(|s| s != symbol);
        self.bump();
    }

    // ---- v4 查询合约窗面(合约库 / 查询串 / 勾选集) ----

    /// 查询合约窗开合。
    pub fn contracts_window_open(&self) -> bool {
        self.contracts_window.load(Ordering::Acquire)
    }

    /// 查询合约窗开合写。
    pub fn set_contracts_window_open(&self, v: bool) {
        self.contracts_window.store(v, Ordering::Release);
        self.bump();
    }

    /// 落一条合约(`eContract.` 裸键上屏),并**回补已有行的名称**。
    ///
    /// 时序决定了这一步必须有:登录即上账的委托/成交回报**先于合约到手**(柜台在
    /// 登录后立刻回放当日回报,而合约查询要走完查询链),网关发它们时合约表还空着,
    /// 名字补不上(0.11.0 第5刀现场实测:委托 2/27、成交 1/14)。合约到货之后
    /// 把同名行补齐 —— 只补空名字,**已有名字不覆盖**;补名不改行序、不新增行。
    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
        self.contract_store.lock().unwrap().put(c);
        if !c.name.is_empty() {
            self.backfill_names(&c.symbol, &c.name);
        }
        self.bump();
    }

    /// 名称回补(0.11.0 第5刀 R6):按代码补委托 / 成交 / 持仓三本账里的空名行。
    ///
    /// 名称是**交易所层面的合约属性**(与网关无关),所以跨网关按代码补;
    /// 补的是显示事实,不参与任何判据(在线/断供/归属守卫都不看这一栏)。
    fn backfill_names(&self, symbol: &str, name: &str) {
        let mut touched = false;
        {
            let mut rows = self.trade_orders.lock().unwrap();
            for (_, (_, o)) in rows.iter_mut() {
                if o.symbol == symbol && o.name.is_empty() {
                    o.name = name.to_string();
                    touched = true;
                }
            }
        }
        {
            let mut rows = self.trade_trades.lock().unwrap();
            for (_, t) in rows.iter_mut() {
                if t.symbol == symbol && t.name.is_empty() {
                    t.name = name.to_string();
                    touched = true;
                }
            }
        }
        {
            let mut rows = self.trade_positions.lock().unwrap();
            for (_, (_, p)) in rows.iter_mut() {
                if p.symbol == symbol && p.name.is_empty() {
                    p.name = name.to_string();
                    touched = true;
                }
            }
        }
        if touched {
            // 快照缓存要跟着作废(否则屏幕上还是旧名字)
            self.trade_dirty.store(true, Ordering::Release);
        }
    }

    /// 合约查询(Python ContractManager 口径:留空 = 全部)。
    pub fn contracts(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
        self.contract_store.lock().unwrap().query(pattern)
    }

    /// 合约库格数。
    pub fn contract_count(&self) -> usize {
        self.contract_store.lock().unwrap().len()
    }

    /// 按代码查合约名(0.11.0 第5刀 R6 的补名口;查不到 = `None`,不编名字)。
    fn contract_name_of(&self, symbol: &str) -> Option<String> {
        self.contract_store
            .lock()
            .unwrap()
            .query(symbol)
            .into_iter()
            .find(|c| c.symbol == symbol && !c.name.is_empty())
            .map(|c| c.name)
    }

    /// 查询串读写。
    pub fn contract_query(&self) -> String {
        self.contract_query.lock().unwrap().clone()
    }

    /// 查询串写。
    pub fn set_contract_query(&self, q: impl Into<String>) {
        *self.contract_query.lock().unwrap() = q.into();
        self.bump();
    }

    /// 勾选翻转(en_symbol)。
    pub fn toggle_contract_selection(&self, en_symbol: &str) {
        let mut v = self.contract_selected.lock().unwrap();
        match v.iter().position(|s| s == en_symbol) {
            Some(i) => {
                v.remove(i);
            }
            None => v.push(en_symbol.to_string()),
        }
        self.bump();
    }

    /// 当前勾选集。
    pub fn selected_contracts(&self) -> Vec<String> {
        self.contract_selected.lock().unwrap().clone()
    }

    /// 撤单拒因翻译(0.11.0 第6刀 R7):机器码 → 人话,**原文照留**。
    ///
    /// oms 的拒因是结构化机器码(判据/日志吃它,不能改),屏幕上要给一句人话。
    /// 这一条住在状态层(不是渲染层),与告警同一口径:事实的产生口说什么,
    /// 屏幕上就说什么,渲染不参与翻译。
    #[must_use]
    pub fn cancel_reject_note(msg: &str) -> String {
        let head = msg.split(' ').next().unwrap_or(msg);
        let note = if head.starts_with("unknown:order") {
            "这台网关的账本里没有这笔委托(登录前的旧单,或不属于这个账户)"
        } else if head.starts_with("mismatch:gateway") {
            "该委托不属于这个账户(归属守卫挡住,不会撤到别人头上)"
        } else if head.starts_with("terminal:status") {
            "这笔委托已经到终态(已成交/已撤/已拒),撤不了了"
        } else if head.starts_with("limit:cancel-guard") {
            "撤单尝试次数已到上限(每单有上限,防连点打爆柜台)"
        } else {
            ""
        };
        if note.is_empty() {
            // 认不出的机器码:照原文,不编故事
            format!("撤单被拒:{msg}")
        } else {
            format!("撤单被拒:{note}(机器码:{msg})")
        }
    }

    /// 追加一行日志(总线面/宿主观察线程写)。
    pub fn log_line(&self, line: impl Into<String>) {
        self.logs.lock().unwrap().push(line);
        self.bump();
    }

    /// 清空日志(渲染"清空日志"按钮 / 测试)。
    pub fn clear_logs(&self) {
        self.logs.lock().unwrap().clear();
        self.bump();
    }

    /// 日志只读快照 + 溢出计数。
    pub fn logs_snapshot(&self) -> (Vec<String>, u64) {
        let r = self.logs.lock().unwrap();
        (r.snapshot(), r.spilled())
    }

    /// 带时间戳的日志快照(BD-UI-41 渲染侧:日志表格的「时间」列)。
    pub fn logs_entries(&self) -> (Vec<LogEntry>, u64) {
        let r = self.logs.lock().unwrap();
        (r.entries(), r.spilled())
    }

    /// 日志当前长度(kittest 断言用)。
    pub fn logs_len(&self) -> usize {
        self.logs.lock().unwrap().len()
    }

    /// 落一次服务快照(eTimer 节流回调)。
    pub fn set_snapshot(&self, snap: BusSnap) {
        *self.snap.lock().unwrap() = Arc::new(snap);
        self.bump();
    }

    /// 当前快照(Arc 克隆,零拷贝走锁)。
    pub fn snapshot(&self) -> Arc<BusSnap> {
        Arc::clone(&self.snap.lock().unwrap())
    }

    /// revision:任何写入 +1(插件面 ePlug 事件也用;测试判定"有事件进来过")。
    pub fn rev(&self) -> u64 {
        self.rev.load(Ordering::Acquire)
    }

    /// 手动推一次 revision(总线面 ePlugUp/Down 记账用)。
    pub fn bump(&self) {
        self.rev.fetch_add(1, Ordering::AcqRel);
    }

    /// 置关窗信号(eframe App 关窗钩子调用)。
    pub fn request_stop(&self) {
        self.stop.store(true, Ordering::Release);
    }

    /// 观察线程轮询用。
    /// 0.3.2(Wave E,BD-UI-50)告警入口:界面层调它,渲染帧灌进 toast 队列。
    pub fn push_alert(&self, level: crate::toast::AlertLevel, text: impl Into<String>) {
        self.pending_alerts
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .push((level, text.into()));
    }

    /// 取走待灌的告警(render 每帧一次)。
    pub(crate) fn take_alerts(&self) -> Vec<(crate::toast::AlertLevel, String)> {
        let mut q = self
            .pending_alerts
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        std::mem::take(&mut *q)
    }

    /// 常驻 toast 队列。
    pub(crate) fn toasts(&self) -> std::sync::MutexGuard<'_, Option<egui_notify::Toasts>> {
        self.toasts.lock()
    }

    /// 去重:这条告警第一次出现返回 true(同一键不再重复弹)。
    pub fn alert_once(&self, key: impl Into<String>) -> bool {
        let key = key.into();
        self.alerted
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .insert(key)
    }

    /// 待灌进 toast 的告警条数(状态层判据用;灌进队列由渲染帧收口)。
    #[must_use]
    pub fn pending_alert_count(&self) -> usize {
        self.pending_alerts
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .len()
    }

    /// 已弹过的告警键数(去重账本;判据用它核「同一状态不重复弹」)。
    #[must_use]
    pub fn alerted_count(&self) -> usize {
        self.alerted
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .len()
    }

    /// 退出确认窗:开 / 关 / 是否已确认放行(0.3.1 BD-UI-49)。
    pub fn exit_confirm_open(&self) -> bool {
        self.exit_confirm_open
            .load(std::sync::atomic::Ordering::Relaxed)
    }

    pub fn open_exit_confirm(&self) {
        self.exit_confirm_open
            .store(true, std::sync::atomic::Ordering::Relaxed);
    }

    pub fn close_exit_confirm(&self) {
        self.exit_confirm_open
            .store(false, std::sync::atomic::Ordering::Relaxed);
    }

    pub fn exit_confirmed(&self) -> bool {
        self.exit_confirmed
            .load(std::sync::atomic::Ordering::Relaxed)
    }

    pub fn confirm_exit(&self) {
        self.exit_confirmed
            .store(true, std::sync::atomic::Ordering::Relaxed);
    }

    pub fn is_stop(&self) -> bool {
        self.stop.load(Ordering::Acquire)
    }

    // ---- svctl 操作面(S2) ----

    /// 入一条操作指令(窗口面离场动作唯一通道;左位不直调)。
    pub fn push_cmd(&self, cmd: UiCmd) {
        self.cmds.lock().unwrap().push_back(cmd);
        self.bump();
    }

    /// 取走全部指令(0.5 M5:命令线程辞行)。
    pub fn drain_cmds(&self) -> Vec<UiCmd> {
        self.cmds.lock().unwrap().drain(..).collect()
    }

    /// 队列里是否有待执行指令(命令线程的「有没有活」判据,不消费)。
    #[must_use]
    pub fn cmd_pending(&self) -> bool {
        !self.cmds.lock().unwrap().is_empty()
    }

    /// 当前待二次确认目标(Some = 二次确认中)。
    pub fn pending_confirm(&self) -> Option<String> {
        self.pending_confirm.lock().unwrap().clone()
    }

    /// 确认步进:同名二点 = 发令(Fired),异名 = 迁移(Switched),首点 = 进入待确认。
    pub fn confirm_step(&self, name: &str) -> ConfirmStep {
        let mut g = self.pending_confirm.lock().unwrap();
        match g.take() {
            Some(p) if p == name => ConfirmStep::Fired,
            Some(_) => {
                *g = Some(name.to_string());
                ConfirmStep::Switched
            }
            None => {
                *g = Some(name.to_string());
                ConfirmStep::Armed
            }
        }
    }

    /// 清除待确认(取消钮)。
    pub fn confirm_cancel(&self) {
        *self.pending_confirm.lock().unwrap() = None;
        self.bump();
    }

    // ---- S4 行情面 ----

    /// 行情库直读(读写两用:worker 写,渲染读)。
    pub fn market(&self) -> &MarketStore {
        &self.market
    }

    // ---- S5 交易面 ----

    /// 全量播种四本账(**测试夹具的入口**;0.5 起生产侧不再走这一条 —— 启动一次四问
    /// 走 `note_*` 合并式播种,见 `plugin.rs::reconcile_trade`)。
    ///
    /// 与 `note_*` 的分工:这一条是「把已有的一份账交给界面」(先清后灌),`note_*` 是
    /// 「一批新事件进来」。行序按入参**反序**赋 seq,正是为了让重建后的 `trade_snap()`
    /// 与喂进来的顺序一致 —— 播种不是「一批新事件」,只有新事件才走「在前」。
    pub fn seed_trade_snap(&self, ts: TradeSnap) {
        // 0.11.0 第5刀 R6:播种面同样补名(oms 账本里那些"发的时候合约表还空着"的
        // 行,进界面这一刻合约早就在手了)
        let named = |symbol: &str, name: String| -> String {
            if name.is_empty() {
                self.contract_name_of(symbol).unwrap_or(name)
            } else {
                name
            }
        };
        {
            let mut book = self.trade_orders.lock().unwrap();
            book.clear();
            for mut o in ts.orders.into_iter().rev() {
                o.name = named(&o.symbol, o.name);
                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                book.insert(o.en_orderid(), (seq, o));
            }
        }
        {
            let mut book = self.trade_trades.lock().unwrap();
            book.clear();
            let mut ids = self.trade_ids.lock().unwrap();
            ids.clear();
            for mut t in ts.trades.into_iter().rev() {
                t.name = named(&t.symbol, t.name);
                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                ids.insert(t.en_tradeid());
                book.push_front((seq, t));
            }
        }
        {
            let mut book = self.trade_positions.lock().unwrap();
            book.clear();
            for mut p in ts.positions.into_iter().rev() {
                p.name = named(&p.symbol, p.name);
                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                book.insert(p.en_positionid(), (seq, p));
            }
        }
        {
            let mut book = self.trade_funds.lock().unwrap();
            book.clear();
            for a in ts.accounts.into_iter().rev() {
                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                book.insert(a.en_accountid(), (seq, a));
            }
        }
        self.trade_dirty.store(true, Ordering::Release);
        self.bump();
    }

    /// 一笔委托入账(基线 OrderMonitor:有 `en_orderid` 就更新可变列,否则新行)。
    ///
    /// 可变列口径照基线 `widget.py:1341-1357` 的 `update` 标记:随回报走的是
    /// `name / traded / traded_price / status / status_msg / datetime`;`symbol /
    /// exchange / type / direction / offset / price / volume / orderid / reference /
    /// gateway_name` 插入后不再被后续回报改写 —— CTP 的后续回报里这些字段可能给空,
    /// 整行覆盖会把「总量」写成 0(`BaseCell.set_content` 的 `if self.is_update` 挡的就是这个)。
    pub fn note_order(&self, o: &ek_data::order::OrderData) {
        let key = o.en_orderid();
        {
            let mut book = self.trade_orders.lock().unwrap();
            match book.get_mut(&key) {
                Some((_, cur)) => {
                    cur.name.clone_from(&o.name);
                    cur.traded = o.traded;
                    cur.traded_price = o.traded_price;
                    cur.status = o.status;
                    cur.status_msg.clone_from(&o.status_msg);
                    cur.datetime = o.datetime;
                }
                None => {
                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                    book.insert(key, (seq, o.clone()));
                }
            }
        }
        self.trade_dirty.store(true, Ordering::Release);
        self.bump();
    }

    /// 一笔成交入账(0.5 裁定:按 `en_tradeid` 去重,新成交在前)。
    ///
    /// 基线的两层口径在这里合成一条:表格层 `TradeMonitor.data_key = ""`(:1267,只插不改、
    /// 不按键更新),引擎层 `trades_filter_dict`(`engine.py:1072-1080`,同一笔成交只算一次,
    /// 注释写明防的是账户重连时柜台重推)。我们的成交表就是用户看到的那本账,而它现在有两个
    /// 事件源(网关推送 + 启动 / 上线播种),所以引擎层那一条必须落到这里,否则同一笔会出两行
    /// —— 实连演示里量出来了:摘账户再重加之后,元账号 3 行成交变 4 行、纯 TD 账号 3 行变 6 行。
    pub fn note_trade(&self, t: &ek_data::trade::TradeData) {
        let id = t.en_tradeid();
        if self.trade_ids.lock().unwrap().contains(&id) {
            return;
        }
        let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
        let dropped = {
            let mut book = self.trade_trades.lock().unwrap();
            book.push_front((seq, t.clone()));
            self.trade_ids.lock().unwrap().insert(id);
            let over = book.len().saturating_sub(TRADE_CAP);
            for _ in 0..over {
                // 挤掉最旧一行同时把它出集:那笔之后再推,还能上屏(去重集不许比账本大)。
                if let Some((_, old)) = book.pop_back() {
                    self.trade_ids.lock().unwrap().remove(&old.en_tradeid());
                }
            }
            over
        };
        if dropped > 0 {
            let total = self
                .trade_dropped
                .fetch_add(dropped as u64, Ordering::AcqRel)
                + dropped as u64;
            // 只在第一次触顶弹一次:触顶之后每笔都丢,弹下去就是刷屏。
            if total == dropped as u64 {
                self.push_alert(
                    crate::toast::AlertLevel::Warning,
                    format!("成交表已达 {TRADE_CAP} 行上限:更早的成交不再显示"),
                );
            }
        }
        self.trade_dirty.store(true, Ordering::Release);
        self.bump();
    }

    /// 一笔持仓入账(基线 PositionMonitor:键 `en_positionid`,可变列照 :1480-1497)。
    ///
    /// 「平仓后残留」由渲染侧处理(基线 :1514-1517 的 `volume == 0 → hideRow`):
    /// 账本留着这一行、把 volume 写成 0,不删行。
    pub fn note_position(&self, p: &ek_data::position::PositionData) {
        let key = p.en_positionid();
        {
            let mut book = self.trade_positions.lock().unwrap();
            match book.get_mut(&key) {
                Some((_, cur)) => {
                    cur.volume = p.volume;
                    cur.yd_volume = p.yd_volume;
                    cur.can_use_volume = p.can_use_volume;
                    cur.frozen = p.frozen;
                    cur.on_road_volume = p.on_road_volume;
                    cur.market_value = p.market_value;
                    cur.avg_price = p.avg_price;
                    cur.price = p.price;
                    cur.last_price = p.last_price;
                    cur.pnl = p.pnl;
                    cur.profit_rate = p.profit_rate;
                    cur.name.clone_from(&p.name);
                }
                None => {
                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                    book.insert(key, (seq, p.clone()));
                }
            }
        }
        self.trade_dirty.store(true, Ordering::Release);
        self.bump();
    }

    /// 一笔资金入账(基线 AccountMonitor:键 `en_accountid`,可变列 balance / frozen)。
    pub fn note_funds(&self, a: &ek_data::account::AccountData) {
        let key = a.en_accountid();
        {
            let mut book = self.trade_funds.lock().unwrap();
            match book.get_mut(&key) {
                Some((_, cur)) => {
                    cur.balance = a.balance;
                    cur.frozen = a.frozen;
                }
                None => {
                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
                    book.insert(key, (seq, a.clone()));
                }
            }
        }
        self.trade_dirty.store(true, Ordering::Release);
        self.bump();
    }

    /// 某网关的行全部作废(0.5 M1:摘除 / 下线后不留旧委托旧仓)。返回清掉的行数。
    pub fn clear_gateway_rows(&self, gateway: &str) -> usize {
        let mut removed = 0usize;
        {
            let mut book = self.trade_orders.lock().unwrap();
            let before = book.len();
            book.retain(|_, (_, o)| o.gateway_name != gateway);
            removed += before - book.len();
        }
        {
            let mut book = self.trade_trades.lock().unwrap();
            let before = book.len();
            book.retain(|(_, t)| t.gateway_name != gateway);
            removed += before - book.len();
            // 去重集跟着账本清:这个网关之后再推同一笔成交,还能上屏。
            // 前缀带点(`en_tradeid` = `{网关}.{成交号}`)→ 名字互为前缀的两个网关不会误清。
            let prefix = format!("{gateway}.");
            self.trade_ids
                .lock()
                .unwrap()
                .retain(|id| !id.starts_with(&prefix));
        }
        {
            let mut book = self.trade_positions.lock().unwrap();
            let before = book.len();
            book.retain(|_, (_, p)| p.gateway_name != gateway);
            removed += before - book.len();
        }
        {
            let mut book = self.trade_funds.lock().unwrap();
            let before = book.len();
            book.retain(|_, (_, a)| a.gateway_name != gateway);
            removed += before - book.len();
        }
        if removed > 0 {
            self.trade_dirty.store(true, Ordering::Release);
            self.bump();
        }
        removed
    }

    /// 成交账本触顶丢弃计数(0.5 偏离③;0 = 从没丢过)。
    #[must_use]
    pub fn trade_dropped(&self) -> u64 {
        self.trade_dropped.load(Ordering::Acquire)
    }

    /// 交易监控快照读(渲染):四本账的缓存视图,脏时按帧重建一次。
    ///
    /// 脏标记由入账置真、这里消费 —— 回报潮里一百条事件也只重建一次(每帧一次),
    /// 不是每条事件一次。锁序:先缓存后账本;入账侧只持账本锁,不反向。
    pub fn trade_snap(&self) -> Arc<TradeSnap> {
        let mut cache = self.trade_snap.lock().unwrap();
        if self.trade_dirty.swap(false, Ordering::AcqRel) {
            *cache = Arc::new(self.build_trade_snap());
        }
        Arc::clone(&cache)
    }

    /// 四本账 → 快照。行序 = seq 降序(基线 `insertRow(0)` 的新事件在前),
    /// 同 seq 用键升序兜底,保证可断言。
    fn build_trade_snap(&self) -> TradeSnap {
        fn by_seq<T>(rows: &BTreeMap<String, (u64, T)>) -> Vec<T>
        where
            T: Clone,
        {
            let mut v: Vec<(u64, &String, &T)> =
                rows.iter().map(|(k, (s, v))| (*s, k, v)).collect();
            v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
            v.into_iter().map(|(_, _, v)| v.clone()).collect()
        }
        TradeSnap {
            orders: by_seq(&self.trade_orders.lock().unwrap()),
            trades: self
                .trade_trades
                .lock()
                .unwrap()
                .iter()
                .map(|(_, t)| t.clone())
                .collect(),
            positions: by_seq(&self.trade_positions.lock().unwrap()),
            accounts: by_seq(&self.trade_funds.lock().unwrap()),
        }
    }

    // ---- S6 CTP 配置面 ----

    /// CTP 配置表单读(渲染克隆面)。
    pub fn ctp_form(&self) -> CtpForm {
        self.ctp_form.lock().unwrap().clone()
    }

    /// CTP 配置表单写。
    pub fn set_ctp_form(&self, f: CtpForm) {
        *self.ctp_form.lock().unwrap() = f;
        self.bump();
    }

    // ---- BD-UI-31 表格行选中面 ----

    /// 该行是否为所属表的选中行(渲染判断;无键 = 未选)。
    pub fn row_selected(&self, table: &str, key: &str) -> bool {
        self.row_sel
            .lock()
            .unwrap()
            .get(table)
            .is_some_and(|k| k == key)
    }

    /// 设所属表的选中行(每表一行;同键重复点 = 保持)。
    pub fn select_row(&self, table: &str, key: &str) {
        self.row_sel
            .lock()
            .unwrap()
            .insert(table.to_string(), key.to_string());
    }
}