ek-ui 0.9.43

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
//! `UiPlugin` —— 总线面:`eLog` 入环形日志、`ePlugUp/Down` 记事件 + rev、
//! `eBar`./`eBarUpdate.` 记行情库(K线窗数据面)、`eTick.` 记 tick 库(v4 行情带
//! 唯一源,S7 上屏地面)、`eContract.`/`eContractFinish` 记合约表,
//! **0.5 M1 起 `eOrder.`/`eTrade.`/`ePosition.`/`eAccount.` 四条推送线入交易账本**
//! (基线 BaseMonitor 同形:后台只 put,界面自己 process_event)。
//! `eTimer`(1s 宿主自带)那一拍只做三件事:管制面快照(`svctl.list`,每
//! `LIST_POLL_TICKS` 拍)、**待播种队列**触发的状态播种(`seed_status`:网关刚上线/
//! 刚重连时一次性问 `{gw}.status`,常态**零轮询** —— 0.7.0 第3刀退役了 0.5 时代的
//! 摊平状态轮询,登录态读数吃 `eStatus.` 推送)、被标记时才问一次的装配显形
//! (`oms.pipeline_status`)。**这一拍不再问交易数据,
//! 也不再辞行操作指令** —— 指令由命令线程执行(0.5 M5,点击即发)。
//!
//! 纪律(D1):UI 不周期取数;唯一例外是观测面 `svctl.list`(基线里对应物是同进程
//! 直读,不是推送)。纪律(D2):后台只 put、不驱动界面 —— 入账 worker 里没有任何 ask。唯一管制面是 ask 契约(svctl / dm.reload /
//! oms 查询与 submit_order·cancel_order / ctp 配置订阅,全部与
//! headless 同源;`PluginContext` 只在 worker 可用)。0.1.1 BD-UI-30:
//! 下单/撤单直发网关的旁路口删除——风控与守卫不可绕过。0.2.10 BD-UI-38:
//! sim 纸面网关退场(流控查询口随之撤)。

use std::sync::OnceLock;
use std::time::Duration;

use ek_core::context::PluginContext;
use ek_core::plugin::Plugin;
use ek_core::service::{PlugNotice, ServiceDesc};
use ek_core::{EVENT_PLUG_DOWN, EVENT_PLUG_UP};
use ek_ctp::plugin::{
    ConfigureReply, ConfigureReq, DisconnectReply, DisconnectReq, GatewayStatus, StatusReq,
    SubscribeReply as CtpSubscribeReply, SubscribeReq as CtpSubscribeReq,
};
use ek_data::account::AccountData;
use ek_data::event::{EVENT_BAR, EVENT_CONTRACT, EVENT_LOG, EVENT_TICK};
use ek_data::log::LogData;
use ek_data::order::OrderData;
use ek_data::position::PositionData;
use ek_data::trade::TradeData;
use ek_dm::plugin::{EVENT_BAR_UPDATE, ReloadReq};
use ek_event::EVENT_TIMER;
use ek_oms::plugin::{
    GetAllAccountsReq, GetAllOrdersReq, GetAllPositionsReq, GetAllTradesReq, OmsCancelOrderReq,
    OmsCancelReply, PipelineStatusReq, SubmitOrderReq,
};
use ek_oms::{SubmitReply, SubmitStatus};
use ek_station::wire::{
    ListReq, PlugRow, RemoveReq, RemoveRes, SVC_LIST, SVC_REMOVE, SVC_START, StartReq, StartRes,
};

// 配置托底三口的 ask 面(0.6.0 环节8);id 以 ek-cfg `cfg_services()` 单源
// (逐角取,不本地复定义字面 —— 服务名将来若改,编译/运行端对上就行)。
// 类型面单源同在 ek-cfg:cfg.save 的 SaveReq/SaveRes ui 现时没有消费方
// (重存段走 cfg.list 读档发 SaveReq 的是上半执面外的 cmd 线;布局待偿),
// 0.6.3 clippy 清债撤这两个未用别名 —— 上游契约仍在,用到时再加回。
//   cfg.list   = CfgListReq/CfgListRes(目录;零值面)
//   cfg.delete = CfgDeleteReq/CfgDeleteRes(抹键/清段)
use ek_cfg::wire::{
    DeleteReq as CfgDeleteReq, DeleteRes as CfgDeleteRes, ListReq as CfgListReq,
    ListRes as CfgListRes,
};

/// `cfg.list` / `cfg.delete` 服务 id(单源自 ek-cfg `wire::cfg_services()` 的
/// 表位;此处取第 2/3 行)。加载期一次,不重复算。
fn cfg_svc_ids() -> (String, String, String) {
    let descs = ek_cfg::wire::cfg_services();
    let id = |i: usize| -> String {
        descs
            .get(i)
            .map_or_else(|| format!("cfg.svc[{i}]"), |d| d.id.clone())
    };
    (id(0), id(1), id(2))
}

/// 配置托底存储根(与 station 工厂 `ek_cfg::CfgPlugin::eco_or_local` 同口径:
/// eco 成功 = 生态配置目录;失败 = 仓内 `./ekcfg-local`)。命令线程直接落库
/// 的「重存段」與目录行共根的保证点。
///
/// 测试隔离:套件用 `ek_cfg::set_default_root_for_tests(根)` 把缺省根整体钉到
/// 仓内显式根(单点在 ek-cfg `default_root()`),本函数无需另打缝。
pub(crate) fn ui_cfg_store() -> ek_cfg::Store {
    match ek_cfg::Store::eco() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("ek-cfg:运维修复根 eco 失败({e}),回落仓内 ./ekcfg-local(显形口径)");
            ek_cfg::Store::with_root(std::path::PathBuf::from("ekcfg-local"))
        }
    }
}

/// **主题档的存储键位(0.8.0 配色双档,BD-UI-61)**:`ns="ui"` / `key="theme"`。
///
/// 为什么走存储根直用 `Store` 而不是总线:`cfg.list` 回包按契约**只有键名 + 形态标志,
/// 没有值**(BD-CFG-18 安全视图),契约里没有「读值」的口 → 走总线必然破契约。
/// 直用 `Store` 是 0.6.0 起的既有口径(BD-CFG-10 soft-centralized,`CfgResave` 同源同根)。
/// 只有**用户手点过**切换钮才落盘(照参考站「只有点击才存 localStorage」);
/// 运维窗「清段 ui」= 主题回缺省深色,与既有运维语义自洽。
pub const THEME_NS: &str = "ui";
/// 段里的键名。
pub const THEME_KEY: &str = "theme";

/// 读档(启动时一次,命令线程;选单窗在插件起来之前也用它)。
/// 读不到(段不存在 / 键不存在 / 脏值 / 读失败)= **缺省深色**(裁定 B:现行为零变化)。
#[must_use]
pub fn load_theme() -> crate::tokens::Theme {
    match ui_cfg_store().load(THEME_NS) {
        Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => map
            .get_plain(THEME_KEY)
            .map(crate::tokens::Theme::from_str)
            .unwrap_or_default(),
        Ok(ek_cfg::LoadOutcome::Fresh) => crate::tokens::Theme::default(),
        Err(e) => {
            eprintln!("ek-cfg:读主题档失败({e}),按缺省深色起界面");
            crate::tokens::Theme::default()
        }
    }
}

