ek-ui 0.1.1

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

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

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

/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
pub const BTN_CLEAR_LOGS: &str = "清空日志";
/// 取消待确认按钮标签。
pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
/// 下单提交按钮标签(v4 = Python「委托」钮)。
pub const BTN_SEND_ORDER: &str = "委托";
/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
pub const BTN_CANCEL_ALL: &str = "全撤";
/// 流控查询按钮标签(S5;v4 收纳进运维窗)。
pub const BTN_QUERY_RATE: &str = "流控查询";
/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
pub const BTN_CANCEL_ORDER: &str = "撤单";
/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
pub const BTN_CTP_CONNECT: &str = "连接 CTP";
/// 菜单项:运维(弹运维窗)。
pub const MENU_OPS: &str = "运维";
/// 菜单项:网关(弹 CTP 配置窗)。
pub const MENU_CTP: &str = "网关";
/// 菜单项:帮助。
pub const MENU_HELP: &str = "帮助";
/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
pub const MENU_CONTRACTS: &str = "查询合约";
/// 菜单项 → 运维窗入口钮。
pub const BTN_OPEN_OPS: &str = "打开运维窗";
/// 菜单项 → CTP 窗入口钮。
pub const BTN_OPEN_CTP: &str = "打开 CTP 网关配置窗";
/// 菜单项 → 查询合约窗入口钮。
pub const BTN_OPEN_CONTRACTS: &str = "打开查询合约窗";
/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";

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

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

/// DtNs → HH:MM:SS(UTC;表内时间列统一口径)。
fn fmt_dt(dt: Option<i64>) -> String {
    dt.map(|ns| {
        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
            .format("%H:%M:%S")
            .to_string()
    })
    .unwrap_or_else(|| "-".to_string())
}

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

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

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

    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
    egui::TopBottomPanel::top("market_band")
        .resizable(true)
        .default_height(240.0)
        .show(ctx, |ui| {
            // ScrollArea auto_shrink(false) 吃满面板可用空间(resizable 面板的官方前提)
            egui::ScrollArea::both()
                .id_salt("market_band_scroll")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    render_market_band(ui, shared);
                });
        });

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

    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
    egui::CentralPanel::default().show(ctx, |ui| {
        let avail = ui.available_size();
        let sp = ui.spacing().item_spacing;
        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
        // Frame+ScrollArea 在预留矩形内才吃满。
        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
        ui.horizontal(|ui| {
            group_frame(ui, "grp_topleft", gw, gh, |ui| {
                render_topleft_group(ui, shared);
            });
            group_frame(ui, "grp_trades", gw, gh, |ui| {
                render_trades_group(ui, shared);
            });
        });
        ui.horizontal(|ui| {
            group_frame(ui, "grp_positions", gw, gh, |ui| {
                render_positions_group(ui, shared);
            });
            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
                render_bottomright_group(ui, shared);
            });
        });
    });

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

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

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

fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
    ui.horizontal(|ui| {
        ui.menu_button(MENU_OPS, |ui| {
            if ui.button(BTN_OPEN_OPS).clicked() {
                shared.set_ops_window_open(true);
                ui.close();
            }
        });
        ui.menu_button(MENU_CTP, |ui| {
            if ui.button(BTN_OPEN_CTP).clicked() {
                shared.set_ctp_window_open(true);
                ui.close();
            }
        });
        ui.menu_button(MENU_CONTRACTS, |ui| {
            if ui.button(BTN_OPEN_CONTRACTS).clicked() {
                shared.set_contracts_window_open(true);
                ui.close();
            }
        });
        ui.menu_button(MENU_HELP, |ui| {
            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
        });
    });
}

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

fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("行情带(唯一源 = eTick. 订阅流)");
    let groups = shared.tick_groups();
    if groups.is_empty() {
        ui.label("(等待 eTick 订阅)");
        return;
    }
    // 二级 tab:只列有 tick 的所
    let sel = shared.selected_exchange();
    let active = if groups.iter().any(|(e, _)| *e == sel) {
        sel.clone()
    } else {
        groups[0].0.clone()
    };
    ui.horizontal(|ui| {
        for (ex, _) in &groups {
            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
                shared.set_selected_exchange(ex.clone());
            }
        }
    });
    let ticks = groups
        .iter()
        .find(|(e, _)| *e == active)
        .map(|(_, v)| v.clone())
        .unwrap_or_default();
    let last_time = ticks
        .iter()
        .map(|t| {
            if t.update_time.is_empty() {
                fmt_dt(Some(t.datetime))
            } else {
                t.update_time.clone()
            }
        })
        .max()
        .unwrap_or_else(|| "-".to_string());
    ui.label(format!(
        "tick {} 格 / 最后更新 {last_time}",
        shared.tick_count()
    ));
    egui::Grid::new("tick_grid")
        .striped(true)
        .num_columns(14)
        .show(ui, |ui| {
            for h in [
                "代码",
                "名称",
                "最新价",
                "涨跌幅",
                "成交量",
                "今开",
                "最高",
                "最低",
                "买1价",
                "买1量",
                "卖1价",
                "卖1量",
                "时间",
                "接口",
            ] {
                ui.strong(h);
            }
            ui.end_row();
            for t in &ticks {
                // 代码列 = 行交互钮:单击 = 选中带入下单面板(方案「行单击 = 选中合约」
                // 落地;Python TickMonitor 只接双击,此为 ek 增值),双击弹 K线窗
                let resp = ui.button(t.symbol.clone());
                if resp.clicked() {
                    shared.set_selected_exchange(active.clone());
                    let mut f = shared.order_form();
                    f.symbol = t.symbol.clone();
                    f.exchange = active.clone();
                    shared.set_order_form(f);
                }
                if resp.double_clicked() {
                    shared.open_kline(t.symbol.clone());
                }
                ui.label(if t.name.is_empty() {
                    "-".to_string()
                } else {
                    t.name.clone()
                });
                ui.label(format!("{:.1}", t.last_price));
                ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
                    if t.pct_change < 0.0 {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    },
                ));
                ui.label(format!("{:.0}", t.volume));
                ui.label(format!("{:.1}", t.open_price));
                ui.label(format!("{:.1}", t.high_price));
                ui.label(format!("{:.1}", t.low_price));
                ui.label(
                    egui::RichText::new(format!("{:.1}", t.bid_price_1))
                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                );
                ui.label(
                    egui::RichText::new(format!("{:.0}", t.bid_volume_1))
                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                );
                ui.label(
                    egui::RichText::new(format!("{:.1}", t.ask_price_1))
                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                );
                ui.label(
                    egui::RichText::new(format!("{:.0}", t.ask_volume_1))
                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                );
                ui.label(if t.update_time.is_empty() {
                    fmt_dt(Some(t.datetime))
                } else {
                    t.update_time.clone()
                });
                ui.label(t.gateway_name.clone());
                ui.end_row();
            }
        });
}

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

fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("下单面板");
    let mut f = shared.order_form();
    let before = f.clone();
    let mut err: Option<String> = None;

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

    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
    let mut ex_opts: Vec<String> = shared
        .tick_groups()
        .iter()
        .map(|(e, _)| e.clone())
        .collect();
    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
        if !ex_opts.iter().any(|x| x == e) {
            ex_opts.push(e.to_string());
        }
    }
    if !ex_opts.contains(&f.exchange) {
        ex_opts.insert(0, f.exchange.clone());
    }
    // 接口下拉:trade_snap 网关去重集 ∪ {sim, ctp}(审计钉:不新增总线口)
    let ts = shared.trade_snap();
    let mut gw_opts: Vec<String> = Vec::new();
    for g in ts
        .orders
        .iter()
        .map(|o| o.gateway_name.clone())
        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
    {
        if !g.is_empty() && !gw_opts.contains(&g) {
            gw_opts.push(g);
        }
    }
    for g in ["sim", "ctp"] {
        if !gw_opts.iter().any(|x| x == g) {
            gw_opts.push(g.to_string());
        }
    }
    if !gw_opts.contains(&f.gateway) {
        gw_opts.insert(0, f.gateway.clone());
    }

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

