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):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
20//!   流控查询钮随 sim 退场撤除)、
21//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
22//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
23//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
24//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
25//!   kittest 走回退面断言)。
26//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
27//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
28//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
29//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
30//!
31//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
32//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
33//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
34
35use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
43pub const BTN_CLEAR_LOGS: &str = "清空日志";
44/// 取消待确认按钮标签。
45pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46/// 下单提交按钮标签(v4 = Python「委托」钮)。
47pub const BTN_SEND_ORDER: &str = "委托";
48/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
49pub const BTN_CANCEL_ALL: &str = "全撤";
50/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
51pub const BTN_CANCEL_ORDER: &str = "撤单";
52/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
53pub const BTN_ACCOUNT_ADD: &str = "添加账户";
54/// 菜单项:运维(弹运维窗)。
55pub const MENU_OPS: &str = "运维";
56/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
57/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
58pub const MENU_ACCOUNTS: &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
76/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
77///
78/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
79/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
80fn subscribe_target(shared: &UiShared) -> Result<String, String> {
81    let md = shared.md_source();
82    if md.is_empty() {
83        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
84    }
85    Ok(md)
86}
87
88fn state_text(r: &PlugRow) -> String {
89    r.state.clone()
90}
91
92/// DtNs → HH:MM:SS(**北京时间 +08**;数据面时间戳统一存 UTC 纳秒 —— CTP
93/// 回报的自然日本就是北京钟面,入账按 +08 换算存 UTC,展示面换回来,表内
94/// 时间列与交易员作息同钟面)。
95fn fmt_dt(dt: Option<i64>) -> String {
96    dt.map(|ns| {
97        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
98            .with_timezone(&beijing_tz())
99            .format("%H:%M:%S")
100            .to_string()
101    })
102    .unwrap_or_else(|| "-".to_string())
103}
104
105/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
106fn beijing_tz() -> chrono::FixedOffset {
107    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
108}
109
110// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
111
112/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
113/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
114/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
115fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
116    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
117        return;
118    };
119    let hovered = ui
120        .input(|i| i.pointer.interact_pos())
121        .is_some_and(|p| union.expand(3.0).contains(p));
122    if selected {
123        ui.painter().rect_filled(
124            union.expand(3.0),
125            3.0,
126            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
127        );
128    } else if hovered {
129        ui.painter().rect_filled(
130            union.expand(3.0),
131            3.0,
132            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
133        );
134    }
135}
136
137/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
138fn order_active(status: ek_data::constant::Status) -> bool {
139    ek_data::constant::ACTIVE_STATUSES.contains(&status)
140}
141
142/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
143/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
144fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
145    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
146}
147
148/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
149/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
150/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
151pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
152    let mut f = shared.order_form();
153    f.symbol = t.symbol.clone();
154    f.exchange = exchange.to_string();
155    match col {
156        8 => {
157            f.direction = Direction::SHORT;
158            f.price = format!("{:.1}", t.bid_price_1);
159        }
160        9 => f.direction = Direction::SHORT,
161        10 => {
162            f.direction = Direction::LONG;
163            f.price = format!("{:.1}", t.ask_price_1);
164        }
165        11 => f.direction = Direction::LONG,
166        _ => {}
167    }
168    shared.set_order_form(f);
169}
170
171/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
172/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
173fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
174    if symbol.is_empty() {
175        return None;
176    }
177    shared.tick_rows().into_iter().find(|t| {
178        t.symbol == symbol
179            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
180    })
181}
182
183/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
184/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
185/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
186pub fn apply_ui_style(ctx: &egui::Context) {
187    apply_ui_style_for(ctx, crate::state::Theme::default());
188}
189
190/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
191/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
192/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
193/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
194pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
195    ctx.style_mut(|style| {
196        style
197            .text_styles
198            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
199        style
200            .text_styles
201            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
202        style
203            .text_styles
204            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
205        style
206            .text_styles
207            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
208        style
209            .text_styles
210            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
211    });
212    ctx.set_visuals(theme_visuals(theme));
213}
214
215/// 四套主题视觉(BD-UI-34)。金融深色 = egui dark 骨架换深海军蓝底
216/// (交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6 Fusion 迁移用。
217fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
218    use egui::Color32;
219    match t {
220        crate::state::Theme::EguiDark => egui::Visuals::dark(),
221        crate::state::Theme::EguiLight => egui::Visuals::light(),
222        crate::state::Theme::QtClassic => {
223            let mut v = egui::Visuals::light();
224            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
225            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
226            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
227            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
228            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
229            v
230        }
231        crate::state::Theme::FinanceDark => {
232            let mut v = egui::Visuals::dark();
233            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
234            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
235            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
236            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
237            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
238            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
239            v.widgets.noninteractive.bg_stroke =
240                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
241            v
242        }
243    }
244}
245
246/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
247///
248/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
249/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
250/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
251/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
252/// Window 挂在所有面板之后。
253pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
254    apply_ui_style_for(ctx, shared.theme());
255    // ---- 菜单栏(顶层面板,最先 = 最外) ----
256    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
257        render_menu_bar(ui, shared);
258    });
259
260    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
261    egui::TopBottomPanel::top("market_band")
262        .resizable(true)
263        .default_height(240.0)
264        .show(ctx, |ui| {
265            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
266            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
267            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
268            egui::ScrollArea::both()
269                .id_salt("market_band_scroll")
270                .auto_shrink([false, false])
271                .show(ui, |ui| {
272                    render_market_band(ui, shared);
273                });
274        });
275
276    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
277    egui::SidePanel::left("order_side")
278        .resizable(true)
279        .default_width(250.0)
280        .show(ctx, |ui| {
281            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
282            egui::ScrollArea::vertical()
283                .id_salt("order_side_scroll")
284                .auto_shrink([false, false])
285                .show(ui, |ui| {
286                    render_order_panel(ui, shared);
287                    ui.separator();
288                    render_book_frame(ui, shared);
289                });
290        });
291
292    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
293    egui::CentralPanel::default().show(ctx, |ui| {
294        let avail = ui.available_size();
295        let sp = ui.spacing().item_spacing;
296        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
297        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
298        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
299        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
300        // Frame+ScrollArea 在预留矩形内才吃满。
301        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
302        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
303        ui.horizontal(|ui| {
304            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
305                render_topleft_group(ui, shared);
306            });
307            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
308                render_trades_group(ui, shared);
309            });
310        });
311        ui.horizontal(|ui| {
312            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
313                render_positions_group(ui, shared);
314            });
315            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
316            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
317            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
318            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
319            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
320                render_bottomright_group(ui, shared);
321            });
322        });
323    });
324
325    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
326    render_windows(ctx, shared);
327}
328
329/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
330/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
331/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
332/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
333/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
334/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
335/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
336fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
337    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
338    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
339    egui::Frame::group(ui.style()).show(&mut child, |ui| {
340        egui::ScrollArea::both()
341            .id_salt(id)
342            .auto_shrink([false, false])
343            .show(ui, |ui| {
344                ui.vertical(content);
345            });
346    });
347}
348
349/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
350/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
351/// = 双滚动条且表头失去固定意义。
352/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
353/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
354/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
355fn group_frame_plain(
356    ui: &mut egui::Ui,
357    _id: &str,
358    w: f32,
359    h: f32,
360    content: impl FnOnce(&mut egui::Ui),
361) {
362    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
363    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
364    egui::Frame::group(ui.style()).show(&mut child, |ui| {
365        ui.vertical(content);
366    });
367}
368
369/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
370/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
371///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
372///   request_repaint → kittest max_steps 爆)。
373/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
374///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
375///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
376///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
377///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
378/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
379///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
380///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
381///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
382///
383/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
384fn pinned_table(
385    ui: &mut egui::Ui,
386    grid_id: &str,
387    num_columns: usize,
388    headers: &[&str],
389    body: impl FnOnce(&mut egui::Ui),
390) {
391    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
392        std::cell::RefCell::new(Vec::new());
393    let resp = egui::ScrollArea::both()
394        .id_salt(format!("{grid_id}_sc"))
395        .auto_shrink([false, false])
396        .show(ui, |ui| {
397            egui::Grid::new(grid_id)
398                .striped(true)
399                .num_columns(num_columns)
400                .show(ui, |ui| {
401                    for h in headers {
402                        let r = ui.strong(*h);
403                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
404                    }
405                    ui.end_row();
406                    body(ui);
407                });
408        });
409    let heads = heads_cell.borrow();
410    if heads.is_empty() {
411        return;
412    }
413    let area = resp.inner_rect;
414    let head_h = heads[0].0.height();
415    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
416    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
417    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
418    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
419    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
420    let m = ui.visuals().clip_rect_margin;
421    let strip = egui::Rect::from_min_max(
422        egui::pos2(area.min.x - m, area.min.y - m),
423        egui::pos2(
424            area.max.x + m,
425            area.min.y + head_h + ui.spacing().item_spacing.y,
426        ),
427    );
428    ui.scope_builder(
429        egui::UiBuilder::new()
430            .id_salt(format!("{grid_id}_hdr_ov"))
431            .max_rect(strip),
432        |ui| {
433            ui.set_clip_rect(strip);
434            let painter = ui.painter();
435            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
436            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
437            let color = ui.visuals().strong_text_color();
438            for (rect, text) in heads.iter() {
439                painter.text(
440                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
441                    egui::Align2::LEFT_CENTER,
442                    text.as_str(),
443                    font.clone(),
444                    color,
445                );
446            }
447        },
448    );
449}
450
451// ================= 菜单栏 =================
452
453fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
454    ui.horizontal(|ui| {
455        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
456        if ui.button(MENU_OPS).clicked() {
457            shared.set_ops_window_open(true);
458        }
459        if ui.button(MENU_ACCOUNTS).clicked() {
460            shared.set_accounts_window_open(true);
461        }
462        if ui.button(MENU_CONTRACTS).clicked() {
463            shared.set_contracts_window_open(true);
464        }
465        ui.menu_button(MENU_HELP, |ui| {
466            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
467            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
468        });
469        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
470        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
471        if ui
472            .button(format!("主题:{}", shared.theme().label()))
473            .clicked()
474        {
475            let t = shared.cycle_theme();
476            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
477        }
478    });
479}
480
481// ================= 行情带 =================
482
483fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
484    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
485    let groups = shared.tick_groups();
486    if groups.is_empty() {
487        ui.label("(等待 eTick 订阅)");
488        return;
489    }
490    // 二级 tab:只列有 tick 的所
491    let sel = shared.selected_exchange();
492    let active = if groups.iter().any(|(e, _)| *e == sel) {
493        sel.clone()
494    } else {
495        groups[0].0.clone()
496    };
497    ui.horizontal(|ui| {
498        for (ex, _) in &groups {
499            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
500                shared.set_selected_exchange(ex.clone());
501            }
502        }
503    });
504    let ticks = groups
505        .iter()
506        .find(|(e, _)| *e == active)
507        .map(|(_, v)| v.clone())
508        .unwrap_or_default();
509    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
510    // 不需要,行情带 tab + 表格即全部)。
511    pinned_table(
512        ui,
513        "tick_grid",
514        14,
515        &[
516            "代码",
517            "名称",
518            "最新价",
519            "涨跌幅",
520            "成交量",
521            "今开",
522            "最高",
523            "最低",
524            "买1价",
525            "买1量",
526            "卖1价",
527            "卖1量",
528            "时间",
529            "行情源",
530        ],
531        |ui| {
532            for t in &ticks {
533                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
534                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
535                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
536                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
537                let row_sel = shared.order_form().symbol == t.symbol;
538                let mut cells = Vec::with_capacity(14);
539                let mut dbl = false;
540                let resp = ui.button(t.symbol.clone());
541                if resp.clicked() {
542                    shared.set_selected_exchange(active.clone());
543                    import_tick(shared, t, &active, 0);
544                }
545                dbl |= resp.double_clicked();
546                cells.push(resp.rect);
547                macro_rules! cell {
548                    ($base:expr, $col:expr) => {{
549                        let base = $base;
550                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
551                        if r.clicked() {
552                            shared.set_selected_exchange(active.clone());
553                            import_tick(shared, t, &active, $col);
554                        }
555                        dbl |= r.double_clicked();
556                        cells.push(base.rect);
557                    }};
558                }
559                cell!(
560                    ui.label(if t.name.is_empty() {
561                        "-".to_string()
562                    } else {
563                        t.name.clone()
564                    }),
565                    1
566                );
567                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
568                cell!(
569                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
570                        if t.pct_change < 0.0 {
571                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
572                        } else {
573                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
574                        },
575                    )),
576                    3
577                );
578                cell!(ui.label(format!("{:.0}", t.volume)), 4);
579                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
580                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
581                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
582                cell!(
583                    ui.label(
584                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
585                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
586                    ),
587                    8
588                );
589                cell!(
590                    ui.label(
591                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
592                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
593                    ),
594                    9
595                );
596                cell!(
597                    ui.label(
598                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
599                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
600                    ),
601                    10
602                );
603                cell!(
604                    ui.label(
605                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
606                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
607                    ),
608                    11
609                );
610                cell!(
611                    ui.label(if t.update_time.is_empty() {
612                        fmt_dt(Some(t.datetime))
613                    } else {
614                        t.update_time.clone()
615                    }),
616                    12
617                );
618                cell!(ui.label(t.gateway_name.clone()), 13);
619                if dbl {
620                    shared.open_kline(t.symbol.clone());
621                }
622                paint_row_bg(ui, &cells, row_sel);
623                ui.end_row();
624            }
625        },
626    );
627}
628
629// ================= 下单面板(常驻) =================
630
631fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
632    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
633    let mut f = shared.order_form();
634    let before = f.clone();
635    let mut err: Option<String> = None;
636
637    // 随价勾选:价格框随该合约最新 tick(勾选态)
638    if f.follow_price {
639        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
640            if t.last_price > 0.0 {
641                f.price = format!("{:.1}", t.last_price);
642            }
643        }
644    }
645
646    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
647    let mut ex_opts: Vec<String> = shared
648        .tick_groups()
649        .iter()
650        .map(|(e, _)| e.clone())
651        .collect();
652    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
653        if !ex_opts.iter().any(|x| x == e) {
654            ex_opts.push(e.to_string());
655        }
656    }
657    if !ex_opts.contains(&f.exchange) {
658        ex_opts.insert(0, f.exchange.clone());
659    }
660    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
661    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
662    let ts = shared.trade_snap();
663    let mut gw_opts: Vec<String> = Vec::new();
664    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
665    // 进了下拉,选它下单只会撞 `unknown:gateway`。
666    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
667    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
668    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
669    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
670    let known_gws = account_gateways(shared);
671    for g in shared
672        .accounts()
673        .iter()
674        .filter(|a| a.connected)
675        .map(|a| a.gateway.clone())
676        .chain(
677            ts.orders
678                .iter()
679                .map(|o| o.gateway_name.clone())
680                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
681                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
682                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
683        )
684    {
685        if !g.is_empty() && !gw_opts.contains(&g) {
686            gw_opts.push(g);
687        }
688    }
689    if !gw_opts.contains(&f.gateway)
690        && !f.gateway.is_empty()
691        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
692    {
693        gw_opts.insert(0, f.gateway.clone());
694    }
695    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
696    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
697    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
698        f.gateway = shared.default_gateway().unwrap_or_default();
699    }
700    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
701    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
702    if f.gateway.is_empty() {
703        if let Some(def) = shared.default_gateway() {
704            f.gateway = def;
705        }
706    }
707    let no_accounts = gw_opts.is_empty();
708
709    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
710    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
711    egui::Grid::new("order_form_grid")
712        .num_columns(2)
713        .show(ui, |ui| {
714            ui.label("交易所");
715            egui::ComboBox::from_id_salt("of_exchange")
716                .selected_text(f.exchange.clone())
717                .show_ui(ui, |ui| {
718                    for e in &ex_opts {
719                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
720                    }
721                });
722            ui.end_row();
723            ui.label("代码");
724            let sym_resp = ui.add(
725                egui::TextEdit::singleline(&mut f.symbol)
726                    .hint_text("cu2709")
727                    .desired_width(150.0),
728            );
729            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
730            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
731            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
732            // has_focus 口径回车订阅从不触发)。
733            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
734                let s = f.symbol.trim().to_string();
735                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
736                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
737                let bare = match s.split_once('.') {
738                    Some((_, b)) => b.trim().to_string(),
739                    None => s,
740                };
741                if !bare.is_empty() {
742                    f.symbol = bare.clone();
743                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
744                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
745                    match subscribe_target(shared) {
746                        Ok(gw) => {
747                            shared.push_cmd(UiCmd::CtpSubscribe {
748                                gateway: gw.clone(),
749                                symbols: vec![bare.clone()],
750                            });
751                            shared
752                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
753                        }
754                        Err(e) => shared.log_line(format!("[订阅] {e}")),
755                    }
756                }
757            }
758            ui.end_row();
759            ui.label("名称");
760            ui.label(
761                tick_of(shared, &f.exchange, &f.symbol)
762                    .map(|t| {
763                        if t.name.is_empty() {
764                            "-".to_string()
765                        } else {
766                            t.name
767                        }
768                    })
769                    .unwrap_or_else(|| "-".to_string()),
770            );
771            ui.end_row();
772            ui.label("方向");
773            egui::ComboBox::from_id_salt("of_direction")
774                .selected_text(f.direction.as_str())
775                .show_ui(ui, |ui| {
776                    for d in [Direction::LONG, Direction::SHORT] {
777                        ui.selectable_value(&mut f.direction, d, d.as_str());
778                    }
779                });
780            ui.end_row();
781            ui.label("开平");
782            ui.horizontal(|ui| {
783                egui::ComboBox::from_id_salt("of_offset")
784                    .selected_text(f.offset.as_str())
785                    .show_ui(ui, |ui| {
786                        for o in [
787                            Offset::OPEN,
788                            Offset::CLOSE,
789                            Offset::CLOSETODAY,
790                            Offset::CLOSEYESTERDAY,
791                        ] {
792                            ui.selectable_value(&mut f.offset, o, o.as_str());
793                        }
794                    });
795                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
796                ui.checkbox(&mut f.auto_split, "自动开平");
797            });
798            ui.end_row();
799            ui.label("类型");
800            egui::ComboBox::from_id_salt("of_order_type")
801                .selected_text(f.order_type.as_str())
802                .show_ui(ui, |ui| {
803                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
804                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
805                    }
806                });
807            ui.end_row();
808            ui.label("价格");
809            ui.horizontal(|ui| {
810                ui.add(
811                    egui::TextEdit::singleline(&mut f.price)
812                        .hint_text("107660")
813                        .desired_width(90.0),
814                );
815                ui.checkbox(&mut f.follow_price, "随价");
816            });
817            ui.end_row();
818            ui.label("数量");
819            ui.add(
820                egui::TextEdit::singleline(&mut f.volume)
821                    .hint_text("1")
822                    .desired_width(90.0),
823            );
824            ui.end_row();
825            ui.label("接口");
826            egui::ComboBox::from_id_salt("of_gateway")
827                .selected_text(if f.gateway.is_empty() {
828                    "(未选账户)".to_string()
829                } else {
830                    f.gateway.clone()
831                })
832                .show_ui(ui, |ui| {
833                    for g in &gw_opts {
834                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
835                    }
836                });
837            ui.end_row();
838            ui.label("操作");
839            ui.horizontal(|ui| {
840                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
841                let send = ui
842                    .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
843                    .inner
844                    .clicked();
845                if send {
846                    match f.to_request() {
847                        Ok(req) => {
848                            let fallback = shared.default_gateway();
849                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
850                                Ok(gateway) => {
851                                    shared.push_cmd(UiCmd::SendOrder {
852                                        req,
853                                        gateway,
854                                        mode: if f.auto_split {
855                                            ek_oms::LockMode::Auto
856                                        } else {
857                                            ek_oms::LockMode::None
858                                        },
859                                    });
860                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
861                                }
862                                Err(e) => {
863                                    shared.log_line(format!("[下单] {e}"));
864                                    err = Some(e);
865                                }
866                            }
867                        }
868                        Err(e) => {
869                            shared.log_line(format!("[下单] {e}"));
870                            err = Some(e);
871                        }
872                    }
873                }
874                if ui.button(BTN_CANCEL_ALL).clicked() {
875                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
876                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
877                    let mut n = 0usize;
878                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
879                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
880                        let gw = if !o.gateway_name.is_empty() {
881                            o.gateway_name.clone()
882                        } else {
883                            shared.default_gateway().unwrap_or_default()
884                        };
885                        if gw.is_empty() {
886                            continue;
887                        }
888                        shared.push_cmd(UiCmd::CancelOrder {
889                            orderid: o.orderid.clone(),
890                            gateway: gw,
891                        });
892                        n += 1;
893                    }
894                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
895                }
896            });
897            ui.end_row();
898            ui.label("代码回车 = 订阅");
899            ui.label("");
900            ui.end_row();
901        });
902    if let Some(e) = err {
903        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
904    }
905    if no_accounts {
906        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
907        ui.label(
908            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
909                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
910        );
911    }
912    if f != before {
913        shared.set_order_form(f.clone());
914    }
915}
916
917/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
918/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
919/// 行情带跳色行指认,盘口帧不重复报幕)。
920fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
921    let f = shared.order_form();
922    match tick_of(shared, &f.exchange, &f.symbol) {
923        Some(t) => {
924            for i in (1..=5).rev() {
925                let (p, v) = match i {
926                    1 => (t.ask_price_1, t.ask_volume_1),
927                    2 => (t.ask_price_2, t.ask_volume_2),
928                    3 => (t.ask_price_3, t.ask_volume_3),
929                    4 => (t.ask_price_4, t.ask_volume_4),
930                    _ => (t.ask_price_5, t.ask_volume_5),
931                };
932                ui.label(
933                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
934                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
935                );
936            }
937            for i in 1..=5 {
938                let (p, v) = match i {
939                    1 => (t.bid_price_1, t.bid_volume_1),
940                    2 => (t.bid_price_2, t.bid_volume_2),
941                    3 => (t.bid_price_3, t.bid_volume_3),
942                    4 => (t.bid_price_4, t.bid_volume_4),
943                    _ => (t.bid_price_5, t.bid_volume_5),
944                };
945                ui.label(
946                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
947                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
948                );
949            }
950        }
951        None => {
952            ui.label("(无 tick)");
953        }
954    }
955}
956
957// ================= 2×2 监控格 =================
958
959fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
960    let tab = shared.topleft_tab();
961    ui.horizontal(|ui| {
962        if ui
963            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
964            .clicked()
965        {
966            shared.set_topleft_tab(TopLeftTab::Orders);
967        }
968        if ui
969            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
970            .clicked()
971        {
972            shared.set_topleft_tab(TopLeftTab::Active);
973        }
974    });
975    render_orders_table(ui, shared, tab == TopLeftTab::Active);
976}
977
978/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
979/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
980/// 活动行撤单钮 + 双击撤单,终态行只读)。
981fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
982    let ts = shared.trade_snap();
983    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
984    let known_gws = account_gateways(shared);
985    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
986    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
987    let rows: Vec<_> = ts
988        .orders
989        .iter()
990        .filter(|o| !active_only || order_active(o.status))
991        .collect();
992    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
993    const ORDER_HEADERS: [&str; 17] = [
994        "代码",
995        "名称",
996        "交易所",
997        "类型",
998        "方向",
999        "开平",
1000        "价格",
1001        "总量",
1002        "已成",
1003        "成交均价",
1004        "状态",
1005        "状态描述",
1006        "时间",
1007        "委托号",
1008        "来源",
1009        "接口",
1010        "操作",
1011    ];
1012    if rows.is_empty() {
1013        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
1014        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
1015        pinned_table(
1016            ui,
1017            if active_only {
1018                "active_grid"
1019            } else {
1020                "order_grid"
1021            },
1022            17,
1023            &ORDER_HEADERS,
1024            |ui| {
1025                ui.label(if active_only {
1026                    "(无未成交)"
1027                } else {
1028                    "(无委托:等网关 send_order)"
1029                });
1030            },
1031        );
1032        return;
1033    }
1034    pinned_table(
1035        ui,
1036        if active_only {
1037            "active_grid"
1038        } else {
1039            "order_grid"
1040        },
1041        17,
1042        &ORDER_HEADERS,
1043        |ui| {
1044            for o in &rows {
1045                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1046                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
1047                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
1048                let key = format!("{}.{}", o.gateway_name, o.orderid);
1049                let row_sel = shared.row_selected("orders", &key);
1050                let mut cells = Vec::with_capacity(17);
1051                let mut dbl = false;
1052                let sel = ui.selectable_label(row_sel, o.symbol.clone());
1053                if sel.clicked() {
1054                    shared.select_row("orders", &key);
1055                }
1056                dbl |= sel.double_clicked();
1057                cells.push(sel.rect);
1058                macro_rules! cell {
1059                    ($base:expr, $col:expr) => {{
1060                        let base = $base;
1061                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
1062                        if r.clicked() {
1063                            shared.select_row("orders", &key);
1064                        }
1065                        dbl |= r.double_clicked();
1066                        cells.push(base.rect);
1067                    }};
1068                }
1069                cell!(
1070                    ui.label(if o.name.is_empty() {
1071                        "-".to_string()
1072                    } else {
1073                        o.name.clone()
1074                    }),
1075                    1
1076                );
1077                cell!(ui.label(o.exchange.as_str().to_string()), 2);
1078                cell!(ui.label(o.order_type.as_str().to_string()), 3);
1079                cell!(
1080                    ui.label(
1081                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1082                            if o.direction == Some(Direction::SHORT) {
1083                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1084                            } else {
1085                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1086                            },
1087                        ),
1088                    ),
1089                    4
1090                );
1091                cell!(ui.label(o.offset.as_str().to_string()), 5);
1092                cell!(ui.label(format!("{:.1}", o.price)), 6);
1093                cell!(ui.label(format!("{:.0}", o.volume)), 7);
1094                cell!(ui.label(format!("{:.0}", o.traded)), 8);
1095                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1096                cell!(
1097                    ui.label(egui::RichText::new(o.status.as_str()).color(
1098                        if o.status == ek_data::constant::Status::REJECTED {
1099                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1100                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1101                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1102                        } else {
1103                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1104                        },
1105                    )),
1106                    10
1107                );
1108                cell!(ui.label(o.status_msg.clone()), 11);
1109                cell!(ui.label(fmt_dt(o.datetime)), 12);
1110                cell!(ui.label(o.orderid.clone()), 13);
1111                cell!(
1112                    ui.label(if o.reference.is_empty() {
1113                        "-".to_string()
1114                    } else {
1115                        o.reference.clone()
1116                    }),
1117                    14
1118                );
1119                cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1120                if dbl && order_active(o.status) {
1121                    // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
1122                    let gw = if !o.gateway_name.is_empty() {
1123                        o.gateway_name.clone()
1124                    } else {
1125                        shared.default_gateway().unwrap_or_default()
1126                    };
1127                    if !gw.is_empty() {
1128                        shared.push_cmd(UiCmd::CancelOrder {
1129                            orderid: o.orderid.clone(),
1130                            gateway: gw,
1131                        });
1132                    }
1133                }
1134                let hr = ui.horizontal(|ui| {
1135                    if order_active(o.status)
1136                        && ui
1137                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1138                            .clicked()
1139                    {
1140                        // 撤单跟随委托的来路网关(行级 gateway_name;
1141                        // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
1142                        let gw = if !o.gateway_name.is_empty() {
1143                            o.gateway_name.clone()
1144                        } else {
1145                            shared.default_gateway().unwrap_or_default()
1146                        };
1147                        if gw.is_empty() {
1148                            shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1149                        } else {
1150                            shared.push_cmd(UiCmd::CancelOrder {
1151                                orderid: o.orderid.clone(),
1152                                gateway: gw,
1153                            });
1154                        }
1155                    }
1156                });
1157                cells.push(hr.response.rect);
1158                paint_row_bg(ui, &cells, row_sel);
1159                ui.end_row();
1160            }
1161        },
1162    );
1163}
1164
1165/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1166fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1167    ui.label("成交");
1168    let ts = shared.trade_snap();
1169    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1170    let known_gws = account_gateways(shared);
1171    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1172    const TRADE_HEADERS: [&str; 12] = [
1173        "成交号",
1174        "代码",
1175        "名称",
1176        "交易所",
1177        "方向",
1178        "开平",
1179        "价格",
1180        "数量",
1181        "成交额",
1182        "时间",
1183        "委托号",
1184        "接口",
1185    ];
1186    if ts.trades.is_empty() {
1187        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1188        pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1189            ui.label("(无成交)");
1190        });
1191        return;
1192    }
1193    pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1194        for t in &ts.trades {
1195            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1196            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1197            let key = format!("{}.{}", t.gateway_name, t.tradeid);
1198            let row_sel = shared.row_selected("trades", &key);
1199            let mut cells = Vec::with_capacity(12);
1200            let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1201            if sel.clicked() {
1202                shared.select_row("trades", &key);
1203            }
1204            cells.push(sel.rect);
1205            macro_rules! cell {
1206                ($base:expr, $col:expr) => {{
1207                    let base = $base;
1208                    let r = click_cell(ui, base.rect, ("trd", &key, $col));
1209                    if r.clicked() {
1210                        shared.select_row("trades", &key);
1211                    }
1212                    cells.push(base.rect);
1213                }};
1214            }
1215            cell!(ui.label(t.symbol.clone()), 1);
1216            cell!(
1217                ui.label(if t.name.is_empty() {
1218                    "-".to_string()
1219                } else {
1220                    t.name.clone()
1221                }),
1222                2
1223            );
1224            cell!(ui.label(t.exchange.as_str().to_string()), 3);
1225            cell!(
1226                ui.label(
1227                    egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1228                        if t.direction == Some(Direction::SHORT) {
1229                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1230                        } else {
1231                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1232                        },
1233                    ),
1234                ),
1235                4
1236            );
1237            cell!(ui.label(t.offset.as_str().to_string()), 5);
1238            cell!(ui.label(format!("{:.1}", t.price)), 6);
1239            cell!(ui.label(format!("{:.0}", t.volume)), 7);
1240            cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1241            cell!(ui.label(fmt_dt(t.datetime)), 9);
1242            cell!(ui.label(t.orderid.clone()), 10);
1243            cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1244            paint_row_bg(ui, &cells, row_sel);
1245            ui.end_row();
1246        }
1247    });
1248}
1249
1250/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1251/// Python mainwindow.py:276 等价,不开 K线)。
1252fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1253    ui.label("持仓");
1254    let ts = shared.trade_snap();
1255    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1256    let known_gws = account_gateways(shared);
1257    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1258    const POS_HEADERS: [&str; 16] = [
1259        "代码",
1260        "名称",
1261        "交易所",
1262        "方向",
1263        "数量",
1264        "昨仓",
1265        "可用",
1266        "冻结",
1267        "在途",
1268        "市值",
1269        "成本",
1270        "均价",
1271        "最新价",
1272        "盈亏",
1273        "盈亏比",
1274        "接口",
1275    ];
1276    if ts.positions.is_empty() {
1277        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1278        pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1279            ui.label("(无持仓)");
1280        });
1281        return;
1282    }
1283    pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1284        for p in &ts.positions {
1285            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1286            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1287            // 不开 K线;方向随持仓方向,Python 同口径)。
1288            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1289            let row_sel = shared.row_selected("positions", &key);
1290            let mut cells = Vec::with_capacity(16);
1291            let mut dbl = false;
1292            let sel = ui.selectable_label(row_sel, p.symbol.clone());
1293            if sel.clicked() {
1294                shared.select_row("positions", &key);
1295            }
1296            dbl |= sel.double_clicked();
1297            cells.push(sel.rect);
1298            macro_rules! cell {
1299                ($base:expr, $col:expr) => {{
1300                    let base = $base;
1301                    let r = click_cell(ui, base.rect, ("pos", &key, $col));
1302                    if r.clicked() {
1303                        shared.select_row("positions", &key);
1304                    }
1305                    dbl |= r.double_clicked();
1306                    cells.push(base.rect);
1307                }};
1308            }
1309            cell!(
1310                ui.label(if p.name.is_empty() {
1311                    "-".to_string()
1312                } else {
1313                    p.name.clone()
1314                }),
1315                1
1316            );
1317            cell!(ui.label(p.exchange.as_str().to_string()), 2);
1318            cell!(
1319                ui.label(egui::RichText::new(p.direction.as_str()).color(
1320                    if p.direction == ek_data::constant::Direction::SHORT {
1321                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1322                    } else {
1323                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1324                    },
1325                )),
1326                3
1327            );
1328            cell!(ui.label(format!("{:.0}", p.volume)), 4);
1329            cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1330            cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1331            cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1332            cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1333            cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1334            cell!(ui.label(format!("{:.2}", p.price)), 10);
1335            cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1336            cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1337            cell!(
1338                ui.label(
1339                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1340                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1341                    } else {
1342                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1343                    }),
1344                ),
1345                13
1346            );
1347            cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1348            cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1349            if dbl {
1350                let mut f = shared.order_form();
1351                f.symbol = p.symbol.clone();
1352                f.exchange = p.exchange.as_str().to_string();
1353                f.direction = p.direction;
1354                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
1355                if !p.gateway_name.is_empty() {
1356                    f.gateway = p.gateway_name.clone();
1357                }
1358                shared.set_order_form(f);
1359                shared.log_line(format!(
1360                    "[ops] 持仓带入下单面板 {}(接口 {})",
1361                    p.symbol, p.gateway_name
1362                ));
1363            }
1364            paint_row_bg(ui, &cells, row_sel);
1365            ui.end_row();
1366        }
1367    });
1368}
1369
1370fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1371    let tab = shared.bottomright_tab();
1372    ui.horizontal(|ui| {
1373        if ui
1374            .selectable_label(
1375                tab == BottomRightTab::Account,
1376                BottomRightTab::Account.label(),
1377            )
1378            .clicked()
1379        {
1380            shared.set_bottomright_tab(BottomRightTab::Account);
1381        }
1382        if ui
1383            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1384            .clicked()
1385        {
1386            shared.set_bottomright_tab(BottomRightTab::Logs);
1387        }
1388    });
1389    match tab {
1390        BottomRightTab::Account => {
1391            let ts = shared.trade_snap();
1392            if ts.accounts.is_empty() {
1393                ui.label("(无资金:等网关 eAccount.)");
1394                return;
1395            }
1396            egui::Grid::new("acct_grid")
1397                .striped(true)
1398                .num_columns(5)
1399                .show(ui, |ui| {
1400                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1401                        ui.strong(h);
1402                    }
1403                    ui.end_row();
1404                    for a in &ts.accounts {
1405                        ui.label(a.accountid.clone());
1406                        ui.label(a.gateway_name.clone());
1407                        ui.label(format!("{:.2}", a.balance));
1408                        ui.label(format!("{:.2}", a.available()));
1409                        ui.label(format!("{:.2}", a.frozen));
1410                        ui.end_row();
1411                    }
1412                });
1413        }
1414        BottomRightTab::Logs => {
1415            render_logs(ui, shared);
1416        }
1417    }
1418}
1419
1420fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1421    ui.horizontal(|ui| {
1422        if ui.button(BTN_CLEAR_LOGS).clicked() {
1423            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1424        }
1425        let (_, spilled) = shared.logs_snapshot();
1426        if spilled > 0 {
1427            ui.label(format!(
1428                "已丢弃 {spilled} 条(环形 {})",
1429                crate::state::RING_CAP
1430            ));
1431        }
1432    });
1433    let lines = shared.logs_snapshot().0;
1434    egui::ScrollArea::vertical()
1435        .id_salt("log_scroll")
1436        .max_height(180.0)
1437        .stick_to_bottom(true)
1438        .show(ui, |ui| {
1439            for line in lines {
1440                ui.monospace(line);
1441            }
1442        });
1443}
1444
1445// ================= 独立窗面(BD-UI-32/33)=================
1446
1447/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1448struct WinGeom {
1449    inner: [f32; 2],
1450    min_inner: [f32; 2],
1451    maximized: bool,
1452}
1453
1454/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1455///
1456/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
1457///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
1458///   context.rs「never used this pass」)。
1459/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
1460///   回退面关闭 = embedded 闭包返回 true。
1461/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
1462/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
1463///   Embedded 回退面无此概念 = 忽略)。
1464/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
1465///   键事件按子窗焦点路由,主窗按键不会误关)。
1466///
1467/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
1468fn show_independent_window(
1469    ctx: &egui::Context,
1470    id_salt: impl std::hash::Hash,
1471    title: &str,
1472    geom: &WinGeom,
1473    embedded: impl Fn(&egui::Context) -> bool,
1474    body: impl Fn(&mut egui::Ui) -> bool,
1475) -> bool {
1476    let closed = std::cell::Cell::new(false);
1477    let mut builder = egui::ViewportBuilder::default()
1478        .with_title(title)
1479        .with_inner_size(geom.inner)
1480        .with_min_inner_size(geom.min_inner)
1481        .with_minimize_button(true)
1482        .with_maximize_button(true)
1483        .with_close_button(true);
1484    if geom.maximized {
1485        builder = builder.with_maximized(true);
1486    }
1487    ctx.show_viewport_immediate(
1488        egui::ViewportId::from_hash_of(id_salt),
1489        builder,
1490        |ctx2, class| {
1491            if class == egui::ViewportClass::Embedded {
1492                closed.set(embedded(ctx2));
1493                return;
1494            }
1495            if ctx2.input(|i| i.viewport().close_requested()) {
1496                closed.set(true);
1497                return;
1498            }
1499            egui::CentralPanel::default().show(ctx2, |ui| {
1500                if body(ui) {
1501                    closed.set(true);
1502                }
1503            });
1504        },
1505    );
1506    closed.get()
1507}
1508
1509fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1510    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
1511    if shared.ops_window_open() {
1512        let closed = show_independent_window(
1513            ctx,
1514            "ops_win",
1515            "运维窗",
1516            &WinGeom {
1517                inner: [680.0, 420.0],
1518                min_inner: [420.0, 260.0],
1519                maximized: false,
1520            },
1521            |ctx2| {
1522                let mut open = shared.ops_window_open();
1523                egui::Window::new("运维窗")
1524                    .id(egui::Id::new("ops_win"))
1525                    .default_pos([80.0, 320.0])
1526                    .default_size([640.0, 380.0])
1527                    .open(&mut open)
1528                    .show(ctx2, |ui| {
1529                        render_ops_window(ui, shared);
1530                    });
1531                shared.set_ops_window_open(open);
1532                !open
1533            },
1534            |ui| {
1535                render_ops_window(ui, shared);
1536                false
1537            },
1538        );
1539        if closed {
1540            shared.set_ops_window_open(false);
1541        }
1542    }
1543
1544    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
1545    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
1546    if shared.accounts_window_open() {
1547        let closed = show_independent_window(
1548            ctx,
1549            "account_win",
1550            "账户面板",
1551            &WinGeom {
1552                inner: [760.0, 520.0],
1553                min_inner: [520.0, 320.0],
1554                maximized: false,
1555            },
1556            |ctx2| {
1557                let mut open = shared.accounts_window_open();
1558                egui::Window::new("账户面板")
1559                    .id(egui::Id::new("account_win"))
1560                    .default_pos([640.0, 320.0])
1561                    .default_size([720.0, 470.0])
1562                    .open(&mut open)
1563                    .show(ctx2, |ui| {
1564                        render_accounts_window(ui, shared);
1565                    });
1566                shared.set_accounts_window_open(open);
1567                !open
1568            },
1569            |ui| {
1570                render_accounts_window(ui, shared);
1571                false
1572            },
1573        );
1574        if closed {
1575            shared.set_accounts_window_open(false);
1576        }
1577    }
1578
1579    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
1580    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
1581    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
1582    if shared.account_edit_open() {
1583        let closed = show_independent_window(
1584            ctx,
1585            "account_edit_win",
1586            "账户配置",
1587            &WinGeom {
1588                inner: [680.0, 340.0],
1589                min_inner: [480.0, 240.0],
1590                maximized: false,
1591            },
1592            |ctx2| {
1593                let mut open = shared.account_edit_open();
1594                let title = match shared.editing_account() {
1595                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
1596                    None => "账户配置 — 添加".to_string(),
1597                };
1598                egui::Window::new(title)
1599                    .id(egui::Id::new("account_edit_win"))
1600                    .default_pos([700.0, 380.0])
1601                    .default_size([660.0, 320.0])
1602                    .open(&mut open)
1603                    .show(ctx2, |ui| {
1604                        render_account_edit_window(ui, shared);
1605                    });
1606                if !open {
1607                    shared.end_account_edit();
1608                }
1609                !open
1610            },
1611            |ui| {
1612                render_account_edit_window(ui, shared);
1613                false
1614            },
1615        );
1616        if closed && shared.account_edit_open() {
1617            shared.end_account_edit();
1618        }
1619    }
1620
1621    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
1622    if shared.contracts_window_open() {
1623        let closed = show_independent_window(
1624            ctx,
1625            "contracts_win",
1626            "查询合约窗",
1627            &WinGeom {
1628                inner: [620.0, 460.0],
1629                min_inner: [420.0, 260.0],
1630                maximized: false,
1631            },
1632            |ctx2| {
1633                let mut open = shared.contracts_window_open();
1634                egui::Window::new("查询合约窗")
1635                    .id(egui::Id::new("contracts_win"))
1636                    .default_pos([420.0, 300.0])
1637                    .default_size([560.0, 420.0])
1638                    .open(&mut open)
1639                    .show(ctx2, |ui| {
1640                        render_contracts_window(ui, shared);
1641                    });
1642                shared.set_contracts_window_open(open);
1643                !open
1644            },
1645            |ui| {
1646                render_contracts_window(ui, shared);
1647                false
1648            },
1649        );
1650        if closed {
1651            shared.set_contracts_window_open(false);
1652        }
1653    }
1654
1655    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
1656    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
1657    // 键按子窗焦点路由,主窗按键不误关)。
1658    let mut closed: Vec<String> = Vec::new();
1659    for sym in shared.kline_symbols() {
1660        let is_closed = show_independent_window(
1661            ctx,
1662            format!("kline_win:{sym}"),
1663            &format!("K线 — {sym}"),
1664            &WinGeom {
1665                inner: [560.0, 360.0],
1666                min_inner: [360.0, 220.0],
1667                maximized: true,
1668            },
1669            |ctx2| {
1670                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1671                let resp = egui::Window::new(format!("K线 — {sym}"))
1672                    .id(egui::Id::new(("kline_win", sym.clone())))
1673                    .default_pos([300.0, 340.0])
1674                    .default_size([540.0, 330.0])
1675                    .show(ctx2, |ui| {
1676                        let cell = shared
1677                            .market()
1678                            .cells()
1679                            .into_iter()
1680                            .find(|c| c.symbol == sym);
1681                        render_kline_cell(ui, cell.as_ref());
1682                    });
1683                esc || resp.is_none()
1684            },
1685            |ui| {
1686                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1687                    return true;
1688                }
1689                let cell = shared
1690                    .market()
1691                    .cells()
1692                    .into_iter()
1693                    .find(|c| c.symbol == sym);
1694                render_kline_cell(ui, cell.as_ref());
1695                false
1696            },
1697        );
1698        if is_closed {
1699            closed.push(sym);
1700        }
1701    }
1702    for s in closed {
1703        shared.close_kline(&s);
1704    }
1705}
1706
1707/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1708/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
1709fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1710    ui.label("插件");
1711    let snap = shared.snapshot();
1712    egui::Grid::new("plug_grid")
1713        .striped(true)
1714        .num_columns(6)
1715        .show(ui, |ui| {
1716            ui.strong("名称");
1717            ui.strong("状态");
1718            ui.strong("版本");
1719            ui.strong("依赖");
1720            ui.strong("统计");
1721            ui.strong("操作");
1722            ui.end_row();
1723            if snap.rows.is_empty() {
1724                ui.label("(快照空:等 eTimer 首拉)");
1725                ui.end_row();
1726            }
1727            for r in &snap.rows {
1728                if is_quarantined(&r.state) {
1729                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1730                    ui.label(
1731                        egui::RichText::new(state_text(r))
1732                            .color(egui::Color32::RED)
1733                            .strong(),
1734                    );
1735                } else {
1736                    ui.label(r.name.clone());
1737                    ui.label(state_text(r));
1738                }
1739                ui.label(r.version.clone());
1740                ui.label(format!("deps:[{}]", r.deps.join(",")));
1741                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1742                ui.horizontal(|ui| {
1743                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1744                        // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
1745                        // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
1746                        // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
1747                        shared.push_cmd(UiCmd::Start {
1748                            factory: r.name.clone(),
1749                            name: String::new(),
1750                        });
1751                    }
1752                    let armed = shared.pending_confirm();
1753                    let armed_here = ui
1754                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1755                        .clicked()
1756                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1757                    if armed_here {
1758                        shared.push_cmd(UiCmd::Remove {
1759                            name: r.name.clone(),
1760                        });
1761                    }
1762                });
1763                ui.end_row();
1764            }
1765        });
1766
1767    if let Some(name) = shared.pending_confirm() {
1768        ui.horizontal(|ui| {
1769            ui.label(
1770                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1771                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1772            );
1773            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1774                shared.confirm_cancel();
1775            }
1776        });
1777    }
1778
1779    ui.label("服务");
1780    let flat: Vec<String> = snap
1781        .services
1782        .iter()
1783        .flat_map(|(n, ids)| {
1784            if ids.is_empty() {
1785                vec![format!("{n} 服务"), "-".to_string()]
1786            } else {
1787                ids.iter()
1788                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1789                    .collect()
1790            }
1791        })
1792        .collect();
1793    if flat.is_empty() {
1794        ui.label("(无服务目录)");
1795    } else {
1796        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1797        egui::Grid::new("svc_grid")
1798            .striped(true)
1799            .num_columns(6)
1800            .show(ui, |ui| {
1801                for r in 0..rows {
1802                    for c in 0..3 {
1803                        let i = c * rows * 2 + r * 2;
1804                        if i < flat.len() {
1805                            ui.label(flat[i].clone());
1806                            ui.label(flat[i + 1].clone());
1807                        } else {
1808                            ui.label("");
1809                            ui.label("");
1810                        }
1811                    }
1812                    ui.end_row();
1813                }
1814            });
1815    }
1816
1817    ui.label("依赖");
1818    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1819        ui.label("(全部无依赖)");
1820    }
1821    for r in &snap.rows {
1822        if !r.deps.is_empty() {
1823            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1824        }
1825    }
1826}
1827
1828/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
1829/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
1830/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
1831/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
1832/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
1833fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
1834    let accounts = shared.accounts();
1835    let locked = shared.accounts_locked();
1836    let md = shared.md_source();
1837    let snap = shared.trade_snap();
1838
1839    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
1840    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
1841    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
1842    let row_label: std::collections::BTreeMap<String, String> = accounts
1843        .iter()
1844        .map(|a| {
1845            let d = a.display_name();
1846            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
1847            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
1848        })
1849        .collect();
1850
1851    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
1852    ui.horizontal(|ui| {
1853        ui.label(
1854            egui::RichText::new(format!(
1855                "当前行情源 = {}",
1856                if md.is_empty() {
1857                    "(未指定)".to_string()
1858                } else {
1859                    md.clone()
1860                }
1861            ))
1862            .strong(),
1863        );
1864        ui.label(format!(
1865            "账户 {}/{}",
1866            accounts.len(),
1867            crate::state::MAX_ACCOUNTS
1868        ));
1869        if shared.can_add_account() {
1870            if ui.button(BTN_ACCOUNT_ADD).clicked() {
1871                shared.begin_account_edit(None);
1872            }
1873        } else {
1874            ui.label(
1875                egui::RichText::new(format!(
1876                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
1877                    crate::state::MAX_ACCOUNTS
1878                ))
1879                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1880            );
1881        }
1882    });
1883
1884    const ACCOUNT_HEADERS: [&str; 7] = [
1885        "网关(派生名)",
1886        "别名",
1887        "账号",
1888        "状态",
1889        "行情源",
1890        "收录(勾→实)",
1891        "操作",
1892    ];
1893    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
1894    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
1895    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
1896    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面。
1897    egui::Grid::new("account_grid")
1898        .striped(true)
1899        .num_columns(7)
1900        .show(ui, |ui| {
1901            for h in &ACCOUNT_HEADERS {
1902                ui.strong(*h);
1903            }
1904            ui.end_row();
1905        if accounts.is_empty() {
1906            ui.label("(无账户:上方『添加账户』开配置弹窗)");
1907            for _ in 0..6 {
1908                ui.label("");
1909            }
1910            ui.end_row();
1911        }
1912        for a in &accounts {
1913            let is_md = a.gateway == md;
1914            let name = row_label
1915                .get(&a.gateway)
1916                .cloned()
1917                .unwrap_or_else(|| a.gateway.clone());
1918            ui.label(a.gateway.clone());
1919            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
1920            ui.label(if a.alias.trim().is_empty() {
1921                "—".to_string()
1922            } else {
1923                a.alias.clone()
1924            });
1925            ui.label(a.account.clone());
1926            // 诚实标注(0.2.18 四态):界面能看见的只有**实例装配**(svctl.list 在场),
1927            // 登录成败 ek-ctp 无状态口可查(规划 §10 开放项 `ctp.status`,面板底部一句统一说明)——
1928            // 故 `Assembled` 的文案是「已装配·登录态未知」,不得写成「已连接」(规划 U5 缓解)。
1929            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
1930            // 会把一行撑成十几行高(s39 快照实锤过)。
1931            match a.stage {
1932                crate::state::AccountStage::AssembleFailed => {
1933                    ui.add(egui::Label::new(
1934                        egui::RichText::new(format!(
1935                            "装配失败:{}",
1936                            a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
1937                        ))
1938                        .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
1939                    )
1940                    .wrap_mode(egui::TextWrapMode::Extend));
1941                }
1942                stage => {
1943                    ui.add(
1944                        egui::Label::new(egui::RichText::new(stage.label()))
1945                            .wrap_mode(egui::TextWrapMode::Extend),
1946                    );
1947                }
1948            }
1949            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
1950            ui.horizontal(|ui| {
1951                if is_md {
1952                    ui.label(egui::RichText::new("● 行情源").strong());
1953                }
1954                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
1955                    ui.small_button(if is_md {
1956                        "已是行情源"
1957                    } else {
1958                        "设为行情源"
1959                    })
1960                });
1961                if resp.inner.clicked() {
1962                    match shared.set_md_source(&a.gateway) {
1963                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
1964                        Err(e) => shared.log_line(format!("[账户] {e}")),
1965                    }
1966                }
1967            });
1968            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
1969            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
1970            ui.vertical(|ui| {
1971                ui.add(
1972                    egui::Label::new(egui::RichText::new(format!(
1973                        "勾:{}",
1974                        classes_label(&a.form.product_classes())
1975                    )))
1976                    .wrap_mode(egui::TextWrapMode::Extend),
1977                );
1978                ui.add(
1979                    egui::Label::new(egui::RichText::new(format!(
1980                        "实:{}",
1981                        if a.effective_classes.is_empty() {
1982                            "—".to_string()
1983                        } else {
1984                            classes_label(&a.effective_classes)
1985                        }
1986                    )))
1987                    .wrap_mode(egui::TextWrapMode::Extend),
1988                );
1989            });
1990            // 操作:连接/重试(四态分离的『连接』入口)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
1991            ui.horizontal(|ui| {
1992                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
1993                let idle = a.stage == crate::state::AccountStage::NotAssembled;
1994                if failed || idle {
1995                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
1996                    // (旧口径"必须先移除才能重试"作废)。
1997                    let label = if failed {
1998                        format!("重试 {name}")
1999                    } else {
2000                        format!("连接 {name}")
2001                    };
2002                    if ui.small_button(label).clicked() {
2003                        push_connect(shared, &a.gateway, &a.form);
2004                    }
2005                }
2006                if ui.small_button(format!("编辑 {name}")).clicked() {
2007                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
2008                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
2009                    shared.begin_account_edit(Some(&a.gateway));
2010                }
2011                let open = snap
2012                    .orders
2013                    .iter()
2014                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
2015                    .count();
2016                let has_pos = snap
2017                    .positions
2018                    .iter()
2019                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2020                if open > 0 {
2021                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
2022                    ui.label(
2023                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2024                            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2025                    );
2026                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
2027                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
2028                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
2029                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2030                        for o in snap
2031                            .orders
2032                            .iter()
2033                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
2034                        {
2035                            shared.push_cmd(UiCmd::CancelOrder {
2036                                orderid: o.orderid.clone(),
2037                                gateway: a.gateway.clone(),
2038                            });
2039                        }
2040                        shared.log_line(format!(
2041                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2042                            a.gateway, open
2043                        ));
2044                    }
2045                } else {
2046                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
2047                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
2048                    let armed = shared.pending_confirm();
2049                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2050                    let fired = ui
2051                        .small_button(remove_button_label(
2052                            &name,
2053                            if armed_here { Some(name.as_str()) } else { None },
2054                        ))
2055                        .clicked()
2056                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2057                    if fired {
2058                        shared.push_cmd(UiCmd::Remove {
2059                            name: a.gateway.clone(),
2060                        });
2061                        shared.remove_account(&a.gateway);
2062                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
2063                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
2064                        shared.end_account_edit_if_editing(&a.gateway);
2065                        if has_pos {
2066                            shared.log_line(format!(
2067                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2068                                a.gateway
2069                            ));
2070                        }
2071                    }
2072                }
2073            });
2074            ui.end_row();
2075        }
2076    });
2077
2078    if let Some(gw) = shared.pending_confirm() {
2079        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
2080        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2081        ui.horizontal(|ui| {
2082            ui.label(
2083                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2084                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2085            );
2086            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2087                shared.confirm_cancel();
2088            }
2089        });
2090    }
2091
2092    ui.label(if locked {
2093        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2094    } else {
2095        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2096    });
2097    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
2098    ui.label(
2099        egui::RichText::new(
2100            "状态列只说实例装配(svctl 在场位);登录成败 / 掉线 / 查询断供界面不可见 —— 开放项 ctp.status",
2101        )
2102        .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2103    );
2104    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
2105    if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2106        if src.form.md_addr.trim().is_empty() {
2107            ui.label(
2108                egui::RichText::new(format!(
2109                    "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2110                    src.gateway,
2111                    row_label
2112                        .get(&src.gateway)
2113                        .cloned()
2114                        .unwrap_or_else(|| src.account.clone())
2115                ))
2116                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2117            );
2118        }
2119    }
2120}
2121
2122/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
2123/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
2124/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
2125/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
2126/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2127fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2128    let editing: Option<crate::state::AccountRow> =
2129        shared.editing_account().and_then(|g| shared.account(&g));
2130    let editing_on = editing.is_some();
2131    let md = shared.md_source();
2132    let locked = shared.accounts_locked();
2133    let mut form: crate::state::CtpForm = shared.ctp_form();
2134    let mut alias = shared.edit_alias();
2135    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
2136    if let Some(e) = shared.edit_error() {
2137        ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2138    }
2139    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
2140    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
2141    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
2142    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
2143    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
2144    ui.horizontal(|ui| {
2145        ui.label("别名");
2146        if ui
2147            .add(
2148                egui::TextEdit::singleline(&mut alias)
2149                    .hint_text("显示用,可空")
2150                    .desired_width(120.0),
2151            )
2152            .changed()
2153        {
2154            shared.set_edit_alias(alias.clone());
2155        }
2156        ui.label("broker");
2157        ui.add_enabled_ui(!editing_on, |ui| {
2158            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2159        });
2160        ui.label("td 前置");
2161        ctp_edit(
2162            ui,
2163            shared,
2164            &mut form.td_addr,
2165            "tcp://host:40001",
2166            CtpField::TdAddr,
2167        );
2168        ui.label("td 用户");
2169        ui.add_enabled_ui(!editing_on, |ui| {
2170            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2171        });
2172    });
2173    if let Some(r) = &editing {
2174        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
2175        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
2176        ui.label(
2177            egui::RichText::new(format!(
2178                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2179                r.gateway
2180            ))
2181            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2182        );
2183    }
2184    ui.horizontal(|ui| {
2185        ui.label("td 密码");
2186        ctp_edit(
2187            ui,
2188            shared,
2189            &mut form.td_password,
2190            "密码",
2191            CtpField::TdPassword,
2192        );
2193        ui.label("AppID");
2194        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2195        ui.label("AuthCode");
2196        ctp_edit(
2197            ui,
2198            shared,
2199            &mut form.td_auth_code,
2200            "授权码",
2201            CtpField::AuthCode,
2202        );
2203    });
2204    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 三格置灰。
2205    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
2206    let md_editable = match &editing {
2207        None => true,
2208        Some(r) => !locked || r.gateway == md,
2209    };
2210    ui.horizontal(|ui| {
2211        ui.label("md 前置");
2212        ui.add_enabled_ui(md_editable, |ui| {
2213            ctp_edit(
2214                ui,
2215                shared,
2216                &mut form.md_addr,
2217                "tcp://host:40011",
2218                CtpField::MdAddr,
2219            );
2220            ui.label("md 用户");
2221            ctp_edit(
2222                ui,
2223                shared,
2224                &mut form.md_user,
2225                "空=td 回落",
2226                CtpField::MdUser,
2227            );
2228            ui.label("md 密码");
2229            ctp_edit(
2230                ui,
2231                shared,
2232                &mut form.md_password,
2233                "空=td 回落",
2234                CtpField::MdPassword,
2235            );
2236        });
2237        if !md_editable {
2238            ui.label(
2239                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2240                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2241            );
2242        }
2243    });
2244    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
2245    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
2246    ui.horizontal(|ui| {
2247        ui.label("收录勾选:");
2248        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2249        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2250        if fut || opt {
2251            shared.set_ctp_form(form.clone());
2252        }
2253        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2254    });
2255    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
2256    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
2257    if let Some(r) = &editing {
2258        ui.horizontal(|ui| {
2259            ui.label("网关实际收录(只读回带):");
2260            ui.label(if r.effective_classes.is_empty() {
2261                "—(尚未装配或无回带)".to_string()
2262            } else {
2263                classes_label(&r.effective_classes)
2264            });
2265            ui.label("装配状态(只读):");
2266            ui.label(format!(
2267                "{}{}",
2268                r.stage.label(),
2269                r.fail_note
2270                    .as_ref()
2271                    .map(|n| format!(" —— {n}"))
2272                    .unwrap_or_default()
2273            ));
2274        });
2275    }
2276    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
2277    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2278    if shared.account_edit_dirty() {
2279        ui.label(
2280            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2281                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2282        );
2283    }
2284    ui.horizontal(|ui| {
2285        if ui.button("保存").clicked() {
2286            match save_account_draft(shared, &form, &alias, false) {
2287                Ok(()) => shared.end_account_edit(),
2288                Err(e) => {
2289                    shared.log_line(format!("[账户] 保存被拦:{e}"));
2290                    shared.set_edit_error(Some(e));
2291                }
2292            }
2293        }
2294        if ui.button("保存并连接").clicked() {
2295            match save_account_draft(shared, &form, &alias, true) {
2296                Ok(()) => shared.end_account_edit(),
2297                Err(e) => {
2298                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2299                    shared.set_edit_error(Some(e));
2300                }
2301            }
2302        }
2303        if ui.button("取消").clicked() {
2304            shared.end_account_edit();
2305        }
2306        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2307    });
2308}
2309
2310/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
2311fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2312    shared
2313        .accounts()
2314        .iter()
2315        .map(|a| a.gateway.clone())
2316        .collect()
2317}
2318
2319/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
2320///
2321/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
2322/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
2323/// 用户会把已摘除账户的委托当成在场账户的。
2324/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
2325/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
2326fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2327    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2328        gateway.to_string()
2329    } else {
2330        format!("{gateway}(已摘除)")
2331    }
2332}
2333
2334/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
2335fn classes_label(classes: &[String]) -> String {
2336    if classes.is_empty() {
2337        return "—".to_string();
2338    }
2339    classes
2340        .iter()
2341        .map(|c| match c.as_str() {
2342            "1" => "期货",
2343            "2" => "期权",
2344            other => other,
2345        })
2346        .collect::<Vec<_>>()
2347        .join("+")
2348}
2349
2350/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
2351///
2352/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
2353/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
2354fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2355    shared.push_cmd(UiCmd::Connect {
2356        gateway: gateway.to_string(),
2357        form: form.clone(),
2358    });
2359    shared.note_connect_sent(gateway);
2360    shared.log_line(format!(
2361        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2362        gateway
2363    ));
2364}
2365
2366/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
2367///
2368/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
2369/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
2370///
2371/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
2372fn save_account_draft(
2373    shared: &UiShared,
2374    form: &crate::state::CtpForm,
2375    alias: &str,
2376    connect: bool,
2377) -> Result<(), String> {
2378    form.validate()?;
2379    if let Some(gw) = shared.editing_account() {
2380        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
2381        if shared.account(&gw).is_none() {
2382            return Err(format!(
2383                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2384            ));
2385        }
2386        shared.update_account_form(&gw, form.clone());
2387        if !shared.set_account_alias(&gw, alias) {
2388            return Err(format!(
2389                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2390            ));
2391        }
2392        shared.log_line(format!(
2393            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2394        ));
2395        if connect {
2396            push_connect(shared, &gw, form);
2397        }
2398        return Ok(());
2399    }
2400    let mut row = crate::state::AccountRow::derive(form)?;
2401    row.alias = alias.trim().to_string();
2402    let gateway = row.gateway.clone();
2403    let account = row.account.clone();
2404    shared.add_account(row.clone())?;
2405    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
2406    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
2407    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
2408    let md = shared.md_source();
2409    let mut f = row.form.clone();
2410    if md.is_empty() && !shared.accounts_locked() {
2411        match shared.set_md_source(&gateway) {
2412            Ok(()) => {
2413                if f.md_addr.trim().is_empty() {
2414                    shared.log_line(format!(
2415                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2416                    ));
2417                } else {
2418                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2419                }
2420            }
2421            Err(e) => {
2422                shared.log_line(format!("[账户] {e}"));
2423                f.md_addr.clear();
2424                f.md_user.clear();
2425                f.md_password.clear();
2426            }
2427        }
2428    } else {
2429        f.md_addr.clear();
2430        f.md_user.clear();
2431        f.md_password.clear();
2432        if md.is_empty() {
2433            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2434        }
2435    }
2436    shared.update_account_form(&gateway, f.clone());
2437    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
2438    shared.log_line(format!(
2439        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2440    ));
2441    if connect {
2442        push_connect(shared, &gateway, &f);
2443    }
2444    Ok(())
2445}
2446
2447/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
2448/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
2449/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
2450fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2451    let mut q = shared.contract_query();
2452    ui.horizontal(|ui| {
2453        ui.label("代码或交易所(留空 = 全部):");
2454        if ui
2455            .add(
2456                egui::TextEdit::singleline(&mut q)
2457                    .hint_text("cu 或 SHFE")
2458                    .desired_width(200.0),
2459            )
2460            .changed()
2461        {
2462            shared.set_contract_query(q.clone());
2463        }
2464    });
2465    let rows = shared.contracts(&q);
2466    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
2467    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
2468    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2469    if rows.is_empty() {
2470        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
2471        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2472            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2473        });
2474        return;
2475    }
2476    let selected = shared.selected_contracts();
2477    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
2478    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
2479    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
2480    // 行溢出才出滚动条。
2481    ui.horizontal(|ui| {
2482        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2483        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2484            if selected.is_empty() {
2485                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2486            } else {
2487                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
2488                let symbols: Vec<String> = selected
2489                    .iter()
2490                    .map(|k| {
2491                        k.split_once('.')
2492                            .map(|(_, s)| s.to_string())
2493                            .unwrap_or_else(|| k.clone())
2494                    })
2495                    .collect();
2496                match subscribe_target(shared) {
2497                    Ok(gw) => {
2498                        shared.push_cmd(UiCmd::CtpSubscribe {
2499                            gateway: gw.clone(),
2500                            symbols,
2501                        });
2502                        shared.log_line(format!(
2503                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2504                            selected.len(),
2505                            gw
2506                        ));
2507                    }
2508                    Err(e) => shared.log_line(format!("[订阅] {e}")),
2509                }
2510            }
2511        }
2512    });
2513    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2514        for c in &rows {
2515            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2516            let mut on = selected.contains(&key);
2517            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
2518            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
2519            // 行选中(动作归行,不归某一列)。
2520            let row_sel = shared.row_selected("contracts", &key);
2521            let mut cells = Vec::with_capacity(5);
2522            let ck = ui.checkbox(&mut on, key.clone());
2523            if ck.changed() {
2524                shared.toggle_contract_selection(&key);
2525                shared.select_row("contracts", &key);
2526            }
2527            cells.push(ck.rect);
2528            macro_rules! cell {
2529                ($base:expr, $col:expr) => {{
2530                    let base = $base;
2531                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
2532                    if r.clicked() {
2533                        shared.select_row("contracts", &key);
2534                    }
2535                    cells.push(base.rect);
2536                }};
2537            }
2538            cell!(ui.label(c.exchange.as_str().to_string()), 1);
2539            cell!(ui.label(c.name.clone()), 2);
2540            cell!(ui.label(format!("{:.0}", c.size)), 3);
2541            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2542            paint_row_bg(ui, &cells, row_sel);
2543            ui.end_row();
2544        }
2545    });
2546}
2547
2548/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
2549enum CtpField {
2550    Broker,
2551    TdAddr,
2552    TdUser,
2553    TdPassword,
2554    AppId,
2555    AuthCode,
2556    MdAddr,
2557    MdUser,
2558    MdPassword,
2559}
2560
2561/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
2562fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2563    let is_secret = matches!(
2564        field,
2565        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2566    );
2567    let mut w = egui::TextEdit::singleline(val)
2568        .hint_text(hint)
2569        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
2570        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
2571        .desired_width(210.0);
2572    if is_secret {
2573        w = w.password(true);
2574    }
2575    if ui.add(w).changed() {
2576        let mut f = shared.ctp_form();
2577        match field {
2578            CtpField::Broker => f.broker_id = val.clone(),
2579            CtpField::TdAddr => f.td_addr = val.clone(),
2580            CtpField::TdUser => f.td_user = val.clone(),
2581            CtpField::TdPassword => f.td_password = val.clone(),
2582            CtpField::AppId => f.td_app_id = val.clone(),
2583            CtpField::AuthCode => f.td_auth_code = val.clone(),
2584            CtpField::MdAddr => f.md_addr = val.clone(),
2585            CtpField::MdUser => f.md_user = val.clone(),
2586            CtpField::MdPassword => f.md_password = val.clone(),
2587        }
2588        shared.set_ctp_form(f);
2589    }
2590}
2591
2592/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
2593///
2594/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
2595fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2596    let Some(c) = cell else {
2597        ui.label("(K线无格:等 dm 合成)");
2598        return;
2599    };
2600    if c.bars.is_empty() {
2601        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2602        return;
2603    }
2604    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
2605    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
2606    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
2607    let mut data = egui_charts::model::bar::BarData::new();
2608    for b in &c.bars {
2609        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2610            .with_timezone(&beijing_tz())
2611            .with_timezone(&chrono::Utc);
2612        data.push(egui_charts::model::bar::Bar::new(
2613            dt,
2614            b.open_price,
2615            b.high_price,
2616            b.low_price,
2617            b.close_price,
2618            b.volume,
2619        ));
2620    }
2621    ui.label(format!(
2622        "K线 {}@{}s({} 根)",
2623        c.symbol,
2624        c.interval,
2625        c.bars.len()
2626    ));
2627    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2628    chart.set_symbol(&c.symbol);
2629    chart.set_timeframe_label(&format!("{}s", c.interval));
2630    chart.enable_tracking_mode();
2631    let _ = chart.show(ui);
2632}
2633
2634#[cfg(test)]
2635mod tests {
2636    use super::*;
2637
2638    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
2639    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
2640    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
2641    #[test]
2642    fn fmt_dt_shows_beijing_wall_clock() {
2643        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2644            ek_data::time::civil_to_nanos(ek_data::time::Civil {
2645                year: 2026,
2646                month: 4,
2647                day: 16,
2648                hour: 1,
2649                minute: 30,
2650                second: 1,
2651                nanos: 0,
2652            }),
2653        );
2654        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
2655        let beijing = expect.with_timezone(&beijing_tz());
2656        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2657        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
2658        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
2659        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2660            year: 2026,
2661            month: 4,
2662            day: 15,
2663            hour: 16,
2664            minute: 0,
2665            second: 30,
2666            nanos: 0,
2667        });
2668        assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2669        // None → 占位
2670        assert_eq!(fmt_dt(None), "-");
2671    }
2672}