/// 落盘「当前主题档」的**建图面**(载入-改;0.8.0 配色双档,BD-UI-61)。
///
/// 步骤 3(升级方案)口径:插件**不自写** —— 写路 = `cfg.save` 总线正路
/// (`UiPlugin::cfg_bus_save`);载入-改的读面留直读(BD-CFG-18 安全视图
/// 无读值口,读是装配/命令线程的合法直读面,PLAN 只铲写)。
fn build_theme_map(
    store: &ek_cfg::Store,
    theme: crate::tokens::Theme,
) -> Result<ek_core::SectionMap, String> {
    let mut map = match store.load(THEME_NS).map_err(|e| e.to_string())? {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => ek_core::SectionMap::new(1),
    };
    map = map.set(THEME_KEY, ek_core::Field::plain(theme.as_str()));
    Ok(map)
}

/// 载入-改「自动连接」勾选(0.9.0;与主题同 `ns="ui"` 一段,载入-改)。
///
/// - 主题键(以及段里其余键)一个不动 —— 两拨消费方共用同一档案段,谁也不许把
///   别人的键抹掉(BD-CFG-5 单写者纪律:ui 插件是这个 ns 的唯一写手);
/// - `auto_connect.*` 的旧键**先全清再插**:摘除/改名后的行不留魂
///   (判据:摘除行重存档案后,ui 段里没有它的键);
/// - `SectionMap::set` 吃 self 返回新值,循环里累计。
fn build_auto_connect_map(
    store: &ek_cfg::Store,
    flags: &[(String, bool)],
) -> Result<ek_core::SectionMap, String> {
    let mut map = match store.load(THEME_NS).map_err(|e| e.to_string())? {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => ek_core::SectionMap::new(1),
    };
    let stale: Vec<String> = map
        .iter()
        .filter(|(k, _)| k.starts_with("auto_connect."))
        .map(|(k, _)| (*k).to_string())
        .collect();
    for k in stale {
        map.remove(&k);
    }
    for (gw, on) in flags {
        map = map.set(
            format!("auto_connect.{gw}"),
            ek_core::Field::plain(if *on { "1" } else { "0" }),
        );
    }
    Ok(map)
}

/// 测试/显式根写口(真写面在显式注入的 Store 上发生;ui_restore 钉用)。
/// 生产写路 = `UiPlugin::cfg_bus_save`(步骤 3 铲插件自写)。
pub fn save_auto_connect_flags_into(
    store: &ek_cfg::Store,
    flags: &[(String, bool)],
) -> Result<(), String> {
    let map = build_auto_connect_map(store, flags)?;
    ek_cfg::cfg_save_at(store.root(), THEME_NS, &map).map_err(|e| e.to_string())
}

/// 载入-改「账户别名标签」(0.9.10,用户裁定:仿真/24小时 两套标签要能选、要能留)。
///
/// 与自动连接同段同纪律:载入-改,邻居键保全;
/// `alias.*` 旧键**先全清再插** —— 摘户/清空别名后的行不留魂(空别名 = 不写键)。
fn build_aliases_map(
    store: &ek_cfg::Store,
    aliases: &[(String, String)],
) -> Result<ek_core::SectionMap, String> {
    let mut map = match store.load(THEME_NS).map_err(|e| e.to_string())? {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => ek_core::SectionMap::new(1),
    };
    let stale: Vec<String> = map
        .iter()
        .filter(|(k, _)| k.starts_with("alias."))
        .map(|(k, _)| (*k).to_string())
        .collect();
    for k in stale {
        map.remove(&k);
    }
    for (gw, alias) in aliases {
        let alias = alias.trim();
        if alias.is_empty() {
            continue;
        }
        map = map.set(format!("alias.{gw}"), ek_core::Field::plain(alias));
    }
    Ok(map)
}

/// 测试/显式根写口(同 `save_auto_connect_flags_into`;生产写路 = cfg.save 正路)。
pub fn save_account_aliases_into(
    store: &ek_cfg::Store,
    aliases: &[(String, String)],
) -> Result<(), String> {
    let map = build_aliases_map(store, aliases)?;
    ek_cfg::cfg_save_at(store.root(), THEME_NS, &map).map_err(|e| e.to_string())
}

use crate::state::{BusSnap, UiShared};

/// 注册名 = 工厂名 = `"ui"`(BD-U5:两者一致)。
pub const UI_NAME: &str = "ui";

/// 界面邮箱**软顶**(0.5 M6 首发;0.7.0 背压刀改语义,数值照旧)。
///
/// 0.4 口径:界面是链上唯一覆写容量的插件,10 万格把"30 条/秒的行情 34 秒就丢"
/// 变成"55 分钟"。0.5.0 起邮箱**无界** → 这个数不再是容量(到顶必丢),而是**软顶**
/// (到顶才丢的那一格):默认一条都不丢,只有积压堆到 10 万帧才说明界面 worker
/// 已经被彻底甩开,那时保护进程比保住行情更重要 → 开始丢新帧并把 `spilled` 涨起来。
/// 数值取 10 万的理由不变:一帧 tick ≈ 30 条/秒 ≈ 55 分钟的量,内存上限也就是这个量级。
/// 越阈显形(黄 6250 / 红 25000,`state::pending_thresholds`)在运维窗与 toast 双路可见。
pub const UI_MAILBOX_SOFT_CAP: u64 = 100_000;

/// 交易四表的推送线(基线 `event.py:13-18`;ek-ctp 0.4.0 起发裸键,ek-core 精确匹配)。
/// 名字单源取 ek-oms::wire —— 同一条线在链上只有一份拼写。
const W_ORDER: &str = ek_oms::wire::EVENT_ORDER;
const W_TRADE: &str = ek_oms::wire::EVENT_TRADE;
const W_POSITION: &str = ek_oms::wire::EVENT_POSITION;
const W_ACCOUNT: &str = ek_oms::wire::EVENT_ACCOUNT;

/// 常驻观测插件(窗口面在 [`crate::runtime`] 持 [`UiShared`] 渲染)。
pub struct UiPlugin {
    shared: std::sync::Arc<UiShared>,
    ctx: OnceLock<PluginContext>,
    /// 命令线程的停线位(0.5 M5;on_stop 置真,线程自己退)。
    cmd_stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
}

impl UiPlugin {
    /// 与窗口面共用的共享状态句柄。
    pub fn new(shared: std::sync::Arc<UiShared>) -> Self {
        Self {
            shared,
            ctx: OnceLock::new(),
            cmd_stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
        }
    }

    /// 宿主上下文句柄(0.9.34):模块窗的 [`crate::mount::CfgBridge`] 要拿它走
    /// `cfg.save` 总线;`on_init` 之前 = `None`(桥退回直写托底)。
    #[must_use]
    pub fn ctx_handle(&self) -> Option<PluginContext> {
        self.ctx.get().cloned()
    }

    /// eTimer 节流回调(0.7.0:管制面快照 + 标记触发的状态播种 + 标记触发的装配显形;
    /// 0.5 时代的「摊平状态轮询」已被第3刀退役,常态零轮询 = 判据 C13)。
    fn refresh(&self) {
        let Some(ctx) = self.ctx.get() else {
            return; // 未 init 就吃到 eTimer:忽略,等下轮
        };
        // 0.5 M3/M5:这一拍只做三件事,而且都不问交易数据。
        // 指令辞行搬到命令线程(`spawn_cmd_thread`);四张表吃 eOrder. / eTrade. /
        // ePosition. / eAccount. 推送入账(基线 BaseMonitor 同形)—— 这一拍里再出现
        // 任何「取数类 ask」就是回到 0.4 的病。
        if self.shared.list_poll_due() {
            self.poll_list(ctx);
        }
        self.seed_status(ctx);
        if self.shared.take_pipeline_due() {
            self.poll_pipeline(ctx);
        }
        if self.shared.take_cfg_due() {
            // 配置托底段目录(0.6.0 环节8):观测面读数,轻 IO 一次(与 poll_pipeline
            // 同档的「观测面例外」,不进周期节拍 —— 只在钮触/回执后续问触发)。
            self.poll_cfg(ctx);
        }
        self.flush_persist();
    }