/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
    let f = shared.order_form();
    ui.label(format!(
        "盘口({})",
        if f.symbol.is_empty() {
            "未选合约"
        } else {
            &f.symbol
        }
    ));
    match tick_of(shared, &f.exchange, &f.symbol) {
        Some(t) => {
            for i in (1..=5).rev() {
                let (p, v) = match i {
                    1 => (t.ask_price_1, t.ask_volume_1),
                    2 => (t.ask_price_2, t.ask_volume_2),
                    3 => (t.ask_price_3, t.ask_volume_3),
                    4 => (t.ask_price_4, t.ask_volume_4),
                    _ => (t.ask_price_5, t.ask_volume_5),
                };
                ui.label(
                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
                );
            }
            for i in 1..=5 {
                let (p, v) = match i {
                    1 => (t.bid_price_1, t.bid_volume_1),
                    2 => (t.bid_price_2, t.bid_volume_2),
                    3 => (t.bid_price_3, t.bid_volume_3),
                    4 => (t.bid_price_4, t.bid_volume_4),
                    _ => (t.bid_price_5, t.bid_volume_5),
                };
                ui.label(
                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
                );
            }
        }
        None => {
            ui.label("(无 tick)");
        }
    }
}

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

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

/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
/// 活动行撤单钮 + 双击撤单,终态行只读)。
fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
    let ts = shared.trade_snap();
    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
    let rows: Vec<_> = ts
        .orders
        .iter()
        .filter(|o| !active_only || order_active(o.status))
        .collect();
    if rows.is_empty() {
        ui.label(if active_only {
            "(无未成交)"
        } else {
            "(无委托:等网关 send_order)"
        });
        return;
    }
    egui::Grid::new(if active_only {
        "active_grid"
    } else {
        "order_grid"
    })
    .striped(true)
    .num_columns(17)
    .show(ui, |ui| {
        for h in [
            "代码",
            "名称",
            "交易所",
            "类型",
            "方向",
            "开平",
            "价格",
            "总量",
            "已成",
            "成交均价",
            "状态",
            "状态描述",
            "时间",
            "委托号",
            "来源",
            "接口",
            "操作",
        ] {
            ui.strong(h);
        }
        ui.end_row();
        for o in &rows {
            // 行钮:双击 = 撤单(Python OrderMonitor itemDoubleClicked→cancel_order
            // 等价,活动行限定;显式撤单钮同令并存)。单击无动作(Python 同口径)。
            let resp = ui.button(o.symbol.clone());
            if resp.double_clicked() && order_active(o.status) {
                let gw = if o.gateway_name.is_empty() {
                    "sim".to_string()
                } else {
                    o.gateway_name.clone()
                };
                shared.push_cmd(UiCmd::CancelOrder {
                    orderid: o.orderid.clone(),
                    gateway: gw,
                });
            }
            ui.label(if o.name.is_empty() {
                "-".to_string()
            } else {
                o.name.clone()
            });
            ui.label(o.exchange.as_str().to_string());
            ui.label(o.order_type.as_str().to_string());
            ui.label(
                egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
                    if o.direction == Some(Direction::SHORT) {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    },
                ),
            );
            ui.label(o.offset.as_str().to_string());
            ui.label(format!("{:.1}", o.price));
            ui.label(format!("{:.0}", o.volume));
            ui.label(format!("{:.0}", o.traded));
            ui.label(format!("{:.1}", o.traded_price));
            ui.label(egui::RichText::new(o.status.as_str()).color(
                if o.status == ek_data::constant::Status::REJECTED {
                    egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                } else if o.status == ek_data::constant::Status::ALLTRADED {
                    egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                } else {
                    egui::Color32::from_rgb(0xE0, 0x88, 0x10)
                },
            ));
            ui.label(o.status_msg.clone());
            ui.label(fmt_dt(o.datetime));
            ui.label(o.orderid.clone());
            ui.label(if o.reference.is_empty() {
                "-".to_string()
            } else {
                o.reference.clone()
            });
            ui.label(o.gateway_name.clone());
            ui.horizontal(|ui| {
                if order_active(o.status)
                    && ui
                        .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
                        .clicked()
                {
                    // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 sim)
                    let gw = if o.gateway_name.is_empty() {
                        "sim".to_string()
                    } else {
                        o.gateway_name.clone()
                    };
                    shared.push_cmd(UiCmd::CancelOrder {
                        orderid: o.orderid.clone(),
                        gateway: gw,
                    });
                }
            });
            ui.end_row();
        }
    });
}

