Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v5(BD-UI-29 面板化,官方面板组合;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单侧) →
5//! CentralPanel(2×2 监控格) → Window 弹窗**,两带物理分离。
6//! - 行情带(TopBottomPanel::resizable = Python 分带 splitter 语义,缺省 240):
7//!   交易所二级 tab(只列有 tick 的所)+ tick 表(唯一源 = `eTick.`
8//!   订阅流,不从 bar 回);行单击 = 带入下单面板(ek 增值,方案钉),行双击 =
9//!   弹 K线窗。
10//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
11//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
12//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
13//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
14//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
15//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
16//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
17//!   D7 口径)。
18//! - 弹窗(egui::Window,依附主窗,kittest 可断言):运维窗(菜单「运维」:
19//!   插件/服务/依赖/流控查询)、CTP 配置窗(菜单「网关」,CTP 插件自己的
20//!   界面)、查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python
21//!   ContractManager 等价)、K线窗(双击弹出,按合约多开)。
22//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
23//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
24//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
25//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
26//!
27//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
28//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
29//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
30
31use ek_data::constant::{Direction, Offset, OrderType};
32use ek_station::wire::PlugRow;
33
34use crate::state::{
35    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
36};
37
38/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
39pub const BTN_CLEAR_LOGS: &str = "清空日志";
40/// 取消待确认按钮标签。
41pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
42/// 下单提交按钮标签(v4 = Python「委托」钮)。
43pub const BTN_SEND_ORDER: &str = "委托";
44/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
45pub const BTN_CANCEL_ALL: &str = "全撤";
46/// 流控查询按钮标签(S5;v4 收纳进运维窗)。
47pub const BTN_QUERY_RATE: &str = "流控查询";
48/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
49pub const BTN_CANCEL_ORDER: &str = "撤单";
50/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
51pub const BTN_CTP_CONNECT: &str = "连接 CTP";
52/// 菜单项:运维(弹运维窗)。
53pub const MENU_OPS: &str = "运维";
54/// 菜单项:网关(弹 CTP 配置窗)。
55pub const MENU_CTP: &str = "网关";
56/// 菜单项:帮助。
57pub const MENU_HELP: &str = "帮助";
58/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
59pub const MENU_CONTRACTS: &str = "查询合约";
60/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
61/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
62/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
63pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
64
65fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
66    if armed_on == Some(name) {
67        format!("确认移除 {name} ?")
68    } else {
69        format!("移除 {name}")
70    }
71}
72
73fn state_text(r: &PlugRow) -> String {
74    r.state.clone()
75}
76
77/// DtNs → HH:MM:SS(UTC;表内时间列统一口径)。
78fn fmt_dt(dt: Option<i64>) -> String {
79    dt.map(|ns| {
80        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
81            .format("%H:%M:%S")
82            .to_string()
83    })
84    .unwrap_or_else(|| "-".to_string())
85}
86
87// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
88
89/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
90/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
91/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
92fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
93    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
94        return;
95    };
96    let hovered = ui
97        .input(|i| i.pointer.interact_pos())
98        .is_some_and(|p| union.expand(3.0).contains(p));
99    if selected {
100        ui.painter().rect_filled(
101            union.expand(3.0),
102            3.0,
103            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
104        );
105    } else if hovered {
106        ui.painter().rect_filled(
107            union.expand(3.0),
108            3.0,
109            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
110        );
111    }
112}
113
114/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
115fn order_active(status: ek_data::constant::Status) -> bool {
116    ek_data::constant::ACTIVE_STATUSES.contains(&status)
117}
118
119/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
120/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
121fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
122    if symbol.is_empty() {
123        return None;
124    }
125    shared.tick_rows().into_iter().find(|t| {
126        t.symbol == symbol
127            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
128    })
129}
130
131/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
132///
133/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
134/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
135/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
136/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
137/// Window 挂在所有面板之后。
138pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
139    // ---- 菜单栏(顶层面板,最先 = 最外) ----
140    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
141        render_menu_bar(ui, shared);
142    });
143
144    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
145    egui::TopBottomPanel::top("market_band")
146        .resizable(true)
147        .default_height(240.0)
148        .show(ctx, |ui| {
149            // ScrollArea auto_shrink(false) 吃满面板可用空间(resizable 面板的官方前提)
150            egui::ScrollArea::both()
151                .id_salt("market_band_scroll")
152                .auto_shrink([false, false])
153                .show(ui, |ui| {
154                    render_market_band(ui, shared);
155                });
156        });
157
158    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
159    egui::SidePanel::left("order_side")
160        .resizable(true)
161        .default_width(250.0)
162        .show(ctx, |ui| {
163            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
164            egui::ScrollArea::vertical()
165                .id_salt("order_side_scroll")
166                .auto_shrink([false, false])
167                .show(ui, |ui| {
168                    render_order_panel(ui, shared);
169                    ui.separator();
170                    render_book_frame(ui, shared);
171                });
172        });
173
174    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
175    egui::CentralPanel::default().show(ctx, |ui| {
176        let avail = ui.available_size();
177        let sp = ui.spacing().item_spacing;
178        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
179        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
180        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
181        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
182        // Frame+ScrollArea 在预留矩形内才吃满。
183        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
184        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
185        ui.horizontal(|ui| {
186            group_frame(ui, "grp_topleft", gw, gh, |ui| {
187                render_topleft_group(ui, shared);
188            });
189            group_frame(ui, "grp_trades", gw, gh, |ui| {
190                render_trades_group(ui, shared);
191            });
192        });
193        ui.horizontal(|ui| {
194            group_frame(ui, "grp_positions", gw, gh, |ui| {
195                render_positions_group(ui, shared);
196            });
197            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
198                render_bottomright_group(ui, shared);
199            });
200        });
201    });
202
203    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
204    render_windows(ctx, shared);
205}
206
207/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
208/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
209/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
210/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
211/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
212/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
213/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
214fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
215    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
216    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
217    egui::Frame::group(ui.style()).show(&mut child, |ui| {
218        egui::ScrollArea::both()
219            .id_salt(id)
220            .auto_shrink([false, false])
221            .show(ui, |ui| {
222                ui.vertical(content);
223            });
224    });
225}
226
227// ================= 菜单栏 =================
228
229fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
230    ui.horizontal(|ui| {
231        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
232        if ui.button(MENU_OPS).clicked() {
233            shared.set_ops_window_open(true);
234        }
235        if ui.button(MENU_CTP).clicked() {
236            shared.set_ctp_window_open(true);
237        }
238        if ui.button(MENU_CONTRACTS).clicked() {
239            shared.set_contracts_window_open(true);
240        }
241        ui.menu_button(MENU_HELP, |ui| {
242            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
243            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
244        });
245    });
246}
247
248// ================= 行情带 =================
249
250fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
251    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
252    let groups = shared.tick_groups();
253    if groups.is_empty() {
254        ui.label("(等待 eTick 订阅)");
255        return;
256    }
257    // 二级 tab:只列有 tick 的所
258    let sel = shared.selected_exchange();
259    let active = if groups.iter().any(|(e, _)| *e == sel) {
260        sel.clone()
261    } else {
262        groups[0].0.clone()
263    };
264    ui.horizontal(|ui| {
265        for (ex, _) in &groups {
266            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
267                shared.set_selected_exchange(ex.clone());
268            }
269        }
270    });
271    let ticks = groups
272        .iter()
273        .find(|(e, _)| *e == active)
274        .map(|(_, v)| v.clone())
275        .unwrap_or_default();
276    let last_time = ticks
277        .iter()
278        .map(|t| {
279            if t.update_time.is_empty() {
280                fmt_dt(Some(t.datetime))
281            } else {
282                t.update_time.clone()
283            }
284        })
285        .max()
286        .unwrap_or_else(|| "-".to_string());
287    ui.label(format!(
288        "tick {} 格 / 最后更新 {last_time}",
289        shared.tick_count()
290    ));
291    egui::Grid::new("tick_grid")
292        .striped(true)
293        .num_columns(14)
294        .show(ui, |ui| {
295            for h in [
296                "代码",
297                "名称",
298                "最新价",
299                "涨跌幅",
300                "成交量",
301                "今开",
302                "最高",
303                "最低",
304                "买1价",
305                "买1量",
306                "卖1价",
307                "卖1量",
308                "时间",
309                "接口",
310            ] {
311                ui.strong(h);
312            }
313            ui.end_row();
314            for t in &ticks {
315                // 代码列 = 行交互钮:单击 = 选中带入下单面板(方案「行单击 = 选中合约」
316                // 落地;Python TickMonitor 只接双击,此为 ek 增值),双击弹 K线窗。
317                // BD-UI-31(R3):选中行 = 下单面板当前合约(带入即跳色,无需另存态)。
318                let row_sel = shared.order_form().symbol == t.symbol;
319                let mut cells = Vec::with_capacity(14);
320                let resp = ui.button(t.symbol.clone());
321                if resp.clicked() {
322                    shared.set_selected_exchange(active.clone());
323                    let mut f = shared.order_form();
324                    f.symbol = t.symbol.clone();
325                    f.exchange = active.clone();
326                    shared.set_order_form(f);
327                }
328                if resp.double_clicked() {
329                    shared.open_kline(t.symbol.clone());
330                }
331                cells.push(resp.rect);
332                cells.push(
333                    ui.label(if t.name.is_empty() {
334                        "-".to_string()
335                    } else {
336                        t.name.clone()
337                    })
338                    .rect,
339                );
340                cells.push(ui.label(format!("{:.1}", t.last_price)).rect);
341                cells.push(
342                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
343                        if t.pct_change < 0.0 {
344                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
345                        } else {
346                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
347                        },
348                    ))
349                    .rect,
350                );
351                cells.push(ui.label(format!("{:.0}", t.volume)).rect);
352                cells.push(ui.label(format!("{:.1}", t.open_price)).rect);
353                cells.push(ui.label(format!("{:.1}", t.high_price)).rect);
354                cells.push(ui.label(format!("{:.1}", t.low_price)).rect);
355                cells.push(
356                    ui.label(
357                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
358                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
359                    )
360                    .rect,
361                );
362                cells.push(
363                    ui.label(
364                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
365                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
366                    )
367                    .rect,
368                );
369                cells.push(
370                    ui.label(
371                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
372                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
373                    )
374                    .rect,
375                );
376                cells.push(
377                    ui.label(
378                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
379                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
380                    )
381                    .rect,
382                );
383                cells.push(
384                    ui.label(if t.update_time.is_empty() {
385                        fmt_dt(Some(t.datetime))
386                    } else {
387                        t.update_time.clone()
388                    })
389                    .rect,
390                );
391                cells.push(ui.label(t.gateway_name.clone()).rect);
392                paint_row_bg(ui, &cells, row_sel);
393                ui.end_row();
394            }
395        });
396}
397
398// ================= 下单面板(常驻) =================
399
400fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
401    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
402    let mut f = shared.order_form();
403    let before = f.clone();
404    let mut err: Option<String> = None;
405
406    // 随价勾选:价格框随该合约最新 tick(勾选态)
407    if f.follow_price {
408        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
409            if t.last_price > 0.0 {
410                f.price = format!("{:.1}", t.last_price);
411            }
412        }
413    }
414
415    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
416    let mut ex_opts: Vec<String> = shared
417        .tick_groups()
418        .iter()
419        .map(|(e, _)| e.clone())
420        .collect();
421    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
422        if !ex_opts.iter().any(|x| x == e) {
423            ex_opts.push(e.to_string());
424        }
425    }
426    if !ex_opts.contains(&f.exchange) {
427        ex_opts.insert(0, f.exchange.clone());
428    }
429    // 接口下拉:trade_snap 网关去重集 ∪ {sim, ctp}(审计钉:不新增总线口)
430    let ts = shared.trade_snap();
431    let mut gw_opts: Vec<String> = Vec::new();
432    for g in ts
433        .orders
434        .iter()
435        .map(|o| o.gateway_name.clone())
436        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
437        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
438    {
439        if !g.is_empty() && !gw_opts.contains(&g) {
440            gw_opts.push(g);
441        }
442    }
443    for g in ["sim", "ctp"] {
444        if !gw_opts.iter().any(|x| x == g) {
445            gw_opts.push(g.to_string());
446        }
447    }
448    if !gw_opts.contains(&f.gateway) {
449        gw_opts.insert(0, f.gateway.clone());
450    }
451
452    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
453    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
454    egui::Grid::new("order_form_grid")
455        .num_columns(2)
456        .show(ui, |ui| {
457            ui.label("交易所");
458            egui::ComboBox::from_id_salt("of_exchange")
459                .selected_text(f.exchange.clone())
460                .show_ui(ui, |ui| {
461                    for e in &ex_opts {
462                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
463                    }
464                });
465            ui.end_row();
466            ui.label("代码");
467            let sym_resp = ui.add(
468                egui::TextEdit::singleline(&mut f.symbol)
469                    .hint_text("cu2709")
470                    .desired_width(150.0),
471            );
472            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
473            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
474            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
475            // has_focus 口径回车订阅从不触发)。
476            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
477                let s = f.symbol.trim().to_string();
478                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
479                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
480                let bare = match s.split_once('.') {
481                    Some((_, b)) => b.trim().to_string(),
482                    None => s,
483                };
484                if !bare.is_empty() {
485                    f.symbol = bare.clone();
486                    shared.push_cmd(UiCmd::CtpSubscribe {
487                        symbols: vec![bare.clone()],
488                    });
489                    shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
490                }
491            }
492            ui.end_row();
493            ui.label("名称");
494            ui.label(
495                tick_of(shared, &f.exchange, &f.symbol)
496                    .map(|t| {
497                        if t.name.is_empty() {
498                            "-".to_string()
499                        } else {
500                            t.name
501                        }
502                    })
503                    .unwrap_or_else(|| "-".to_string()),
504            );
505            ui.end_row();
506            ui.label("方向");
507            egui::ComboBox::from_id_salt("of_direction")
508                .selected_text(f.direction.as_str())
509                .show_ui(ui, |ui| {
510                    for d in [Direction::LONG, Direction::SHORT] {
511                        ui.selectable_value(&mut f.direction, d, d.as_str());
512                    }
513                });
514            ui.end_row();
515            ui.label("开平");
516            ui.horizontal(|ui| {
517                egui::ComboBox::from_id_salt("of_offset")
518                    .selected_text(f.offset.as_str())
519                    .show_ui(ui, |ui| {
520                        for o in [
521                            Offset::OPEN,
522                            Offset::CLOSE,
523                            Offset::CLOSETODAY,
524                            Offset::CLOSEYESTERDAY,
525                        ] {
526                            ui.selectable_value(&mut f.offset, o, o.as_str());
527                        }
528                    });
529                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
530                ui.checkbox(&mut f.auto_split, "自动开平");
531            });
532            ui.end_row();
533            ui.label("类型");
534            egui::ComboBox::from_id_salt("of_order_type")
535                .selected_text(f.order_type.as_str())
536                .show_ui(ui, |ui| {
537                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
538                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
539                    }
540                });
541            ui.end_row();
542            ui.label("价格");
543            ui.horizontal(|ui| {
544                ui.add(
545                    egui::TextEdit::singleline(&mut f.price)
546                        .hint_text("107660")
547                        .desired_width(90.0),
548                );
549                ui.checkbox(&mut f.follow_price, "随价");
550            });
551            ui.end_row();
552            ui.label("数量");
553            ui.add(
554                egui::TextEdit::singleline(&mut f.volume)
555                    .hint_text("1")
556                    .desired_width(90.0),
557            );
558            ui.end_row();
559            ui.label("接口");
560            egui::ComboBox::from_id_salt("of_gateway")
561                .selected_text(if f.gateway.is_empty() {
562                    "sim".to_string()
563                } else {
564                    f.gateway.clone()
565                })
566                .show_ui(ui, |ui| {
567                    for g in &gw_opts {
568                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
569                    }
570                });
571            ui.end_row();
572            ui.label("操作");
573            ui.horizontal(|ui| {
574                if ui.button(BTN_SEND_ORDER).clicked() {
575                    match f.to_request() {
576                        Ok(req) => match crate::state::parse_gateway(&f.gateway) {
577                            Ok(gateway) => {
578                                shared.push_cmd(UiCmd::SendOrder {
579                                    req,
580                                    gateway,
581                                    mode: if f.auto_split {
582                                        ek_oms::LockMode::Auto
583                                    } else {
584                                        ek_oms::LockMode::None
585                                    },
586                                });
587                                shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
588                            }
589                            Err(e) => {
590                                shared.log_line(format!("[下单] {e}"));
591                                err = Some(e);
592                            }
593                        },
594                        Err(e) => {
595                            shared.log_line(format!("[下单] {e}"));
596                            err = Some(e);
597                        }
598                    }
599                }
600                if ui.button(BTN_CANCEL_ALL).clicked() {
601                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
602                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
603                    let mut n = 0usize;
604                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
605                        let gw = if o.gateway_name.is_empty() {
606                            "sim".to_string()
607                        } else {
608                            o.gateway_name.clone()
609                        };
610                        shared.push_cmd(UiCmd::CancelOrder {
611                            orderid: o.orderid.clone(),
612                            gateway: gw,
613                        });
614                        n += 1;
615                    }
616                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
617                }
618            });
619            ui.end_row();
620            ui.label("代码回车 = 订阅");
621            ui.label("");
622            ui.end_row();
623        });
624    if let Some(e) = err {
625        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
626    }
627    if f != before {
628        shared.set_order_form(f.clone());
629    }
630}
631
632/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
633/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
634/// 行情带跳色行指认,盘口帧不重复报幕)。
635fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
636    let f = shared.order_form();
637    match tick_of(shared, &f.exchange, &f.symbol) {
638        Some(t) => {
639            for i in (1..=5).rev() {
640                let (p, v) = match i {
641                    1 => (t.ask_price_1, t.ask_volume_1),
642                    2 => (t.ask_price_2, t.ask_volume_2),
643                    3 => (t.ask_price_3, t.ask_volume_3),
644                    4 => (t.ask_price_4, t.ask_volume_4),
645                    _ => (t.ask_price_5, t.ask_volume_5),
646                };
647                ui.label(
648                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
649                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
650                );
651            }
652            for i in 1..=5 {
653                let (p, v) = match i {
654                    1 => (t.bid_price_1, t.bid_volume_1),
655                    2 => (t.bid_price_2, t.bid_volume_2),
656                    3 => (t.bid_price_3, t.bid_volume_3),
657                    4 => (t.bid_price_4, t.bid_volume_4),
658                    _ => (t.bid_price_5, t.bid_volume_5),
659                };
660                ui.label(
661                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
662                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
663                );
664            }
665        }
666        None => {
667            ui.label("(无 tick)");
668        }
669    }
670}
671
672// ================= 2×2 监控格 =================
673
674fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
675    let tab = shared.topleft_tab();
676    ui.horizontal(|ui| {
677        if ui
678            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
679            .clicked()
680        {
681            shared.set_topleft_tab(TopLeftTab::Orders);
682        }
683        if ui
684            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
685            .clicked()
686        {
687            shared.set_topleft_tab(TopLeftTab::Active);
688        }
689    });
690    render_orders_table(ui, shared, tab == TopLeftTab::Active);
691}
692
693/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
694/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
695/// 活动行撤单钮 + 双击撤单,终态行只读)。
696fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
697    let ts = shared.trade_snap();
698    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
699    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
700    let rows: Vec<_> = ts
701        .orders
702        .iter()
703        .filter(|o| !active_only || order_active(o.status))
704        .collect();
705    if rows.is_empty() {
706        ui.label(if active_only {
707            "(无未成交)"
708        } else {
709            "(无委托:等网关 send_order)"
710        });
711        return;
712    }
713    egui::Grid::new(if active_only {
714        "active_grid"
715    } else {
716        "order_grid"
717    })
718    .striped(true)
719    .num_columns(17)
720    .show(ui, |ui| {
721        for h in [
722            "代码",
723            "名称",
724            "交易所",
725            "类型",
726            "方向",
727            "开平",
728            "价格",
729            "总量",
730            "已成",
731            "成交均价",
732            "状态",
733            "状态描述",
734            "时间",
735            "委托号",
736            "来源",
737            "接口",
738            "操作",
739        ] {
740            ui.strong(h);
741        }
742        ui.end_row();
743        for o in &rows {
744            // 行钮(BD-UI-31 R3):单击 = 选中行(跳色);双击 = 撤单(Python
745            // OrderMonitor itemDoubleClicked→cancel_order 等价,活动行限定;
746            // 显式撤单钮同令并存。双击先经 clicked,选中顺带发生,无冲突)。
747            let key = format!("{}.{}", o.gateway_name, o.orderid);
748            let row_sel = shared.row_selected("orders", &key);
749            let mut cells = Vec::with_capacity(17);
750            let sel = ui.selectable_label(row_sel, o.symbol.clone());
751            if sel.clicked() {
752                shared.select_row("orders", &key);
753            }
754            if sel.double_clicked() && order_active(o.status) {
755                let gw = if o.gateway_name.is_empty() {
756                    "sim".to_string()
757                } else {
758                    o.gateway_name.clone()
759                };
760                shared.push_cmd(UiCmd::CancelOrder {
761                    orderid: o.orderid.clone(),
762                    gateway: gw,
763                });
764            }
765            cells.push(sel.rect);
766            cells.push(
767                ui.label(if o.name.is_empty() {
768                    "-".to_string()
769                } else {
770                    o.name.clone()
771                })
772                .rect,
773            );
774            cells.push(ui.label(o.exchange.as_str().to_string()).rect);
775            cells.push(ui.label(o.order_type.as_str().to_string()).rect);
776            cells.push(
777                ui.label(
778                    egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
779                        if o.direction == Some(Direction::SHORT) {
780                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
781                        } else {
782                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
783                        },
784                    ),
785                )
786                .rect,
787            );
788            cells.push(ui.label(o.offset.as_str().to_string()).rect);
789            cells.push(ui.label(format!("{:.1}", o.price)).rect);
790            cells.push(ui.label(format!("{:.0}", o.volume)).rect);
791            cells.push(ui.label(format!("{:.0}", o.traded)).rect);
792            cells.push(ui.label(format!("{:.1}", o.traded_price)).rect);
793            cells.push(
794                ui.label(egui::RichText::new(o.status.as_str()).color(
795                    if o.status == ek_data::constant::Status::REJECTED {
796                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
797                    } else if o.status == ek_data::constant::Status::ALLTRADED {
798                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
799                    } else {
800                        egui::Color32::from_rgb(0xE0, 0x88, 0x10)
801                    },
802                ))
803                .rect,
804            );
805            cells.push(ui.label(o.status_msg.clone()).rect);
806            cells.push(ui.label(fmt_dt(o.datetime)).rect);
807            cells.push(ui.label(o.orderid.clone()).rect);
808            cells.push(
809                ui.label(if o.reference.is_empty() {
810                    "-".to_string()
811                } else {
812                    o.reference.clone()
813                })
814                .rect,
815            );
816            cells.push(ui.label(o.gateway_name.clone()).rect);
817            let hr = ui.horizontal(|ui| {
818                if order_active(o.status)
819                    && ui
820                        .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
821                        .clicked()
822                {
823                    // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 sim)
824                    let gw = if o.gateway_name.is_empty() {
825                        "sim".to_string()
826                    } else {
827                        o.gateway_name.clone()
828                    };
829                    shared.push_cmd(UiCmd::CancelOrder {
830                        orderid: o.orderid.clone(),
831                        gateway: gw,
832                    });
833                }
834            });
835            cells.push(hr.response.rect);
836            paint_row_bg(ui, &cells, row_sel);
837            ui.end_row();
838        }
839    });
840}
841
842/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
843fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
844    ui.label("成交");
845    let ts = shared.trade_snap();
846    if ts.trades.is_empty() {
847        ui.label("(无成交)");
848        return;
849    }
850    egui::Grid::new("trade_grid")
851        .striped(true)
852        .num_columns(12)
853        .show(ui, |ui| {
854            for h in [
855                "成交号",
856                "代码",
857                "名称",
858                "交易所",
859                "方向",
860                "开平",
861                "价格",
862                "数量",
863                "成交额",
864                "时间",
865                "委托号",
866                "接口",
867            ] {
868                ui.strong(h);
869            }
870            ui.end_row();
871            for t in &ts.trades {
872                // 行钮(BD-UI-31 R3):单击 = 选中行;方向列上色(R2,与委托/
873                // 持仓表同口径:空绿/多红)。
874                let key = format!("{}.{}", t.gateway_name, t.tradeid);
875                let row_sel = shared.row_selected("trades", &key);
876                let mut cells = Vec::with_capacity(12);
877                let sel = ui.selectable_label(row_sel, t.tradeid.clone());
878                if sel.clicked() {
879                    shared.select_row("trades", &key);
880                }
881                cells.push(sel.rect);
882                cells.push(ui.label(t.symbol.clone()).rect);
883                cells.push(
884                    ui.label(if t.name.is_empty() {
885                        "-".to_string()
886                    } else {
887                        t.name.clone()
888                    })
889                    .rect,
890                );
891                cells.push(ui.label(t.exchange.as_str().to_string()).rect);
892                cells.push(
893                    ui.label(
894                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
895                            if t.direction == Some(Direction::SHORT) {
896                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
897                            } else {
898                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
899                            },
900                        ),
901                    )
902                    .rect,
903                );
904                cells.push(ui.label(t.offset.as_str().to_string()).rect);
905                cells.push(ui.label(format!("{:.1}", t.price)).rect);
906                cells.push(ui.label(format!("{:.0}", t.volume)).rect);
907                cells.push(ui.label(format!("{:.1}", t.traded_amount)).rect);
908                cells.push(ui.label(fmt_dt(t.datetime)).rect);
909                cells.push(ui.label(t.orderid.clone()).rect);
910                cells.push(ui.label(t.gateway_name.clone()).rect);
911                paint_row_bg(ui, &cells, row_sel);
912                ui.end_row();
913            }
914        });
915}
916
917/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
918/// Python mainwindow.py:276 等价,不开 K线)。
919fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
920    ui.label("持仓");
921    let ts = shared.trade_snap();
922    if ts.positions.is_empty() {
923        ui.label("(无持仓)");
924        return;
925    }
926    egui::Grid::new("pos_grid")
927        .striped(true)
928        .num_columns(16)
929        .show(ui, |ui| {
930            for h in [
931                "代码",
932                "名称",
933                "交易所",
934                "方向",
935                "数量",
936                "昨仓",
937                "可用",
938                "冻结",
939                "在途",
940                "市值",
941                "成本",
942                "均价",
943                "最新价",
944                "盈亏",
945                "盈亏比",
946                "接口",
947            ] {
948                ui.strong(h);
949            }
950            ui.end_row();
951            for p in &ts.positions {
952                // 行钮(BD-UI-31 R3):单击 = 选中行(跳色);双击 = 带入下单面板
953                // (Python mainwindow.py:276 等价,不开 K线)。
954                let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
955                let row_sel = shared.row_selected("positions", &key);
956                let mut cells = Vec::with_capacity(16);
957                let sel = ui.selectable_label(row_sel, p.symbol.clone());
958                if sel.clicked() {
959                    shared.select_row("positions", &key);
960                }
961                if sel.double_clicked() {
962                    let mut f = shared.order_form();
963                    f.symbol = p.symbol.clone();
964                    f.exchange = p.exchange.as_str().to_string();
965                    f.direction = p.direction;
966                    shared.set_order_form(f);
967                    shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
968                }
969                cells.push(sel.rect);
970                cells.push(
971                    ui.label(if p.name.is_empty() {
972                        "-".to_string()
973                    } else {
974                        p.name.clone()
975                    })
976                    .rect,
977                );
978                cells.push(ui.label(p.exchange.as_str().to_string()).rect);
979                cells.push(
980                    ui.label(egui::RichText::new(p.direction.as_str()).color(
981                        if p.direction == ek_data::constant::Direction::SHORT {
982                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
983                        } else {
984                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
985                        },
986                    ))
987                    .rect,
988                );
989                cells.push(ui.label(format!("{:.0}", p.volume)).rect);
990                cells.push(ui.label(format!("{:.0}", p.yd_volume)).rect);
991                cells.push(ui.label(format!("{:.0}", p.can_use_volume)).rect);
992                cells.push(ui.label(format!("{:.0}", p.frozen)).rect);
993                cells.push(ui.label(format!("{:.0}", p.on_road_volume)).rect);
994                cells.push(ui.label(format!("{:.1}", p.market_value)).rect);
995                cells.push(ui.label(format!("{:.2}", p.price)).rect);
996                cells.push(ui.label(format!("{:.2}", p.avg_price)).rect);
997                cells.push(ui.label(format!("{:.2}", p.last_price)).rect);
998                cells.push(
999                    ui.label(
1000                        egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1001                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1002                        } else {
1003                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1004                        }),
1005                    )
1006                    .rect,
1007                );
1008                cells.push(ui.label(format!("{:+.4}", p.profit_rate)).rect);
1009                cells.push(ui.label(p.gateway_name.clone()).rect);
1010                paint_row_bg(ui, &cells, row_sel);
1011                ui.end_row();
1012            }
1013        });
1014}
1015
1016fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1017    let tab = shared.bottomright_tab();
1018    ui.horizontal(|ui| {
1019        if ui
1020            .selectable_label(
1021                tab == BottomRightTab::Account,
1022                BottomRightTab::Account.label(),
1023            )
1024            .clicked()
1025        {
1026            shared.set_bottomright_tab(BottomRightTab::Account);
1027        }
1028        if ui
1029            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1030            .clicked()
1031        {
1032            shared.set_bottomright_tab(BottomRightTab::Logs);
1033        }
1034    });
1035    match tab {
1036        BottomRightTab::Account => {
1037            let ts = shared.trade_snap();
1038            if ts.accounts.is_empty() {
1039                ui.label("(无资金:等网关 eAccount.)");
1040                return;
1041            }
1042            egui::Grid::new("acct_grid")
1043                .striped(true)
1044                .num_columns(5)
1045                .show(ui, |ui| {
1046                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1047                        ui.strong(h);
1048                    }
1049                    ui.end_row();
1050                    for a in &ts.accounts {
1051                        ui.label(a.accountid.clone());
1052                        ui.label(a.gateway_name.clone());
1053                        ui.label(format!("{:.2}", a.balance));
1054                        ui.label(format!("{:.2}", a.available()));
1055                        ui.label(format!("{:.2}", a.frozen));
1056                        ui.end_row();
1057                    }
1058                });
1059        }
1060        BottomRightTab::Logs => {
1061            render_logs(ui, shared);
1062        }
1063    }
1064}
1065
1066fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1067    ui.horizontal(|ui| {
1068        if ui.button(BTN_CLEAR_LOGS).clicked() {
1069            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1070        }
1071        let (_, spilled) = shared.logs_snapshot();
1072        if spilled > 0 {
1073            ui.label(format!(
1074                "已丢弃 {spilled} 条(环形 {})",
1075                crate::state::RING_CAP
1076            ));
1077        }
1078    });
1079    let lines = shared.logs_snapshot().0;
1080    egui::ScrollArea::vertical()
1081        .id_salt("log_scroll")
1082        .max_height(180.0)
1083        .stick_to_bottom(true)
1084        .show(ui, |ui| {
1085            for line in lines {
1086                ui.monospace(line);
1087            }
1088        });
1089}
1090
1091// ================= 弹窗面 =================
1092
1093fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1094    // 运维窗(菜单「运维」)
1095    let mut ops_open = shared.ops_window_open();
1096    egui::Window::new("运维窗")
1097        .id(egui::Id::new("ops_win"))
1098        .default_pos([80.0, 320.0])
1099        .default_size([640.0, 380.0])
1100        .open(&mut ops_open)
1101        .show(ctx, |ui| {
1102            render_ops_window(ui, shared);
1103        });
1104    shared.set_ops_window_open(ops_open);
1105
1106    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面,主窗只当弹窗宿主)
1107    let mut ctp_open = shared.ctp_window_open();
1108    egui::Window::new("CTP 网关配置窗")
1109        .id(egui::Id::new("ctp_win"))
1110        .default_pos([640.0, 320.0])
1111        .default_size([560.0, 330.0])
1112        .open(&mut ctp_open)
1113        .show(ctx, |ui| {
1114            render_ctp_window(ui, shared);
1115        });
1116    shared.set_ctp_window_open(ctp_open);
1117
1118    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)
1119    let mut contracts_open = shared.contracts_window_open();
1120    egui::Window::new("查询合约窗")
1121        .id(egui::Id::new("contracts_win"))
1122        .default_pos([420.0, 300.0])
1123        .default_size([560.0, 420.0])
1124        .open(&mut contracts_open)
1125        .show(ctx, |ui| {
1126            render_contracts_window(ui, shared);
1127        });
1128    shared.set_contracts_window_open(contracts_open);
1129
1130    // K线窗(双击弹出,按合约多开;窗关闭钮 = 出表)
1131    let mut closed: Vec<String> = Vec::new();
1132    for sym in shared.kline_symbols() {
1133        let resp = egui::Window::new(format!("K线 — {sym}"))
1134            .id(egui::Id::new(("kline_win", sym.clone())))
1135            .default_pos([300.0, 340.0])
1136            .default_size([540.0, 330.0])
1137            .show(ctx, |ui| {
1138                let cell = shared
1139                    .market()
1140                    .cells()
1141                    .into_iter()
1142                    .find(|c| c.symbol == sym);
1143                render_kline_cell(ui, cell.as_ref());
1144            });
1145        if resp.is_none() {
1146            closed.push(sym);
1147        }
1148    }
1149    for s in closed {
1150        shared.close_kline(&s);
1151    }
1152}
1153
1154/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1155/// + 依赖图 + 流控查询钮。回灌界面面已退役(总线口保留)。
1156fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1157    ui.label("插件");
1158    let snap = shared.snapshot();
1159    egui::Grid::new("plug_grid")
1160        .striped(true)
1161        .num_columns(6)
1162        .show(ui, |ui| {
1163            ui.strong("名称");
1164            ui.strong("状态");
1165            ui.strong("版本");
1166            ui.strong("依赖");
1167            ui.strong("统计");
1168            ui.strong("操作");
1169            ui.end_row();
1170            if snap.rows.is_empty() {
1171                ui.label("(快照空:等 eTimer 首拉)");
1172                ui.end_row();
1173            }
1174            for r in &snap.rows {
1175                if is_quarantined(&r.state) {
1176                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1177                    ui.label(
1178                        egui::RichText::new(state_text(r))
1179                            .color(egui::Color32::RED)
1180                            .strong(),
1181                    );
1182                } else {
1183                    ui.label(r.name.clone());
1184                    ui.label(state_text(r));
1185                }
1186                ui.label(r.version.clone());
1187                ui.label(format!("deps:[{}]", r.deps.join(",")));
1188                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1189                ui.horizontal(|ui| {
1190                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1191                        shared.push_cmd(UiCmd::Start {
1192                            factory: r.name.clone(),
1193                        });
1194                    }
1195                    let armed = shared.pending_confirm();
1196                    let armed_here = ui
1197                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1198                        .clicked()
1199                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1200                    if armed_here {
1201                        shared.push_cmd(UiCmd::Remove {
1202                            name: r.name.clone(),
1203                        });
1204                    }
1205                });
1206                ui.end_row();
1207            }
1208        });
1209
1210    if let Some(name) = shared.pending_confirm() {
1211        ui.horizontal(|ui| {
1212            ui.label(
1213                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1214                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1215            );
1216            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1217                shared.confirm_cancel();
1218            }
1219        });
1220    }
1221
1222    ui.label("服务");
1223    let flat: Vec<String> = snap
1224        .services
1225        .iter()
1226        .flat_map(|(n, ids)| {
1227            if ids.is_empty() {
1228                vec![format!("{n} 服务"), "-".to_string()]
1229            } else {
1230                ids.iter()
1231                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1232                    .collect()
1233            }
1234        })
1235        .collect();
1236    if flat.is_empty() {
1237        ui.label("(无服务目录)");
1238    } else {
1239        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1240        egui::Grid::new("svc_grid")
1241            .striped(true)
1242            .num_columns(6)
1243            .show(ui, |ui| {
1244                for r in 0..rows {
1245                    for c in 0..3 {
1246                        let i = c * rows * 2 + r * 2;
1247                        if i < flat.len() {
1248                            ui.label(flat[i].clone());
1249                            ui.label(flat[i + 1].clone());
1250                        } else {
1251                            ui.label("");
1252                            ui.label("");
1253                        }
1254                    }
1255                    ui.end_row();
1256                }
1257            });
1258    }
1259
1260    ui.label("依赖");
1261    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1262        ui.label("(全部无依赖)");
1263    }
1264    for r in &snap.rows {
1265        if !r.deps.is_empty() {
1266            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1267        }
1268    }
1269
1270    ui.horizontal(|ui| {
1271        if ui.small_button(BTN_QUERY_RATE).clicked() {
1272            shared.push_cmd(UiCmd::QueryRate);
1273        }
1274    });
1275}
1276
1277/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1278/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1279/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1280fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1281    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1282    let mut form: crate::state::CtpForm = shared.ctp_form();
1283    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1284        ui.label("broker");
1285        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1286        ui.label("td 前置");
1287        ctp_edit(
1288            ui,
1289            shared,
1290            &mut form.td_addr,
1291            "tcp://host:40001",
1292            CtpField::TdAddr,
1293        );
1294        ui.label("td 用户");
1295        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1296        ui.end_row();
1297        ui.label("td 密码");
1298        ctp_edit(
1299            ui,
1300            shared,
1301            &mut form.td_password,
1302            "密码",
1303            CtpField::TdPassword,
1304        );
1305        ui.label("AppID");
1306        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1307        ui.label("AuthCode");
1308        ctp_edit(
1309            ui,
1310            shared,
1311            &mut form.td_auth_code,
1312            "授权码",
1313            CtpField::AuthCode,
1314        );
1315        ui.end_row();
1316        ui.label("md 前置");
1317        ctp_edit(
1318            ui,
1319            shared,
1320            &mut form.md_addr,
1321            "tcp://host:40011",
1322            CtpField::MdAddr,
1323        );
1324        ui.label("md 用户");
1325        ctp_edit(
1326            ui,
1327            shared,
1328            &mut form.md_user,
1329            "空=td 回落",
1330            CtpField::MdUser,
1331        );
1332        ui.label("md 密码");
1333        ctp_edit(
1334            ui,
1335            shared,
1336            &mut form.md_password,
1337            "空=td 回落",
1338            CtpField::MdPassword,
1339        );
1340        ui.end_row();
1341    });
1342    ui.horizontal(|ui| {
1343        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1344            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1345            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1346        }
1347        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1348    });
1349}
1350
1351/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1352/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1353/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1354fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1355    let mut q = shared.contract_query();
1356    ui.horizontal(|ui| {
1357        ui.label("代码或交易所(留空 = 全部):");
1358        if ui
1359            .add(
1360                egui::TextEdit::singleline(&mut q)
1361                    .hint_text("cu 或 SHFE")
1362                    .desired_width(200.0),
1363            )
1364            .changed()
1365        {
1366            shared.set_contract_query(q.clone());
1367        }
1368    });
1369    let rows = shared.contracts(&q);
1370    if rows.is_empty() {
1371        ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1372        return;
1373    }
1374    let selected = shared.selected_contracts();
1375    egui::ScrollArea::vertical()
1376        .id_salt("contract_scroll")
1377        .max_height(260.0)
1378        .auto_shrink([false, false])
1379        .show(ui, |ui| {
1380            egui::Grid::new("contract_grid")
1381                .striped(true)
1382                .num_columns(5)
1383                .show(ui, |ui| {
1384                    for h in ["选(代码)", "交易所", "名称", "乘数", "最小变动"] {
1385                        ui.strong(h);
1386                    }
1387                    ui.end_row();
1388                    for c in &rows {
1389                        let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1390                        let mut on = selected.contains(&key);
1391                        // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1392                        // BD-UI-31(R3):单击勾选 = 订阅选择 + 行选中跳色(同动作
1393                        // 双后果,无冲突);悬停淡底照常。
1394                        let row_sel = shared.row_selected("contracts", &key);
1395                        let mut cells = Vec::with_capacity(5);
1396                        let ck = ui.checkbox(&mut on, key.clone());
1397                        if ck.changed() {
1398                            shared.toggle_contract_selection(&key);
1399                            shared.select_row("contracts", &key);
1400                        }
1401                        cells.push(ck.rect);
1402                        cells.push(ui.label(c.exchange.as_str().to_string()).rect);
1403                        cells.push(ui.label(c.name.clone()).rect);
1404                        cells.push(ui.label(format!("{:.0}", c.size)).rect);
1405                        cells.push(ui.label(format!("{:.4}", c.price_tick)).rect);
1406                        paint_row_bg(ui, &cells, row_sel);
1407                        ui.end_row();
1408                    }
1409                });
1410        });
1411    ui.horizontal(|ui| {
1412        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1413        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1414            if selected.is_empty() {
1415                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1416            } else {
1417                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1418                let symbols: Vec<String> = selected
1419                    .iter()
1420                    .map(|k| {
1421                        k.split_once('.')
1422                            .map(|(_, s)| s.to_string())
1423                            .unwrap_or_else(|| k.clone())
1424                    })
1425                    .collect();
1426                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1427                shared.log_line(format!(
1428                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1429                    selected.len()
1430                ));
1431            }
1432        }
1433    });
1434}
1435
1436/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1437enum CtpField {
1438    Broker,
1439    TdAddr,
1440    TdUser,
1441    TdPassword,
1442    AppId,
1443    AuthCode,
1444    MdAddr,
1445    MdUser,
1446    MdPassword,
1447}
1448
1449/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1450fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1451    let is_secret = matches!(
1452        field,
1453        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1454    );
1455    let mut w = egui::TextEdit::singleline(val)
1456        .hint_text(hint)
1457        .desired_width(110.0);
1458    if is_secret {
1459        w = w.password(true);
1460    }
1461    if ui.add(w).changed() {
1462        let mut f = shared.ctp_form();
1463        match field {
1464            CtpField::Broker => f.broker_id = val.clone(),
1465            CtpField::TdAddr => f.td_addr = val.clone(),
1466            CtpField::TdUser => f.td_user = val.clone(),
1467            CtpField::TdPassword => f.td_password = val.clone(),
1468            CtpField::AppId => f.td_app_id = val.clone(),
1469            CtpField::AuthCode => f.td_auth_code = val.clone(),
1470            CtpField::MdAddr => f.md_addr = val.clone(),
1471            CtpField::MdUser => f.md_user = val.clone(),
1472            CtpField::MdPassword => f.md_password = val.clone(),
1473        }
1474        shared.set_ctp_form(f);
1475    }
1476}
1477
1478/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1479///
1480/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1481fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1482    let Some(c) = cell else {
1483        ui.label("(K线无格:等 dm 合成)");
1484        return;
1485    };
1486    if c.bars.is_empty() {
1487        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1488        return;
1489    }
1490    let mut data = egui_charts::model::bar::BarData::new();
1491    for b in &c.bars {
1492        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1493        data.push(egui_charts::model::bar::Bar::new(
1494            dt,
1495            b.open_price,
1496            b.high_price,
1497            b.low_price,
1498            b.close_price,
1499            b.volume,
1500        ));
1501    }
1502    ui.label(format!(
1503        "K线 {}@{}s({} 根)",
1504        c.symbol,
1505        c.interval,
1506        c.bars.len()
1507    ));
1508    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1509    chart.set_symbol(&c.symbol);
1510    chart.set_timeframe_label(&format!("{}s", c.interval));
1511    chart.enable_tracking_mode();
1512    let _ = chart.show(ui);
1513}