    /// 步骤 3 写口正路:一切落盘走 **`cfg.save` 总线**(插件不自写)。
    /// cfg 缺席(UnknownService)= 软集中负形直写托底(BD-CFG-10,显形审计行;
    /// 步骤 2 deps 定死后装配期 cfg 必在位,此形只罩 on_init 前的独立窗口)。
    fn cfg_bus_save(&self, ns: &str, map: &ek_core::SectionMap) -> Result<(), String> {
        if let Some(ctx) = self.ctx.get() {
            match ctx.ask_service::<ek_cfg::wire::SaveReq, ek_cfg::wire::SaveRes>(
                "cfg.save",
                ek_cfg::wire::SaveReq {
                    ns: ns.to_string(),
                    map: map.clone(),
                },
                Duration::from_millis(1500),
            ) {
                Ok(r) if r.ok => return Ok(()),
                Ok(r) => return Err(r.msg),
                Err(ek_core::error::AskError::UnknownService { .. }) => {
                    self.shared
                        .log_line(format!("[cfg] cfg 缺席,{ns} 直写托底(软集中负形)"));
                }
                Err(e) => return Err(format!("{e:?}")),
            }
        }
        ui_cfg_store().save(ns, map).map_err(|e| e.to_string())
    }

    /// 步骤 3 写口正路(清段面):走 **`cfg.delete` 总线**(段文件 + 秘库条一起清);
    /// cfg 缺席 = `purge_account_archive` 直清负形(同 BD-CFG-10 口径)。
    fn cfg_bus_delete(&self, ns: &str) -> Result<(), String> {
        if let Some(ctx) = self.ctx.get() {
            match ctx.ask_service::<ek_cfg::wire::DeleteReq, ek_cfg::wire::DeleteRes>(
                "cfg.delete",
                ek_cfg::wire::DeleteReq {
                    ns: ns.to_string(),
                    key: String::new(),
                },
                Duration::from_millis(1500),
            ) {
                Ok(r) if r.ok => return Ok(()),
                Ok(r) => return Err(r.msg),
                Err(ek_core::error::AskError::UnknownService { .. }) => {
                    self.shared
                        .log_line(format!("[cfg] cfg 缺席,{ns} 直清托底(软集中负形)"));
                }
                Err(e) => return Err(format!("{e:?}")),
            }
        }
        crate::appconfig::purge_ns_direct(&ui_cfg_store(), ns)
    }

    /// **落盘拍(0.9.10 收口,用户实锤「删了账户确认了、重启又显示」的第二条根因)**:
    /// 四类用户手点落盘(theme / 档案抹除 / 自动连接 / 别名)从 eTimer 拍抽出成
    /// 单口 —— **eTimer(1s 拍)与 `on_stop`(收场)都吃这一口**。
    /// 旧口径只在 1s 拍消费:删除后 ≤1s 内关窗 = 待办没消费 = 档案没抹 = 重启复活
    /// (摘行到关窗常在 1s 内 = race 常态,不是偶发)。收场 flush 封死这个窗口。
    fn flush_persist(&self) {
        if self.shared.take_theme_persist_due() {
            // 0.8.0 配色双档:用户手点切换钮后的那次落盘(渲染线程零文件 IO,写在这拍)。
            // 步骤 3 正路:载入-改建图 → **cfg.save 总线写**。
            let theme = self.shared.theme();
            let wr = build_theme_map(&ui_cfg_store(), theme)
                .and_then(|map| self.cfg_bus_save(THEME_NS, &map));
            match wr {
                Ok(()) => self
                    .shared
                    .log_line(format!("[ui] 主题已存(ui.theme={})", theme.as_str())),
                Err(e) => self
                    .shared
                    .log_line(format!("[ui] 主题落盘失败:{e}(本次切换只在本次运行内生效)")),
            }
        }
        let purges = self.shared.take_archive_purges();
        for gw in purges {
            // 0.9.10 用户实锤「界面删了的账户,下次启动又显示」:摘行只摘了
            // 内存清册,档案段 `ctp.{gw}` 还在 = 启动清点扫段 = 原样复活。
            // 摘行必须连档案一起抹(与 write_account 对称;渲染线程零文件 IO,
            // 落手在这拍)。步骤 3 正路:cfg.delete 总线清段。
            match self.cfg_bus_delete(&format!("ctp.{gw}")) {
                Ok(()) => self.shared.log_line(format!(
                    "[账户] 档案段 ctp.{gw} 已抹(删除已落盘:下次启动清点不再复活)"
                )),
                Err(e) => self.shared.log_line(format!(
                    "[账户] 档案 ctp.{gw} 抹除失败:{e}(本次删除只在今次运行生效,重启会复活)"
                )),
            }
        }
        if self.shared.take_auto_connect_persist_due() {
            // 0.9.0 自动连接勾选:同一支纪律(手点才写,渲染线程零文件 IO)。
            // ui 段**载入-改-存**:主题键必须保住;摘了户口的行要清键(不留魂)。
            let flags: Vec<(String, bool)> = self
                .shared
                .accounts()
                .iter()
                .map(|a| (a.gateway.clone(), a.auto_connect))
                .collect();
            // 步骤 3 正路:载入-改建图 → cfg.save 总线写。
            let wr = build_auto_connect_map(&ui_cfg_store(), &flags)
                .and_then(|map| self.cfg_bus_save(THEME_NS, &map));
            match wr {
                Ok(()) => {
                    let on: Vec<&str> = flags
                        .iter()
                        .filter(|(_, on)| *on)
                        .map(|(gw, _)| gw.as_str())
                        .collect();
                    if on.is_empty() {
                        self.shared
                            .log_line("[ui] 自动连接勾选已存(ui 段;当前全部关)".to_string());
                    } else {
                        self.shared.log_line(format!(
                            "[ui] 自动连接勾选已存(ui 段;开:{};守护/桌面启动清点按它起连)",
                            on.join(", ")
                        ));
                    }
                }
                Err(e) => self.shared.log_line(format!(
                    "[ui] 自动连接勾选落盘失败:{e}(本轮勾选只在本次运行内生效)"
                )),
            }
        }
        if self.shared.take_alias_persist_due() {
            // 0.9.10 别名标签:保存/改名/摘行触发的落盘(同自动连接纪律)。
            let aliases: Vec<(String, String)> = self
                .shared
                .accounts()
                .iter()
                .map(|a| (a.gateway.clone(), a.alias.clone()))
                .collect();
            // 步骤 3 正路:载入-改建图 → cfg.save 总线写。
            let wr = build_aliases_map(&ui_cfg_store(), &aliases)
                .and_then(|map| self.cfg_bus_save(THEME_NS, &map));
            match wr {
                Ok(()) => {
                    let named: Vec<String> = aliases
                        .iter()
                        .filter(|(_, al)| !al.trim().is_empty())
                        .map(|(gw, al)| format!("{al}({gw})"))
                        .collect();
                    if named.is_empty() {
                        self.shared
                            .log_line("[ui] 账户别名已存(ui 段;当前全部无别名)".to_string());
                    } else {
                        self.shared.log_line(format!(
                            "[ui] 账户别名已存(ui 段;{};启动清点按它贴标签)",
                            named.join(", ")
                        ));
                    }
                }
                Err(e) => self.shared.log_line(format!(
                    "[ui] 账户别名落盘失败:{e}(本次改名只在本次运行内生效)"
                )),
            }
        }
    }