/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("成交");
    let ts = shared.trade_snap();
    if ts.trades.is_empty() {
        ui.label("(无成交)");
        return;
    }
    egui::Grid::new("trade_grid")
        .striped(true)
        .num_columns(12)
        .show(ui, |ui| {
            for h in [
                "成交号",
                "代码",
                "名称",
                "交易所",
                "方向",
                "开平",
                "价格",
                "数量",
                "成交额",
                "时间",
                "委托号",
                "接口",
            ] {
                ui.strong(h);
            }
            ui.end_row();
            for t in &ts.trades {
                ui.label(t.tradeid.clone());
                ui.label(t.symbol.clone());
                ui.label(if t.name.is_empty() {
                    "-".to_string()
                } else {
                    t.name.clone()
                });
                ui.label(t.exchange.as_str().to_string());
                ui.label(t.direction.map(|d| d.as_str()).unwrap_or("-").to_string());
                ui.label(t.offset.as_str().to_string());
                ui.label(format!("{:.1}", t.price));
                ui.label(format!("{:.0}", t.volume));
                ui.label(format!("{:.1}", t.traded_amount));
                ui.label(fmt_dt(t.datetime));
                ui.label(t.orderid.clone());
                ui.label(t.gateway_name.clone());
                ui.end_row();
            }
        });
}

/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
/// Python mainwindow.py:276 等价,不开 K线)。
fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("持仓");
    let ts = shared.trade_snap();
    if ts.positions.is_empty() {
        ui.label("(无持仓)");
        return;
    }
    egui::Grid::new("pos_grid")
        .striped(true)
        .num_columns(16)
        .show(ui, |ui| {
            for h in [
                "代码",
                "名称",
                "交易所",
                "方向",
                "数量",
                "昨仓",
                "可用",
                "冻结",
                "在途",
                "市值",
                "成本",
                "均价",
                "最新价",
                "盈亏",
                "盈亏比",
                "接口",
            ] {
                ui.strong(h);
            }
            ui.end_row();
            for p in &ts.positions {
                // 行钮:双击 = 带入下单面板(Python mainwindow.py:276 等价,不开 K线)。
                let resp = ui.button(p.symbol.clone());
                if resp.double_clicked() {
                    let mut f = shared.order_form();
                    f.symbol = p.symbol.clone();
                    f.exchange = p.exchange.as_str().to_string();
                    f.direction = p.direction;
                    shared.set_order_form(f);
                    shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
                }
                ui.label(if p.name.is_empty() {
                    "-".to_string()
                } else {
                    p.name.clone()
                });
                ui.label(p.exchange.as_str().to_string());
                ui.label(egui::RichText::new(p.direction.as_str()).color(
                    if p.direction == ek_data::constant::Direction::SHORT {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    },
                ));
                ui.label(format!("{:.0}", p.volume));
                ui.label(format!("{:.0}", p.yd_volume));
                ui.label(format!("{:.0}", p.can_use_volume));
                ui.label(format!("{:.0}", p.frozen));
                ui.label(format!("{:.0}", p.on_road_volume));
                ui.label(format!("{:.1}", p.market_value));
                ui.label(format!("{:.2}", p.price));
                ui.label(format!("{:.2}", p.avg_price));
                ui.label(format!("{:.2}", p.last_price));
                ui.label(
                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
                    } else {
                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
                    }),
                );
                ui.label(format!("{:+.4}", p.profit_rate));
                ui.label(p.gateway_name.clone());
                ui.end_row();
            }
        });
}

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

fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
    ui.horizontal(|ui| {
        if ui.button(BTN_CLEAR_LOGS).clicked() {
            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
        }
        let (_, spilled) = shared.logs_snapshot();
        if spilled > 0 {
            ui.label(format!(
                "已丢弃 {spilled} 条(环形 {})",
                crate::state::RING_CAP
            ));
        }
    });
    let lines = shared.logs_snapshot().0;
    egui::ScrollArea::vertical()
        .id_salt("log_scroll")
        .max_height(180.0)
        .stick_to_bottom(true)
        .show(ui, |ui| {
            for line in lines {
                ui.monospace(line);
            }
        });
}

// ================= 弹窗面 =================

