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