    /// 配置托底段目录(0.6.0;`cfg.list` 零值面,BD-CFG-17/18)。
    fn poll_cfg(&self, ctx: &PluginContext) {
        let (_, svc_list, _) = cfg_svc_ids();
        match ctx.ask_service::<CfgListReq, CfgListRes>(
            svc_list.as_str(),
            CfgListReq,
            Duration::from_secs(2),
        ) {
            Ok(res) => {
                self.shared.set_cfg_briefs(res.entries);
                if !res.msg.is_empty() {
                    self.shared
                        .log_line(format!("[cfg] 目录:{msg}", msg = res.msg));
                }
            }
            Err(e) => self
                .shared
                .log_line(format!("[cfg] 段落目录失败(cfg 缺席或超时): {e:?}")),
        }
    }

    /// 管制面快照(`svctl.list`):谁在场 + 服务目录 + 邮箱溢出计数。
    ///
    /// 0.5 M3 降到每 `LIST_POLL_TICKS` 拍一次。它给的不是交易数据而是「宿主长什么样」
    /// 的观测面 —— 基线里的对应物是 `get_all_gateway_names` 的同进程直读,不是推送,
    /// 所以它留在白名单里(「界面不周期取数」这条纪律的唯一例外)。
    fn poll_list(&self, ctx: &PluginContext) {
        match ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(2)) {
            Ok(rows) => {
                let mut services = Vec::with_capacity(rows.len());
                for r in &rows {
                    let ids = ctx
                        .services_of(&r.name)
                        .map(|v| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>())
                        .unwrap_or_default();
                    services.push((r.name.clone(), ids));
                }
                // 账户在场位回填 + 「实例没了就作废会话读数」。
                // 0.5:「新装配」不再由这里检测 —— ePlugUp. 已订阅,即时且不靠轮询。
                self.shared.sync_accounts_connected(&rows);
                // 0.4.5(P7d):邮箱溢出的当场显形。运维窗那一列一直有 spilled,
                // 但 ek-core 的 stderr 诊断每 128 条才打一条 —— 屏上此前没有任何东西。
                self.shared.note_plugin_spill(&rows);
                self.shared.set_snapshot(BusSnap { rows, services });
            }
            Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e}")),
        }
    }

    /// 一次性状态播种(0.7.0 第3刀:周期轮询退役)。
    ///
    /// 常态**零轮询**:登录态/断供读数全靠 `eStatus.` 推送到货。这里只处理
    /// 「网关刚上线(或刚重连)」的待播种队列 —— 每网关运行期至多一次 ask,
    /// 拿初始快照(推送是变化触发,刚上线还没有变化,界面得有一份起点)。
    /// 只读口不写日志(ek-ctp D-STATUS ④),播种不冲 eLog 环。
    fn seed_status(&self, ctx: &PluginContext) {
        for gw in self.shared.take_status_seeds() {
            let svc = format!("{gw}.{}", ek_ctp::SVC_STATUS);
            match ctx.ask_service::<StatusReq, GatewayStatus>(
                &svc,
                StatusReq,
                Duration::from_secs(2),
            ) {
                Ok(st) => self.shared.note_gateway_status(&gw, &st),
                Err(e) => {
                    // 打 Display(不是 Debug):超时行自带「目标积压 N 帧」(ek-core 0.5.0)。
                    // **不排回队列重试**:常态零轮询是这一刀的判据(C13),问不到就留在
                    // 「无证据」态等推送 —— `eStatus.` 一到,读数立刻归位。
                    self.shared.note_status_unreachable(&gw, &format!("{e}"));
                }
            }
        }
    }

    /// 装配显形(0.5 M3 改事件触发):`oms.pipeline_status` 只在被标记的那一拍问一次。
    ///
    /// 标记来自启动、`ePlugUp.oms` 与运维窗的刷新钮。它此前是每 4 拍一次的轮询 ——
    /// 装配很少变,而它是 oms worker 上又一次串行 ask;在事件驱动模型里这种轮询是多余的。
    fn poll_pipeline(&self, ctx: &PluginContext) {
        match ctx.ask_service::<PipelineStatusReq, Vec<String>>(
            ek_oms::SVC_PIPELINE_STATUS,
            PipelineStatusReq,
            Duration::from_secs(2),
        ) {
            Ok(lines) => self.shared.set_pipeline_lines(lines),
            Err(e) => {
                // 不每拍刷日志:oms 不在场是合法态(清单里没选它,ask 走 TargetGone
                // 快错)。只在「从有到无」那一次显形一条,与状态轮询「只读口不冲 eLog 环」
                // 同口径。读数清空 → 运维窗那一节整节不画。
                if !self.shared.pipeline_lines().is_empty() {
                    self.shared
                        .log_line(format!("[ui] oms.pipeline_status 失手: {e:?}"));
                }
                self.shared.set_pipeline_lines(Vec::new());
            }
        }
    }

    /// 启动一次四问(0.5 M4;基线无对应物 —— 插件热插拔是 ek 独有场景)。
    ///
    /// 一次性,不是周期:之后四张表只吃推送。走 `note_*` 而不是 `seed_trade_snap`,
    /// 是为了**合并而不是覆盖** —— 网关可能在 oms 还没消化完之前就已经把委托推给了
    /// 界面,覆盖式播种会把界面已经看见的那一笔抹掉。
    ///
    /// 执行位置 = 命令线程(0.5 执行核查改的):原先它挂在 `on_start` 里,而 `on_start`
    /// 是宿主启动序列的一部分 —— 四口最坏 4 × 2 秒会把后面插件的启动一起拖住;挂在
    /// 入账 worker 上又违反「入账 worker 里没有任何 ask」。所以它跟着命令线程走。
    fn reconcile_trade(shared: &UiShared, ctx: &PluginContext) {
        let orders = ctx.ask_service::<GetAllOrdersReq, Vec<OrderData>>(
            "oms.get_all_orders",
            GetAllOrdersReq,
            Duration::from_secs(2),
        );
        let trades = ctx.ask_service::<GetAllTradesReq, Vec<TradeData>>(
            "oms.get_all_trades",
            GetAllTradesReq,
            Duration::from_secs(2),
        );
        let positions = ctx.ask_service::<GetAllPositionsReq, Vec<PositionData>>(
            "oms.get_all_positions",
            GetAllPositionsReq,
            Duration::from_secs(2),
        );
        let funds = ctx.ask_service::<GetAllAccountsReq, Vec<AccountData>>(
            "oms.get_all_accounts",
            GetAllAccountsReq,
            Duration::from_secs(2),
        );
        let mut n = 0usize;
        if let Ok(rows) = &orders {
            for o in rows {
                shared.note_order(o);
            }
            n += rows.len();
        }
        if let Ok(rows) = &trades {
            for t in rows {
                shared.note_trade(t);
            }
            n += rows.len();
        }
        if let Ok(rows) = &positions {
            for p in rows {
                shared.note_position(p);
            }
            n += rows.len();
        }
        if let Ok(rows) = &funds {
            for a in rows {
                shared.note_funds(a);
            }
            n += rows.len();
        }
        // 只在真播种到东西时落一条;空清单(刚装配、柜台还没回报)不刷日志。
        if n > 0 {
            let bad = usize::from(orders.is_err())
                + usize::from(trades.is_err())
                + usize::from(positions.is_err())
                + usize::from(funds.is_err());
            shared.log_line(format!(
                "[ui] 启动播种 {n} 行(委托 {} / 成交 {} / 持仓 {} / 资金 {};失手 {} 口)",
                orders.as_ref().map_or(0, |v| v.len()),
                trades.as_ref().map_or(0, |v| v.len()),
                positions.as_ref().map_or(0, |v| v.len()),
                funds.as_ref().map_or(0, |v| v.len()),
                bad
            ));
        }
    }

    /// 命令线程(0.5 M5):点击即发,不等 eTimer 拍,也不占 ui 的入账 worker。
    ///
    /// 基线对应物是「UI 主线程直调 `main_engine.send_order`,立即返回」(engine.py:915-923)。
    /// ek-core 的 ask 是同步的,所以把「等回包」这件事从入账线程挪走:入账线程从此只
    /// 做「收事件 → 写账本」,每事件 O(1),永不被后端拖住。
    /// 这一条线程也是「会等的东西」唯一允许待的地方:启动一次四问在这里跑
    /// (见 `reconcile_trade`),既不占宿主的启动序列,也不占入账 worker。
    fn spawn_cmd_thread(&self) {
        let Some(ctx) = self.ctx.get().cloned() else {
            return; // 未 init:没有管制面可用
        };
        let shared = std::sync::Arc::clone(&self.shared);
        let stop = std::sync::Arc::clone(&self.cmd_stop);
        if std::thread::Builder::new()
            .name("ui-cmd".to_string())
            .spawn(move || {
                loop {
                    if stop.load(std::sync::atomic::Ordering::Acquire) {
                        break;
                    }
                    // 用户指令优先于播种:播种最坏 4 × 2 秒,不能把点击排到它后面。
                    if shared.cmd_pending() {
                        Self::run_cmds(&shared, &ctx);
                        continue;
                    }
                    // 播种档:启动一次 + 之后每次网关 / oms 上线各一次(标记是消费性的)。
                    if shared.take_trade_reseed() {
                        Self::reconcile_trade(&shared, &ctx);
                        continue;
                    }
                    // K线历史补数(I2 第一刀;开窗入队,这里发 tq 桥)。
                    if shared.kline_fetch_pending() {
                        Self::run_kline_fetches(&shared, &ctx);
                        continue;
                    }
                    std::thread::sleep(Duration::from_millis(20));
                }
            })
            .is_err()
        {
            // 线程起不来 = 指令只会进队列不会执行。这一条必须上屏,不能静默降级。
            self.shared
                .log_line("[ui] 命令线程起不来:操作指令会滞留在队列里");
        }
    }

    /// K线历史补数执行体(I2 第一刀;命令线程):
    /// 入队单 → 找 tq 派生网关 → `{gw}.klines`(1min×300,Length 写法)→ 回包并格。
    /// error/truncated/ask 失手 = 日志显形(不静默),cell 只吃 error=None 的数据。
    fn run_kline_fetches(shared: &UiShared, ctx: &PluginContext) {
        for f in shared.take_kline_fetches() {
            let Some(gw) = ctx
                .plugins()
                .iter()
                .map(|p| p.name.clone())
                .find(|n| n.starts_with("tq"))
            else {
                shared.log_line(format!(
                    "[kline] {} tq 网关不在线,历史补数丢弃(重开 K线窗再试)",
                    f.symbol
                ));
                continue;
            };
            let req = ek_tq::KlinesReq {
                symbol: f.symbol.clone(),
                raw: false,
                interval_secs: f.interval_secs,
                range: ek_tq::RangeSpec::Length {
                    count: 300,
                    end_ns: None,
                },
                max_rows: Some(300),
            };
            match ctx.ask::<ek_tq::KlinesReq, ek_tq::KlinesReply>(&gw, req, Duration::from_secs(45))
            {
                Ok(reply) => {
                    if let Some(err) = &reply.error {
                        shared.log_line(format!(
                            "[kline] {}/{}s 历史显形: {}",
                            f.symbol, f.interval_secs, err.message
                        ));
                        continue;
                    }
                    let n = reply.rows.len();
                    shared
                        .market()
                        .note_history(&f.symbol, f.interval_secs, &reply.rows);
                    shared.bump();
                    shared.log_line(format!(
                        "[kline] {}/{}s 历史补数 +{n} 根(mode={} truncated={})",
                        f.symbol,
                        f.interval_secs,
                        reply.mode.as_str(),
                        reply.truncated
                    ));
                }
                Err(e) => shared.log_line(format!(
                    "[kline] {}/{}s 历史 ask 失手: {e:?}",
                    f.symbol, f.interval_secs
                )),
            }
        }
    }

    /// 辞行指令队列(0.5 M5:由命令线程调用,不在 ui worker 上;回执进日志,面板语义可断言)。
    fn run_cmds(shared: &UiShared, ctx: &PluginContext) {
        for cmd in shared.drain_cmds() {
            match cmd {
                crate::state::UiCmd::Start { factory, name } => {
                    match ctx.ask_service::<StartReq, StartRes>(
                        SVC_START,
                        StartReq {
                            factory: factory.clone(),
                            name: name.clone(),
                        },
                        Duration::from_secs(3),
                    ) {
                        Ok(StartRes::Started { name }) => {
                            shared.log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
                        }
                        Ok(StartRes::Rejected { reason }) => {
                            shared.log_line(format!("[ops] start {factory} 被拒: {reason}"));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] start {factory} 失败: {e:?}"));
                        }
                    }
                    // 起停回执之后重问一次管制面:运维窗那一行与账户在场位立刻动。
                    shared.request_list_refresh();
                    // 0.9.34:模块挂载表跟着动 —— 新起一个 ctp 实例只让「交易网关」
                    // 窗多一行,菜单项数不变(`kind()` 归组)。
                    shared.remount_modules();
                }
                crate::state::UiCmd::Remove { name } => {
                    // 观测面自身的闸(0.4.3 BD-UI-64,审核 P1;0.5 M5 换了理由,闸不换):
                    // 0.4 的理由是「这条指令在 ui 自己的 worker 上执行 → 摘 ui = 自己等自己
                    // (ek-core `close_one` 只能走到 2 秒超时的 detach 分支)」。
                    // 0.5 执行者换成命令线程,「自己等自己」这一半不成立了,但结论一样:
                    // 摘掉 ui 之后没有 eTimer 拍再跑快照与状态轮询、入账 worker 也没了,
                    // 窗口还开着而里面全是上一帧。所以仍然直拒,不发 svctl。
                    // 管制面 `svctl` 的同一道闸长在 ek-station supervisor 侧(0.2.11)。
                    if name == UI_NAME {
                        shared.log_line(
                            "[ops] 拒绝移除 ui:观测面自身不可自摘(摘掉后没有下一拍执行任何指令)"
                                .to_string(),
                        );
                        continue;
                    }
                    match ctx.ask_service::<RemoveReq, RemoveRes>(
                        SVC_REMOVE,
                        RemoveReq { name: name.clone() },
                        Duration::from_secs(3),
                    ) {
                        Ok(RemoveRes::Removed { name }) => {
                            shared.log_line(format!("[ops] svctl.remove {name} 完成"));
                        }
                        Ok(RemoveRes::NoSuchName { name }) => {
                            shared.log_line(format!("[ops] remove {name} 不存在/不可摘"));
                        }
                        // 0.4.3:回执枚举增项(ek-station 0.2.11)—— 名字在场但不可摘,
                        // 或宿主另有错。原先这一档被塞进 NoSuchName 的 name 栏(错误文本
                        // 当名字打),这一栏现在照请求回填,错因只进 reason。
                        Ok(RemoveRes::Rejected { name, reason }) => {
                            shared.log_line(format!("[ops] remove {name} 被拒: {reason}"));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] remove {name} 失败: {e:?}"));
                        }
                    }
                    shared.request_list_refresh();
                    // 0.9.34:摘掉实例 → 模块窗更新(某模块实例清零且非 always = 下架)。
                    shared.remount_modules();
                }
                crate::state::UiCmd::Reload {
                    interval,
                    symbol,
                    bars,
                } => {
                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
                    match ctx.ask_service::<ReloadReq, usize>(
                        "dm.reload",
                        ReloadReq {
                            interval,
                            symbol: symbol.clone(),
                            bars: bars.clone(),
                            size: Some(bars.len()),
                            push: true,
                        },
                        Duration::from_secs(5),
                    ) {
                        Ok(n) => {
                            shared.market().note_reload();
                            shared.log_line(format!(
                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
                            ));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
                        }
                    }
                }
                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
                        "oms.submit_order",
                        SubmitOrderReq {
                            req: req.clone(),
                            gateway_name: gateway.clone(),
                            mode,
                        },
                        Duration::from_secs(10),
                    ) {
                        Ok(reply) => {
                            let tag = match reply.status {
                                SubmitStatus::Sent => "SENT",
                                SubmitStatus::Partial => "PARTIAL",
                                SubmitStatus::Rejected => "REJECTED",
                            };
                            for leg in &reply.legs {
                                match (leg.ok, &leg.en_orderid, &leg.reason) {
                                    (true, Some(id), _) => shared.log_line(format!(
                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
                                        leg.req.symbol,
                                        leg.req.direction.as_str(),
                                        leg.req.offset.as_str(),
                                        leg.req.volume,
                                        reply.parent_id
                                    )),
                                    _ => shared.log_line(format!(
                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
                                        leg.req.symbol,
                                        leg.req.direction.as_str(),
                                        leg.req.offset.as_str(),
                                        leg.req.volume,
                                        leg.reason.as_deref().unwrap_or("?"),
                                        reply.parent_id
                                    )),
                                }
                            }
                            if reply.legs.is_empty() {
                                shared.log_line(format!(
                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
                                    req.symbol,
                                    req.offset.as_str(),
                                    req.volume,
                                    reply.parent_id
                                ));
                            }
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
                        }
                    }
                }
                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
                    // OrderData.en_orderid() 同式。
                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
                        "oms.cancel_order",
                        OmsCancelOrderReq {
                            en_orderid: format!("{gateway}.{orderid}"),
                            gateway_name: gateway.clone(),
                        },
                        Duration::from_secs(10),
                    ) {
                        Ok(OmsCancelReply { ok: true, msg }) => {
                            shared.log_line(format!("[ops] {msg}"));
                        }
                        Ok(OmsCancelReply { ok: false, msg }) => {
                            // 0.11.0 第6刀 R7:屏幕上给人话,机器码原文照留(取证要吃原文)
                            shared
                                .log_line(format!("[ops] {}", UiShared::cancel_reject_note(&msg)));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
                        }
                    }
                }
                crate::state::UiCmd::CtpConfigure { gateway, form } => {
                    // 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
                    // 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
                    let svc = format!("{gateway}.configure");
                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
                        svc.as_str(),
                        form.to_configure_req(),
                        Duration::from_secs(5),
                    ) {
                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
                        Ok(ConfigureReply { ok: true, msg, .. }) => {
                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
                        }
                        Ok(ConfigureReply { ok: false, msg, .. }) => {
                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
                        }
                    }
                }
                crate::state::UiCmd::Connect { gateway, form } => {
                    // **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
                    // `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
                    // (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
                    // 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
                    shared.note_connect_sent(&gateway);
                    // 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
                    // (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
                    let form = shared.prepare_connect_form(&gateway, form);
                    let started = match ctx.ask_service::<StartReq, StartRes>(
                        SVC_START,
                        StartReq {
                            factory: "ctp".to_string(),
                            name: gateway.clone(),
                        },
                        Duration::from_secs(3),
                    ) {
                        Ok(StartRes::Started { name }) => {
                            shared.log_line(format!("[ops] svctl.start ctp → {name} 上线"));
                            true
                        }
                        Ok(StartRes::Rejected { reason }) => {
                            // 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
                            // 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
                            if shared.account(&gateway).is_some_and(|a| a.connected) {
                                shared
                                    .log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
                                true
                            } else {
                                shared.log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
                                shared.note_connect_result(&gateway, Err(reason));
                                continue;
                            }
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
                            continue;
                        }
                    };
                    if !started {
                        continue;
                    }
                    let svc = format!("{gateway}.configure");
                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
                        svc.as_str(),
                        form.to_configure_req(),
                        Duration::from_secs(5),
                    ) {
                        // `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
                        // (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
                        // 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
                        Ok(ConfigureReply {
                            ok: true,
                            msg,
                            product_classes,
                        }) => {
                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
                            shared.note_effective_product_classes(&gateway, &product_classes);
                            shared.note_connect_result(&gateway, Ok(()));
                        }
                        Ok(ConfigureReply { ok: false, msg, .. }) => {
                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
                            shared.note_connect_result(&gateway, Err(msg));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
                        }
                    }
                }
                crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
                    // 多账号面:一律路由到行情元账号(渲染侧已按此入列);
                    // 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
                    // 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
                    let svc = format!("{gateway}.subscribe");
                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
                        svc.as_str(),
                        CtpSubscribeReq {
                            symbols: symbols.clone(),
                        },
                        Duration::from_secs(5),
                    ) {
                        Ok(CtpSubscribeReply { ok: true, msg }) => {
                            shared.note_subscriptions(&symbols);
                            shared.log_line(format!(
                                "[ops] {svc} {} 受理:{}",
                                symbols.join(","),
                                msg
                            ));
                        }
                        Ok(CtpSubscribeReply { ok: false, msg }) => {
                            shared.log_line(format!(
                                "[ops] {svc} {} 被拒:{}",
                                symbols.join(","),
                                msg
                            ));
                        }
                        Err(e) => {
                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
                        }
                    }
                }
                crate::state::UiCmd::Disconnect { gateway } => {
                    // 0.2.19 规划裁定 1:『断开』= `{gw}.disconnect`(停会话),
                    // 『移除』= `svctl.remove`(停实例)—— 两条走两条不同的口,不共用。
                    // 发令即把该行标成「已断开」:用户按了钮就要立刻看到,不等下一轮轮询。
                    let svc = format!("{gateway}.disconnect");
                    match ctx.ask_service::<DisconnectReq, DisconnectReply>(
                        svc.as_str(),
                        DisconnectReq,
                        Duration::from_secs(5),
                    ) {
                        Ok(DisconnectReply { ok: true, msg }) => {
                            shared.mark_account_stopped(&gateway);
                            shared.log_line(format!("[账户] 断开 {gateway}:{msg}(实例仍在场)"));
                        }
                        Ok(DisconnectReply { ok: false, msg }) => {
                            shared.log_line(format!("[账户] 断开 {gateway} 未执行:{msg}"));
                        }
                        Err(e) => {
                            shared.log_line(format!(
                                "[账户] 断开 {gateway} 失败:{e:?}(网关不在场或超时)",
                            ));
                        }
                    }
                }
                crate::state::UiCmd::CfgList => {
                    // 目录刷新(命令线程执行档;与 worker poll_cfg 同一读数面)。
                    let (_, svc_list, _) = cfg_svc_ids();
                    match ctx.ask_service::<CfgListReq, CfgListRes>(
                        svc_list.as_str(),
                        CfgListReq,
                        Duration::from_secs(2),
                    ) {
                        Ok(res) => {
                            shared.set_cfg_briefs(res.entries);
                            if !res.msg.is_empty() {
                                shared.log_line(format!("[cfg] 目录:{msg}", msg = res.msg));
                            }
                        }
                        Err(e) => shared.log_line(format!("[cfg] 段落目录失败: {e:?}")),
                    }
                }
                crate::state::UiCmd::CfgDelete { ns } => {
                    // 清段(BD-CFG-20 运维语义;空 key = 整段;零值面)。
                    let (svc_save, svc_list, svc_delete) = cfg_svc_ids();
                    let _ = (svc_save, svc_list);
                    match ctx.ask_service::<CfgDeleteReq, CfgDeleteRes>(
                        svc_delete.as_str(),
                        CfgDeleteReq {
                            ns: ns.clone(),
                            key: String::new(),
                        },
                        Duration::from_secs(2),
                    ) {
                        Ok(res) if res.ok => {
                            shared.log_line(format!(
                                "[cfg] 清段 {ns}:键 {} / 秘条 {}",
                                res.removed_keys, res.removed_secrets
                            ));
                            shared.request_cfg_refresh();
                        }
                        Ok(res) => shared.log_line(format!("[cfg] 清段 {ns} 未执行:{}", res.msg)),
                        Err(e) => shared.log_line(format!("[cfg] 清段 {ns} 失败: {e:?}")),
                    }
                    shared.arm_cfg_delete(None);
                }
                crate::state::UiCmd::CfgResave { ns } => {
                    // 重存段(0.6.0):明面按现状重读 → serialize → store。**值不变
                    // (BD-CFG-15:不输出 = 不持久,这里连「不重排空行」都不做之外的
                    // 改动一律没有)**;rev/格式修复口。兜底根与 cfg 实例同口径
                    // (BD-CFG-10 soft-centralized:直调用库,不拉起注册)。
                    let store = ui_cfg_store();
                    let loaded = store
                        .load(&ns)
                        .map_err(|e| e.to_string())
                        .and_then(|o| match o {
                            ek_cfg::LoadOutcome::Fresh => Err(format!("{ns} 段不存在,不持久空形")),
                            map @ ek_cfg::LoadOutcome::Loaded { .. } => Ok(map),
                        });
                    match loaded {
                        Ok(ek_cfg::LoadOutcome::Fresh) => {
                            unreachable!("Fresh 已被上面的 and_then 挡走");
                        }
                        Ok(ek_cfg::LoadOutcome::Loaded { map, warnings }) => {
                            if !warnings.is_empty() {
                                shared.log_line(format!("[cfg] {ns} 重读警告:{warnings:?}"));
                            }
                            // 步骤 3 正路:重存写 = cfg.save 总线(run_cmds 处 ctx 在手;
                            // cfg 缺席 = 直写负形托底,BD-CFG-10)。
                            let wr = match ctx
                                .ask_service::<ek_cfg::wire::SaveReq, ek_cfg::wire::SaveRes>(
                                    "cfg.save",
                                    ek_cfg::wire::SaveReq {
                                        ns: ns.clone(),
                                        map: map.clone(),
                                    },
                                    Duration::from_millis(1500),
                                ) {
                                Ok(r) if r.ok => Ok(()),
                                Ok(r) => Err(r.msg),
                                Err(ek_core::error::AskError::UnknownService { .. }) => {
                                    shared.log_line(format!(
                                        "[cfg] cfg 缺席,{ns} 直写托底(软集中负形)"
                                    ));
                                    ek_cfg::cfg_save_at(store.root(), &ns, &map)
                                        .map_err(|e| e.to_string())
                                }
                                Err(e) => Err(format!("{e:?}")),
                            };
                            match wr {
                                Ok(()) => {
                                    shared.log_line(format!("[cfg] {ns} 已重存(rev {})", map.rev))
                                }
                                Err(e) => shared.log_line(format!("[cfg] {ns} 重存失败:{e}")),
                            }
                        }
                        Err(msg) => shared.log_line(format!("[cfg] {msg}")),
                    }
                }
            }
        }
    }
}