fn render_windows(ctx: &egui::Context, shared: &UiShared) {
    // 运维窗(菜单「运维」)
    let mut ops_open = shared.ops_window_open();
    egui::Window::new("运维窗")
        .id(egui::Id::new("ops_win"))
        .default_pos([80.0, 320.0])
        .default_size([640.0, 380.0])
        .open(&mut ops_open)
        .show(ctx, |ui| {
            render_ops_window(ui, shared);
        });
    shared.set_ops_window_open(ops_open);

    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面,主窗只当弹窗宿主)
    let mut ctp_open = shared.ctp_window_open();
    egui::Window::new("CTP 网关配置窗")
        .id(egui::Id::new("ctp_win"))
        .default_pos([640.0, 320.0])
        .default_size([560.0, 330.0])
        .open(&mut ctp_open)
        .show(ctx, |ui| {
            render_ctp_window(ui, shared);
        });
    shared.set_ctp_window_open(ctp_open);

    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)
    let mut contracts_open = shared.contracts_window_open();
    egui::Window::new("查询合约窗")
        .id(egui::Id::new("contracts_win"))
        .default_pos([420.0, 300.0])
        .default_size([560.0, 420.0])
        .open(&mut contracts_open)
        .show(ctx, |ui| {
            render_contracts_window(ui, shared);
        });
    shared.set_contracts_window_open(contracts_open);

    // K线窗(双击弹出,按合约多开;窗关闭钮 = 出表)
    let mut closed: Vec<String> = Vec::new();
    for sym in shared.kline_symbols() {
        let resp = egui::Window::new(format!("K线 — {sym}"))
            .id(egui::Id::new(("kline_win", sym.clone())))
            .default_pos([300.0, 340.0])
            .default_size([540.0, 330.0])
            .show(ctx, |ui| {
                let cell = shared
                    .market()
                    .cells()
                    .into_iter()
                    .find(|c| c.symbol == sym);
                render_kline_cell(ui, cell.as_ref());
            });
        if resp.is_none() {
            closed.push(sym);
        }
    }
    for s in closed {
        shared.close_kline(&s);
    }
}

/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
/// + 依赖图 + 流控查询钮。回灌界面面已退役(总线口保留)。
fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("插件");
    let snap = shared.snapshot();
    egui::Grid::new("plug_grid")
        .striped(true)
        .num_columns(6)
        .show(ui, |ui| {
            ui.strong("名称");
            ui.strong("状态");
            ui.strong("版本");
            ui.strong("依赖");
            ui.strong("统计");
            ui.strong("操作");
            ui.end_row();
            if snap.rows.is_empty() {
                ui.label("(快照空:等 eTimer 首拉)");
                ui.end_row();
            }
            for r in &snap.rows {
                if is_quarantined(&r.state) {
                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
                    ui.label(
                        egui::RichText::new(state_text(r))
                            .color(egui::Color32::RED)
                            .strong(),
                    );
                } else {
                    ui.label(r.name.clone());
                    ui.label(state_text(r));
                }
                ui.label(r.version.clone());
                ui.label(format!("deps:[{}]", r.deps.join(",")));
                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
                ui.horizontal(|ui| {
                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
                        shared.push_cmd(UiCmd::Start {
                            factory: r.name.clone(),
                        });
                    }
                    let armed = shared.pending_confirm();
                    let armed_here = ui
                        .small_button(remove_button_label(&r.name, armed.as_deref()))
                        .clicked()
                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
                    if armed_here {
                        shared.push_cmd(UiCmd::Remove {
                            name: r.name.clone(),
                        });
                    }
                });
                ui.end_row();
            }
        });

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

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

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

    ui.horizontal(|ui| {
        if ui.small_button(BTN_QUERY_RATE).clicked() {
            shared.push_cmd(UiCmd::QueryRate);
        }
    });
}

/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
    let mut form: crate::state::CtpForm = shared.ctp_form();
    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
        ui.label("broker");
        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
        ui.label("td 前置");
        ctp_edit(
            ui,
            shared,
            &mut form.td_addr,
            "tcp://host:40001",
            CtpField::TdAddr,
        );
        ui.label("td 用户");
        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
        ui.end_row();
        ui.label("td 密码");
        ctp_edit(
            ui,
            shared,
            &mut form.td_password,
            "密码",
            CtpField::TdPassword,
        );
        ui.label("AppID");
        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
        ui.label("AuthCode");
        ctp_edit(
            ui,
            shared,
            &mut form.td_auth_code,
            "授权码",
            CtpField::AuthCode,
        );
        ui.end_row();
        ui.label("md 前置");
        ctp_edit(
            ui,
            shared,
            &mut form.md_addr,
            "tcp://host:40011",
            CtpField::MdAddr,
        );
        ui.label("md 用户");
        ctp_edit(
            ui,
            shared,
            &mut form.md_user,
            "空=td 回落",
            CtpField::MdUser,
        );
        ui.label("md 密码");
        ctp_edit(
            ui,
            shared,
            &mut form.md_password,
            "空=td 回落",
            CtpField::MdPassword,
        );
        ui.end_row();
    });
    ui.horizontal(|ui| {
        if ui.small_button(BTN_CTP_CONNECT).clicked() {
            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
        }
        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
    });
}

