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