impl Plugin for UiPlugin {
    fn name(&self) -> &str {
        UI_NAME
    }

    fn deps(&self) -> &[&str] {
        // 步骤 2(升级方案,用户裁定):cfg = 核心插件,**全插件必依赖**,
        // 宿主 deps 拓扑序保证 cfg 最先激活(激活序定死,不可改)。
        &["cfg"]
    }

    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
        vec![
            std::borrow::Cow::Borrowed(EVENT_LOG),
            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
            std::borrow::Cow::Borrowed(EVENT_BAR),
            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
            std::borrow::Cow::Borrowed(EVENT_TICK),
            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
            std::borrow::Cow::Borrowed(ek_ctp::EVENT_CONTRACT_FINISH),
            // 0.5 M1 交易四表的推送线(基线 BaseMonitor 各自订阅自己的线)。
            // 不订 eTradeSync.:ek-oms 处理完成交后重推的那一条(plugin.rs:305-308),
            // 与 ek-ctp 的 eTrade. 是同一笔成交的两份 —— 订两条就是重复入账。
            std::borrow::Cow::Borrowed(W_ORDER),
            std::borrow::Cow::Borrowed(W_TRADE),
            std::borrow::Cow::Borrowed(W_POSITION),
            std::borrow::Cow::Borrowed(W_ACCOUNT),
            std::borrow::Cow::Borrowed(EVENT_TIMER),
            // 0.7.0 第3刀:状态变化推送(裸线,载荷自带网关名)—— 界面零轮询的地基
            std::borrow::Cow::Borrowed(ek_ctp::EVENT_STATUS_PREFIX),
        ]
    }

    fn mailbox_soft_cap(&self) -> u64 {
        UI_MAILBOX_SOFT_CAP
    }

    fn version(&self) -> std::borrow::Cow<'static, str> {
        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
    }

    fn services(&self) -> Vec<ServiceDesc> {
        Vec::new() // 纯视图零服务;管制面在 svctl
    }

    fn on_init(&self, ctx: &PluginContext) {
        let _ = self.ctx.set(ctx.clone());
    }

    fn on_start(&self) {
        self.shared.log_line("[ui] 界面插件上线");
        // 0.5 M5 + M4:命令线程先起(点击即发);启动一次四问在那条线程里跑 ——
        // `on_start` 是 `engine.start()` 启动链的一环,四口最坏 4 × 2 秒不许把后面
        // 插件的启动一起拖住,更不许占入账 worker。
        self.spawn_cmd_thread();
        // 启动一次四问的标记(on_start 是 `engine.start()` 启动链的一环,四口最坏
        // 4 × 2 秒不许把后面插件的启动一起拖住,更不许占入账 worker)。
        self.shared.request_trade_reseed();
        self.shared.request_pipeline_refresh();
        // 0.8.0 配色双档:启动读一次档(命令线程,一次小文件读),用户没点过钮 = 缺省深色。
        let theme = load_theme();
        self.shared.set_theme(theme);
        if theme == crate::tokens::Theme::Light {
            self.shared.log_line("[ui] 主题:浅色(读盘 ui.theme)");
        }
    }

    fn on_stop(&self) {
        self.shared.log_line("[ui] 界面插件下线");
        // 命令线程停线:它在 20ms 空转切片自查这个位,不会卡在 sleep 里。
        self.cmd_stop
            .store(true, std::sync::atomic::Ordering::Release);
        // **收场 flush(0.9.10,用户实锤「删了账户、确认了,重启又显示」的第二条根因)**:
        // 落盘待办过去只在 1s eTimer 拍消费 —— 删除后 ≤1s 内关窗 = 待办没消费 =
        // 档案没抹 = 重启复活。收场把四类待办冲干净,封死 race 窗口。
        self.flush_persist();
    }

    fn on_mail(&self, event: &ek_event::Event) {
        let ty = event.event_type.as_str();
        if ty == EVENT_LOG {
            if let Some(d) = event.data.downcast_ref::<LogData>() {
                let tag = if d.is_warning_or_above() {
                    "WARN"
                } else {
                    "INFO"
                };
                self.shared
                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
                // 0.5.0(用户定「链进行的状态改为事件日志推送」):`[链] ` 前缀的行除了进日志窗,
                // 还落进账户行的「阶段」栏(ek-ctp 0.9.8 D-QUERY-GATE ⑧)。这一条是纯显示落账,
                // 不参与 `apply_status` 那套在线/断供判据。
                // 前缀单源:`crate::state::CHAIN_STATE_PREFIX` = ek-ctp 0.9.8 那一份的 re-export
                // (state.rs:201;0.6.3 路标兑现,注释同步 —— 无本地字面)。
                if let Some(note) = d.msg.strip_prefix(crate::state::CHAIN_STATE_PREFIX) {
                    self.shared.note_chain(&d.gateway_name, note);
                }
            } else {
                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
            }
            return;
        }
        if ty == ek_ctp::EVENT_STATUS_PREFIX {
            // 0.7.0 第3刀:状态变化推送 → 一次到货三件事(文案 / 状态列 / 去重告警)。
            if let Some(ev) = event.data.downcast_ref::<ek_ctp::GatewayStatusEvent>() {
                self.shared.note_status_event(ev);
            } else {
                self.shared
                    .log_line("[eStatus] 载荷类型异常(非 GatewayStatusEvent)");
            }
            return;
        }
        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
                let verb = if ty == EVENT_PLUG_UP {
                    "上线"
                } else {
                    "下线"
                };
                self.shared
                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
                // 在场位与运维窗那一行跟着这条线动(不等 4 拍的快照节拍)。
                self.shared.request_list_refresh();
                if ty == EVENT_PLUG_UP {
                    // 0.5 M3:装配显形改吃这条线 —— 不再靠每拍 list 去发现「谁新上线」。
                    if n.name == ek_oms::NAME {
                        self.shared.request_pipeline_refresh();
                    }
                    // 0.5 执行核查补的第三处:网关 / oms 上线之后重播种一次四张表。
                    // 摘除一个网关账户会作废它名下的委托与成交行,而 oms 那边那些行还在;
                    // 重新添加时不重问一次,界面就永久少一段历史。顺带覆盖「ui 先于 oms
                    // 起来、第一次播种问空了」这一序。
                    let is_account = self.shared.accounts().iter().any(|a| a.gateway == n.name);
                    if n.name == ek_oms::NAME || is_account {
                        self.shared.request_trade_reseed();
                    }
                    // 0.7.0 第3刀:网关上线 → 播种一次初始快照(运行期至多这一发 ask;
                    // 之后登录态/断供全靠 `eStatus.` 推送,界面零轮询)。
                    if is_account {
                        self.shared.request_status_seed(&n.name);
                    }
                    // 0.5 M3:订阅重放同样改吃这条线(与 `sync_accounts_connected` 的
                    // newly 回带是同一件事的两条路,留一条:这条即时、那条要等快照拍)。
                    let md = self.shared.md_source();
                    let subs = self.shared.subscriptions();
                    if !md.is_empty() && n.name == md && !subs.is_empty() {
                        self.shared.push_cmd(crate::state::UiCmd::CtpSubscribe {
                            gateway: n.name.clone(),
                            symbols: subs.clone(),
                        });
                        self.shared.log_line(format!(
                            "[ops] 行情源 {} 上线,重放订阅 {} 项",
                            n.name,
                            subs.len()
                        ));
                    }
                } else {
                    // 0.5 M1:网关下线 = 它名下的委托 / 成交 / 持仓 / 资金行全部作废。
                    // 推送模型里没人再告诉界面「这一行已经不存在了」,不清就是替一个
                    // 已经不存在的会话说话(与 0.2.19「实例不在场就作废会话读数」同口径)。
                    let removed = self.shared.clear_gateway_rows(&n.name);
                    if removed > 0 {
                        self.shared.log_line(format!(
                            "[ui] 插件下线 {}:作废 {} 行交易数据",
                            n.name, removed
                        ));
                    }
                    // 0.5 执行核查补的第二处:摘掉 oms 之后管线读数不会自己清空。
                    // 0.4 是每 4 拍问一次,4 秒内自己就空了;改事件触发后没人再问,
                    // 运维窗那一节会替一个已经不存在的 oms 说话。标记一次:下一拍问一次
                    // → TargetGone 快错 → `poll_pipeline` 的 Err 分支清空并落一条日志。
                    if n.name == ek_oms::NAME {
                        self.shared.request_pipeline_refresh();
                    }
                }
            }
            self.shared.bump();
            return;
        }
        if ty == EVENT_BAR {
            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
                self.shared.market().on_frame(f);
            }
            self.shared.bump();
            return;
        }
        if ty == EVENT_BAR_UPDATE {
            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
                self.shared.market().on_update(b);
            }
            self.shared.bump();
            return;
        }
        if ty == EVENT_TICK {
            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
                self.shared.update_tick(t);
            }
            return;
        }
        if ty == EVENT_CONTRACT {
            // 0.11.0 第4刀(ek-ctp R5):合约流分批到货,一帧 ≤2000 行 → 逐行上屏
            // (合约窗照旧一段段长出来,只是每一拍长一批);单条形继续受理(兼容)。
            if let Some(v) = event
                .data
                .downcast_ref::<Vec<ek_data::contract::ContractData>>()
            {
                for c in v {
                    self.shared.update_contract(c);
                }
            } else if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
                self.shared.update_contract(c);
            }
            return;
        }
        if ty == ek_ctp::EVENT_CONTRACT_FINISH {
            let gw = event
                .data
                .downcast_ref::<String>()
                .cloned()
                .unwrap_or_else(|| "?".to_string());
            self.shared
                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
            return;
        }
        // 0.5 M1 交易四表:后台只把事件推过来,界面自己入账(基线 BaseMonitor.process_event)。
        // 这里不 ask 任何东西,所以入账 worker 永不被后端拖住。
        if ty == W_ORDER {
            match event.data.downcast_ref::<OrderData>() {
                Some(o) => self.shared.note_order(o),
                None => self.shared.note_bad_payload(W_ORDER),
            }
            return;
        }
        if ty == W_TRADE {
            match event.data.downcast_ref::<TradeData>() {
                Some(t) => self.shared.note_trade(t),
                None => self.shared.note_bad_payload(W_TRADE),
            }
            return;
        }
        if ty == W_POSITION {
            match event.data.downcast_ref::<PositionData>() {
                Some(p) => self.shared.note_position(p),
                None => self.shared.note_bad_payload(W_POSITION),
            }
            return;
        }
        if ty == W_ACCOUNT {
            match event.data.downcast_ref::<AccountData>() {
                Some(a) => self.shared.note_funds(a),
                None => self.shared.note_bad_payload(W_ACCOUNT),
            }
            return;
        }
        if ty == EVENT_TIMER {
            self.refresh();
        }
    }
}