/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
    let mut q = shared.contract_query();
    ui.horizontal(|ui| {
        ui.label("代码或交易所(留空 = 全部):");
        if ui
            .add(
                egui::TextEdit::singleline(&mut q)
                    .hint_text("cu 或 SHFE")
                    .desired_width(200.0),
            )
            .changed()
        {
            shared.set_contract_query(q.clone());
        }
    });
    let rows = shared.contracts(&q);
    if rows.is_empty() {
        ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
        return;
    }
    let selected = shared.selected_contracts();
    egui::ScrollArea::vertical()
        .id_salt("contract_scroll")
        .max_height(260.0)
        .auto_shrink([false, false])
        .show(ui, |ui| {
            egui::Grid::new("contract_grid")
                .striped(true)
                .num_columns(5)
                .show(ui, |ui| {
                    for h in ["选(代码)", "交易所", "名称", "乘数", "最小变动"] {
                        ui.strong(h);
                    }
                    ui.end_row();
                    for c in &rows {
                        let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
                        let mut on = selected.contains(&key);
                        // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)
                        if ui.checkbox(&mut on, key.clone()).changed() {
                            shared.toggle_contract_selection(&key);
                        }
                        ui.label(c.exchange.as_str().to_string());
                        ui.label(c.name.clone());
                        ui.label(format!("{:.0}", c.size));
                        ui.label(format!("{:.4}", c.price_tick));
                        ui.end_row();
                    }
                });
        });
    ui.horizontal(|ui| {
        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
            if selected.is_empty() {
                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
            } else {
                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
                let symbols: Vec<String> = selected
                    .iter()
                    .map(|k| {
                        k.split_once('.')
                            .map(|(_, s)| s.to_string())
                            .unwrap_or_else(|| k.clone())
                    })
                    .collect();
                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
                shared.log_line(format!(
                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
                    selected.len()
                ));
            }
        }
    });
}

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

/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
    let is_secret = matches!(
        field,
        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
    );
    let mut w = egui::TextEdit::singleline(val)
        .hint_text(hint)
        .desired_width(110.0);
    if is_secret {
        w = w.password(true);
    }
    if ui.add(w).changed() {
        let mut f = shared.ctp_form();
        match field {
            CtpField::Broker => f.broker_id = val.clone(),
            CtpField::TdAddr => f.td_addr = val.clone(),
            CtpField::TdUser => f.td_user = val.clone(),
            CtpField::TdPassword => f.td_password = val.clone(),
            CtpField::AppId => f.td_app_id = val.clone(),
            CtpField::AuthCode => f.td_auth_code = val.clone(),
            CtpField::MdAddr => f.md_addr = val.clone(),
            CtpField::MdUser => f.md_user = val.clone(),
            CtpField::MdPassword => f.md_password = val.clone(),
        }
        shared.set_ctp_form(f);
    }
}

/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
///
/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
    let Some(c) = cell else {
        ui.label("(K线无格:等 dm 合成)");
        return;
    };
    if c.bars.is_empty() {
        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
        return;
    }
    let mut data = egui_charts::model::bar::BarData::new();
    for b in &c.bars {
        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
        data.push(egui_charts::model::bar::Bar::new(
            dt,
            b.open_price,
            b.high_price,
            b.low_price,
            b.close_price,
            b.volume,
        ));
    }
    ui.label(format!(
        "K线 {}@{}s({} 根)",
        c.symbol,
        c.interval,
        c.bars.len()
    ));
    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
    chart.set_symbol(&c.symbol);
    chart.set_timeframe_label(&format!("{}s", c.interval));
    chart.enable_tracking_mode();
    let _ = chart.show(ui);
}