ek-ui 0.4.3

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
//! 共享观测状态:`UiPlugin`(总线面)写、`render_body`(窗口面)读。
//!
//! 线程形貌:总线侧在**插件 worker 线程**写;窗口侧在 **eframe 主线程**读;
//! 锁粒度 = 每次 `lock().clone()/snapshot()`,不跨帧持锁。

use std::collections::{BTreeMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, 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 },
}

/// 行情缓存单格容量(防涨;丢头保尾,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.7.0)是资金/持仓交替,实测每类约 4 秒一拍;30 秒 = 约 7 拍
/// 没见到资金,足够避开「刚登录还没轮到」的正常空窗,又不至于把 F2 那种断供拖很久才显形。
pub const ACCOUNT_STALE_SECS: i64 = 30;

/// `{gw}.status` 轮询节拍(eTimer 每 1 秒一拍;每 N 拍问一次)。
///
/// 2 拍 ≈ 2 秒:状态口在 ek-ctp 侧是只读口(不发查询、不写日志),但 8 个账户
/// 每秒各一次 ask 仍是白给的总线流量;而掉线显形的时效性 2 秒足够。
pub const STATUS_POLL_TICKS: u32 = 2;

/// 二次确认步进: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>,
}

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,
        })
    }

    /// 状态列文案(装配态 + 登录态拼一起)。
    ///
    /// 「已装配」后面必须跟着登录态,不能光写「已装配」——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()
        }
    }
}

/// 交易监控快照(S5;ui 插件 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>,
    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)退役:
    /// 可拖 + 列线是全部表的固定形制,不再是可开关的东西 → 字段与访问器一并删。
    /// `{gw}.status` 轮询节拍(eTimer 每 1 秒一拍,每 `STATUS_POLL_TICKS` 拍问一次)。
    status_tick: AtomicU32,
    /// 「查询断供」阈值覆盖 `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;
            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.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 每拍调一次,每 `STATUS_POLL_TICKS` 拍回一次 `true`。
    pub fn status_poll_due(&self) -> bool {
        let n = self.status_tick.fetch_add(1, Ordering::Relaxed) + 1;
        n.is_multiple_of(STATUS_POLL_TICKS)
    }

    /// `{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 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.` 裸键上屏)。
    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
        self.contract_store.lock().unwrap().put(c);
        self.bump();
    }

    /// 合约查询(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()
    }

    /// 查询串读写。
    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()
    }

    /// 追加一行日志(总线面/宿主观察线程写)。
    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();
    }

    /// 取走全部指令(eTimer 节拍辞行)。
    pub fn drain_cmds(&self) -> Vec<UiCmd> {
        self.cmds.lock().unwrap().drain(..).collect()
    }

    /// 当前待二次确认目标(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 交易面 ----

    /// 交易监控快照写(ui 插件 eTimer 拍)。
    pub fn set_trade_snap(&self, ts: TradeSnap) {
        *self.trade_snap.lock().unwrap() = Arc::new(ts);
        self.bump();
    }

    /// 交易监控快照读(渲染)。
    pub fn trade_snap(&self) -> Arc<TradeSnap> {
        self.trade_snap.lock().unwrap().clone()
    }

    // ---- 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());
    }
}