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, beijing_tz, 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
68/// F4 口径句(0.2.20,D-UI-F4-SCOPE):账户面板底部与配置弹窗**共用这一条常量**。
69/// 两处各写一遍字面量 = 将来不同式的坑(s57 数的是"两处都在")。
70pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
71
72fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
73    if armed_on == Some(name) {
74        format!("确认移除 {name} ?")
75    } else {
76        format!("移除 {name}")
77    }
78}
79
80/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
81///
82/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
83/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
84fn subscribe_target(shared: &UiShared) -> Result<String, String> {
85    let md = shared.md_source();
86    if md.is_empty() {
87        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
88    }
89    Ok(md)
90}
91
92fn state_text(r: &PlugRow) -> String {
93    r.state.clone()
94}
95
96// 0.3.1(Wave D,BD-UI-49):时间列格式收进 `crate::format::time_hms_millis`(`HH:MM:SS.mmm`)。
97// 口径没变:北京时间 +08,数据面时间戳统一存 UTC 纳秒 —— CTP 回报的自然日本就是北京钟面,
98// 入账按 +08 换算存 UTC,展示面换回来,表内时间列与交易员作息同钟面,且与日志同源。
99
100// 展示面统一时区 = `crate::state::beijing_tz`(0.2.27 提到 state:日志入环打戳
101// 与表格时间列必须同一个口径,一份实现两处共用)。这里不留 doc comment ——
102// 它后面没有 item,clippy `empty_lines_after_doc_comment` 正是钉这种。
103
104// ---- BD-UI-31(R3)行高亮:0.2.29 起由表格层承担(BD-UI-44)----
105// 原来这里有两支手绘助手:`paint_row_bg`(行底色 = 各格响应矩形并集 + 硬编码半透明蓝)
106// 和 `click_cell`(在标签矩形上叠一层点击感应)。换成 egui_extras::TableBuilder 后,
107// 选中/悬停底色 = `TableRow::set_selected` 与库自己的悬停跟踪(egui_extras layout.rs:137
108// 起:底色吃 `visuals.selection.bg_fill` / `widgets.hovered.bg_fill`,并按半间距补缝 =
109// 整行连续),格可点 = `TableBuilder::sense(Sense::click())` + `TableRow::col` 返回的
110// Response。两支助手撤除 = 少两处自己维护的几何,行底色也改跟主题(0.2.27 斑马纹同口径)。
111
112/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
113fn order_active(status: ek_data::constant::Status) -> bool {
114    ek_data::constant::ACTIVE_STATUSES.contains(&status)
115}
116
117/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
118/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
119/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
120pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
121    let mut f = shared.order_form();
122    f.symbol = t.symbol.clone();
123    f.exchange = exchange.to_string();
124    match col {
125        8 => {
126            f.direction = Direction::SHORT;
127            f.price = crate::format::price(t.bid_price_1);
128        }
129        9 => f.direction = Direction::SHORT,
130        10 => {
131            f.direction = Direction::LONG;
132            f.price = crate::format::price(t.ask_price_1);
133        }
134        11 => f.direction = Direction::LONG,
135        _ => {}
136    }
137    shared.set_order_form(f);
138}
139
140/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
141/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
142fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
143    if symbol.is_empty() {
144        return None;
145    }
146    shared.tick_rows().into_iter().find(|t| {
147        t.symbol == symbol
148            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
149    })
150}
151
152/// 字号 + 间距 + 控件规格 + 主题视觉(0.2.30 起全部走 `crate::tokens`;主题只有一套
153/// Qt深色,`Theme` 枚举与菜单循环钮已删 —— 裁定 8)。真实 App、独立子窗(immediate
154/// 共享同一 Context)、kittest 三处同源。
155/// 语义色(涨红跌绿 / 多红空绿 / 警告橙 / 错误深红 / 成功深绿 / 次级灰)不在这一层:
156/// 它们在 `tokens::semantic` 里恒定,渲染侧直绘,不随视觉档漂移(金融语义不玩配色)。
157pub fn apply_ui_style(ctx: &egui::Context) {
158    ctx.style_mut(|style| {
159        crate::tokens::apply(style);
160    });
161    ctx.set_visuals(crate::tokens::visuals());
162}
163
164/// 表格与页签的「chrome」配色(表头带、分隔线、tab 头底、选中 tab 下划线)。
165///
166/// 为什么要有这个结构:qdarkstyle 有 `QHeaderView::section`(表头带底 `#455364`)和
167/// `QTabBar::tab:top:selected`(选中 tab `#54687A` 底 + 3px `#259AE9` 下划线)这两个概念,
168/// **egui 的 `Visuals` 里没有对应字段**(它只有 `selection.bg_fill` 一根,且
169/// `selectable_label` 只能整块填充)。所以这一层只能在渲染侧直绘。
170///
171/// 取色口径(审计「多余」条):不新造颜色,全部从主题**已经声明过**的字段里取 ——
172/// 取出来正是 qss 原值(`noninteractive.bg_stroke` = `#455364` = `QHeaderView::section`
173/// 底;`hovered.bg_fill` = `#54687A` = 选中 tab 底;`hyperlink_color` = `#259AE9` =
174/// 选中 tab 下划线)。0.2.30 起主题只有一套,原来「按主题分叉」的那条 match 随之消失。
175#[derive(Clone, Copy)]
176struct Chrome {
177    /// 表头带底 + 一切分隔线/网格线(1px)。
178    line: egui::Color32,
179    /// 表头带文字。
180    header_text: egui::Color32,
181    /// 未选中 tab 的「头」底(qss `QTabBar::tab:top` 正是表头带同色)。
182    tab_head_fill: egui::Color32,
183    /// 选中 tab 的底。
184    tab_fill: egui::Color32,
185    /// 选中 tab 的文字。
186    tab_text: egui::Color32,
187    /// 选中 tab 的下划线(qss 3px 那支)。
188    tab_underline: egui::Color32,
189}
190
191impl Chrome {
192    /// 从当前 visuals 派生(唯一主题 → 唯一派生规则,不再按主题分叉)。
193    fn of(v: &egui::Visuals) -> Self {
194        let line = v.widgets.noninteractive.bg_stroke.color;
195        Self {
196            line,
197            header_text: v.widgets.noninteractive.fg_stroke.color,
198            tab_head_fill: line,
199            tab_fill: v.widgets.hovered.bg_fill,
200            tab_text: v.widgets.hovered.fg_stroke.color,
201            tab_underline: v.hyperlink_color,
202        }
203    }
204}
205
206/// 页签(BD-UI-41,对照 qdarkstyle `QTabBar::tab:top`)。
207///
208/// Python 侧四个区全是 QTabWidget(`mainwindow.py:106-204`,连只有一个 tab 的
209/// 「成交」「持仓」也带 tab 头),qss 给 tab 的形态是:
210///   未选中 `QTabBar::tab:top { background #455364; padding 2px 4px }` +
211///     `!selected { border-bottom: 2px solid #19232D }`(qss:1579 / 1559)
212///   选中   `QTabBar::tab:top:selected { background #54687A;
213///     border-bottom: 3px solid #259AE9; 上圆角 4px }`(qss:1592)
214/// egui 的 `selectable_label` 只有「整块 selection 填充」,画不出下划线,也没有
215/// 「未选中也有头底」这一层。所以这里照 pinned_table 表头带同一套路子:
216/// **控件仍走 selectable_label**(可访问性标签 / 点击 / 键盘都不换),只是随后用
217/// painter 盖上头底 + 下划线,并重画文字(epaint 0.33 没有 z-order,后画的填充
218/// 会盖住先画的文字,所以文字必须重画 —— 与表头带同一处理)。
219fn tab_label(ui: &mut egui::Ui, selected: bool, text: impl Into<String>) -> egui::Response {
220    let ch = Chrome::of(ui.visuals());
221    let text: String = text.into();
222    let resp = ui.selectable_label(selected, text.clone());
223    let r = resp.rect;
224    let radius = ui.visuals().widgets.inactive.corner_radius;
225    let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
226    let p = ui.painter();
227    let (fill, ink, underline) = if selected {
228        (
229            ch.tab_fill,
230            ch.tab_text,
231            egui::Stroke::new(3.0, ch.tab_underline), // qss 的 3px 那支
232        )
233    } else {
234        (
235            ch.tab_head_fill,
236            ui.visuals().widgets.inactive.fg_stroke.color,
237            egui::Stroke::new(2.0, ch.line), // qss 未选中 tab 的 2px 下边线
238        )
239    };
240    p.rect_filled(r, radius, fill);
241    p.hline(r.x_range(), r.max.y, underline);
242    p.text(
243        r.center(),
244        egui::Align2::CENTER_CENTER,
245        text.as_str(),
246        font,
247        ink,
248    );
249    resp
250}
251
252/// 普通 Grid 的表头带(BD-UI-41)。
253///
254/// 与 `pinned_table` 的覆盖带同一套路子:头行照常在 grid 里占格(列宽由它量出来,
255/// 这是唯一权威源),随后 painter 盖上带底 + 下边线并重画文字(epaint 0.33 无 z-order,
256/// 填充盖字 → 文字必须重画)。
257///
258/// 为什么这三处不走 `pinned_table`:它们是**自动尺寸浮动窗里的短表**(账户清单 /
259/// 运维插件表 / 服务目录)。0.2.17 实锤(s39):`pinned_table` =
260/// `auto_shrink(false)` 滚动区吃满整块可用高 + 覆盖式表头带,在自动尺寸浮动窗里
261/// 两者凑不出正确高度 → 表行与其后的口径句互相压字。账户行最多几个,不需要固定
262/// 表头滚动面,只要那根带。
263///
264/// 0.2.29(BD-UI-44)把这条边界写死:数据监控表(行情/委托/成交/持仓/日志/合约/资金)
265/// 一律 TableBuilder;自动尺寸浮动窗里的短表一律 Grid + 这根带。资金表原来在第三类里,
266/// 实测它和日志表共用同一块 tab 容器(不是浮动窗)→ 已并入 TableBuilder 一侧。
267fn header_band(ui: &mut egui::Ui, heads: &[(egui::Rect, String)], content_right: f32) {
268    if heads.is_empty() {
269        return;
270    }
271    let ch = Chrome::of(ui.visuals());
272    // 带的横向范围 = **整行**(这一格 / 这一窗的可用宽),不是表头文字矩形的并集。
273    // 根因(0.2.28 用户实看指出「运维窗表头背景没按整行、最后一列只有文字有背景」):
274    // `Response::rect` 是控件自己的矩形,不是列框(grid.rs:180-193 —— `next_cell`
275    // 给出列框,`align_size_within_rect` 按 LEFT_CENTER 对齐后返回子控件尺寸)。
276    // 表头 label 的 rect 宽 = 文字宽。原来按表头 rect 的并集算带 → 带只盖到
277    // 最后一列**表头文字**的右端,列内比它宽的部分(运维窗『操作』列的两个按钮、
278    // 服务目录的空实例列、资金格右侧)没有带 = 看上去断开一截。
279    // 口径照 Python:QHeaderView 铺满整个表视口;也与 `pinned_table` 那条带一致
280    // (那条铺的是 ScrollArea 的 inner_rect = 整格宽)。
281    let left = ui.max_rect().min.x;
282    // 0.2.30(BD-UI-47):带的右端取「可用宽」与**表体实际右端**的较大者。
283    // 浮动窗是自动尺寸的,而 `header_band` 在 Grid 之后才画 —— 令牌把控件交互宽
284    // 从 egui 缺省 128 收到 40 后,窗内 Grid 的实际宽度比窗的可用宽更宽(s69 实测
285    // 带 640 / 体表 727),带就盖不住最后一列。口径不变:带必须盖住整行。
286    let right = ui.max_rect().max.x.max(content_right);
287    let top = heads[0].0.min.y;
288    let bottom = heads[0].0.max.y + ui.spacing().item_spacing.y;
289    let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
290    let p = ui.painter();
291    p.rect_filled(strip, 0.0, ch.line);
292    p.hline(
293        strip.x_range(),
294        strip.max.y,
295        egui::Stroke::new(1.0, ch.line),
296    );
297    let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
298    let mid_y = (top + heads[0].0.max.y) * 0.5;
299    for (rect, text) in heads {
300        p.text(
301            egui::pos2(rect.min.x, mid_y),
302            egui::Align2::LEFT_CENTER,
303            text.as_str(),
304            font.clone(),
305            ch.header_text,
306        );
307    }
308}
309
310/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
311///
312/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
313/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
314/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
315/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
316/// Window 挂在所有面板之后。
317pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
318    apply_ui_style(ctx);
319    // 0.3.1(Wave D,BD-UI-49)退出确认:关窗先问一句(照 Python `closeEvent`)。
320    // 拦关闭用的是 egui 0.33 的 `ViewportCommand::CancelClose`;用户在窗里点「确认退出」
321    // 才置放行位并重发 `Close` —— 否则确认完也关不掉。
322    let close_requested = ctx.input(|i| i.viewport().close_requested());
323    if close_requested && !shared.exit_confirmed() && !shared.exit_confirm_open() {
324        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
325        shared.open_exit_confirm();
326    }
327    // 0.3.2(Wave E,BD-UI-50)告警 toast 每帧收口(队列常驻在 UiShared,不是这里的局部变量)。
328    crate::toast::drain_and_show(ctx, shared);
329    if shared.exit_confirm_open() {
330        egui::Window::new("退出确认")
331            .collapsible(false)
332            .resizable(false)
333            .show(ctx, |ui| {
334                ui.label("确认退出 ek-ui?");
335                ui.horizontal(|ui| {
336                    if ui.button("取消").clicked() {
337                        shared.close_exit_confirm();
338                    }
339                    if ui.button("确认退出").clicked() {
340                        shared.confirm_exit();
341                        shared.close_exit_confirm();
342                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
343                    }
344                });
345            });
346    }
347    // ---- 菜单栏(顶层面板,最先 = 最外) ----
348    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
349        render_menu_bar(ui, shared);
350    });
351
352    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
353    egui::TopBottomPanel::top("market_band")
354        .resizable(true)
355        .default_height(crate::tokens::MARKET_BAND_HEIGHT)
356        .show(ctx, |ui| {
357            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
358            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
359            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
360            egui::ScrollArea::both()
361                .id_salt("market_band_scroll")
362                .auto_shrink([false, false])
363                .show(ui, |ui| {
364                    render_market_band(ui, shared);
365                });
366        });
367
368    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
369    egui::SidePanel::left("order_side")
370        .resizable(true)
371        .default_width(crate::tokens::ORDER_SIDE_WIDTH)
372        .show(ctx, |ui| {
373            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
374            egui::ScrollArea::vertical()
375                .id_salt("order_side_scroll")
376                .auto_shrink([false, false])
377                .show(ui, |ui| {
378                    render_order_panel(ui, shared);
379                    ui.separator();
380                    render_book_frame(ui, shared);
381                });
382        });
383
384    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
385    egui::CentralPanel::default().show(ctx, |ui| {
386        crate::dock::show_monitor_dock(ui, shared);
387    });
388
389    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
390    render_windows(ctx, shared);
391}
392
393// 0.3.0(Wave C,BD-UI-48):`group_frame_plain`(BD-UI-34 的「预留矩形 + 描边」格容器)退役。下带四组的容器换 `egui_dock` 自己的
394// leaf 容器 —— 格边框与分隔条由 dock `Style` 给,而 dock 的 `Style` 只从 `tokens::apply`
395// 填好的 egui `Style` 派生(`Style::from_egui`),不新增第二套颜色。
396// 0.2.30 那条教训仍然成立并记在 DESIGN.md:egui 0.33.3 的 `Frame` 没有 `min_size`,
397// `Frame::paint` 的描边按**内容** min_rect 算(frame.rs:486)——内容少的格边框会跟着缩。
398
399/// 表头固定表(BD-UI-34 用户裁定「每个表的表头都要固定」;0.2.29 起实现换成
400/// `egui_extras::TableBuilder`,BD-UI-44)。
401///
402/// 为什么换(实拍毛病 2/3/5,对照 Python 的 QTableView):
403/// - **列宽跟内容**:手绘 Grid 的列宽只能取各格 `Response::rect` 的最大值,而
404///   `Response::rect` 是控件矩形不是列框(0.2.28 实锤,egui grid.rs:180-193)
405///   → 列宽只能"差不多",数字列对不齐。TableBuilder 的列宽是库自己量出来的
406///   (`Column::auto()` → `max_used_widths`,egui_extras table.rs:840-852)。
407/// - **表头天生固定**:表头行画在体表 ScrollArea 之外(egui_extras table.rs:746
408///   `ScrollArea::new([false, vscroll])` = 横向不滚、纵向滚)→ 0.2.8 起那块
409///   「ScrollArea 之后重画覆盖带 + 重画表头文字」的 DIY 全撤(当年 DIY 是因为
410///   egui 没有表;scroll_area.rs:412 那句「painting a sticky header on top of it」
411///   就是 DIY 的出处)。
412/// - **横向溢出**:成交表 14 列比格子宽,Python 出横向滚动条(毛病 5)。TableBuilder
413///   自己只开竖滚 → 整张表(表头 + 体表)包在一层横向 ScrollArea 里:表头跟着体表
414///   一起横滚(不错位),竖滚仍归表体(表头不动)。
415/// - **选中 / 悬停底色**改吃主题:`TableRow::set_selected` → egui_extras layout.rs:137
416///   用 `visuals.selection.bg_fill`,悬停用 `widgets.hovered.bg_fill`,且两者都按
417///   `expand2(0.5 * item_spacing)` 补缝(layout.rs:125-127)= 整行底色连续
418///   → 手绘 `paint_row_bg` 那支硬编码半透明蓝撤掉(与 0.2.27「斑马纹跟主题」同口径)。
419/// - **网格线**:列线 = 可拖列边界那根线(egui_extras table.rs:894-907,色 =
420///   `widgets.noninteractive.bg_stroke` = chrome 线色);行线 = 每行上报的行底 +
421///   半间距。只有合约表开:Python `widget.py:367` 的 `setShowGrid(False)` 只加在
422///   交易表上,合约表(`widget.py:2423` / `2621`)没关(`gridline-color:#455364`,qss:1882)。
423///
424/// body 闭包在表体内执行(行循环归闭包);行高由本模块统一算,body 只写每格内容。
425enum GridLines {
426    /// 不画网格线(Python 交易表口径)。
427    Plain,
428    /// 列线 + 行线(Python 合约表口径)。
429    Grid,
430}
431
432/// 行高 / 表头高 = 字号 + 6。Python QTableView 的 sizeHint ≈ 字体高 + 4~6;
433/// 0.2.28 之前 Grid 自然行高在 kittest 实测行间距 21 = 正文 14.7 + 6,同值。
434fn table_heights(ui: &egui::Ui) -> (f32, f32) {
435    let st = ui.style();
436    // 行高必须取「文字高 + 6」与**控件交互高令牌**的较大者。格里有钮 / 勾选框时
437    // TableBuilder 的实际行高 = max(请求值, 格内最高控件):请求值 22 < 实际 26 时,
438    // 行线公式按假行高算 —— 0.2.30 实锤:行线画在 y=514 而第 3 行文字中心在 511
439    // = 0.2.28 治过的毛病 8(行线压到文字中心上)复发。别把这个 max 拆掉。
440    let floor_h = crate::tokens::INTERACT_HEIGHT;
441    (
442        (st.text_styles[&egui::TextStyle::Body].size + 6.0).max(floor_h),
443        (st.text_styles[&egui::TextStyle::Small].size + 6.0).max(floor_h),
444    )
445}
446
447/// 格内容闭包的返回值里能不能取到一个 `Response`。
448/// 为什么要这个:egui 的点击只发给**一个**响应 —— `context.rs:1384`
449/// `let clicked = Some(id) == viewport.interact_widgets.clicked;` 比的是单个 Id,
450/// 后加的控件优先。格里有真控件(钮 / 勾选框)时,点击归那个控件,归不到
451/// `TableRow::col` 返回的「格响应」。所以行动作必须两边都看(0.2.29 实锤:
452/// 只看格响应 → 行情首列钮、委托撤单钮、合约勾选框三处点击全丢)。
453trait CellOutcome {
454    fn response(&self) -> Option<egui::Response>;
455}
456
457impl CellOutcome for egui::Response {
458    fn response(&self) -> Option<egui::Response> {
459        Some(self.clone())
460    }
461}
462
463impl CellOutcome for () {
464    fn response(&self) -> Option<egui::Response> {
465        None
466    }
467}
468
469impl<T: CellOutcome> CellOutcome for egui::InnerResponse<T> {
470    fn response(&self) -> Option<egui::Response> {
471        self.inner
472            .response()
473            .or_else(|| Some(self.response.clone()))
474    }
475}
476
477/// 一格:把调用点写的闭包交给库的 `TableRow::col`,并带回**两个**响应 ——
478/// 格响应(整格可点,`TableBuilder::sense` 给的)与格内容自己的响应(钮/勾选框)。
479/// 助手签名把「参数 = `&mut egui::Ui`」写死,调用点那个闭包(表达式形态
480/// `|ui| ui.label(..)`,本身返回 `egui::Response`)于是有了确定的期望类型 ——
481/// 参数类型推得出来;返回值在这里被助手吃掉(库的 `col` 要的是返回 `()` 的闭包,
482/// rustc 不会替你把 `Response` 隐式丢成 unit,它只提示「consider using a semicolon」)。
483/// 宏里套一层定义点闭包再塞 `$make` 时,期望类型来得太晚:调用点闭包的参数类型
484/// 推不出来(E0282,本轮实撞)。
485/// `right_align`(0.2.29,毛病 3「数字不成列」):数字列走 `Layout::right_to_left`
486/// → 同一列里每行数字的右边界落在格右缘,数字因此成列对齐。
487fn tcell<R: CellOutcome>(
488    row: &mut egui_extras::TableRow<'_, '_>,
489    right_align: bool,
490    make: impl FnOnce(&mut egui::Ui) -> R,
491) -> (egui::Response, Option<egui::Response>) {
492    let mut captured: Option<R> = None;
493    let (_, cell_r) = row.col(|ui| {
494        captured = Some(if right_align {
495            // 0.2.30:右对齐这件事的实现搬进 `ui_ext::num_cell`(UiExt 五支之一),
496            // 这里只留「要不要走数字格」这个开关。
497            crate::ui_ext::num_cell(ui, make)
498        } else {
499            make(ui)
500        });
501    });
502    let inner = captured.as_ref().and_then(|r| r.response());
503    (cell_r, inner)
504}
505
506/// body 闭包的形状(0.2.29):`(表体, 行高) -> 行数`。
507/// 行高由 `table_heights` 统一算;返回值 = 这一拍画了几行 —— 网格线按它算
508/// (N 行 → N-1 根行线)。
509///
510/// 为什么 body 直接吃 `egui_extras::TableBody` 而不是我们包一层上下文:包一层就是
511/// 双层高阶生命周期(bounded closure 里再套 bounded closure),调用点里
512/// `row.col(|ui| ..)` 的闭包参数类型推不出来(E0282,本轮实撞)。照库自己的形状传最稳。
513fn pinned_table(
514    ui: &mut egui::Ui,
515    table_id: &str,
516    headers: &[&str],
517    body: impl FnOnce(&mut egui_extras::TableBody<'_>, f32) -> usize,
518) -> Option<usize> {
519    pinned_table_impl(ui, table_id, headers, GridLines::Plain, None, false, body)
520}
521
522/// 0.3.1(Wave D,BD-UI-49)可排序表:`sort` = 当前排序键,返回**这一拍被点过的表头列**。
523/// 排序键由调用方从 `UiShared::table_sort` 取、点完表头再写回 `toggle_table_sort` --
524/// 排序只作用在「喂表前的快照副本」上(`sorted_rows`),数据模型不被界面排序改写。
525fn pinned_table_sorted(
526    ui: &mut egui::Ui,
527    table_id: &str,
528    headers: &[&str],
529    sort: Option<(usize, bool)>,
530    resize: bool,
531    body: impl FnOnce(&mut egui_extras::TableBody<'_>, f32) -> usize,
532) -> Option<usize> {
533    pinned_table_impl(ui, table_id, headers, GridLines::Plain, sort, resize, body)
534}
535
536/// 喂表前的快照副本排序(W2 的判据对象)。`key` 返回**可比较字符串**;数字列调用方给
537/// 零填充串(`{:020.4}`)保证字典序 == 数值序。`sort` 为 `None` = 原序,一行都不动。
538fn sorted_rows<'a, T>(
539    rows: &'a [T],
540    sort: Option<(usize, bool)>,
541    key: impl Fn(&T, usize) -> String,
542) -> Vec<&'a T> {
543    let mut out: Vec<&'a T> = rows.iter().collect();
544    if let Some((col, asc)) = sort {
545        out.sort_by(|a, b| {
546            let (ka, kb) = (key(a, col), key(b, col));
547            if asc { ka.cmp(&kb) } else { kb.cmp(&ka) }
548        });
549    }
550    out
551}
552
553/// 同上 + **体表网格线**(BD-UI-41)。口径照 Python:全仓只有合约表有网格线。
554fn pinned_table_grid(
555    ui: &mut egui::Ui,
556    table_id: &str,
557    headers: &[&str],
558    body: impl FnOnce(&mut egui_extras::TableBody<'_>, f32) -> usize,
559) -> Option<usize> {
560    pinned_table_impl(ui, table_id, headers, GridLines::Grid, None, false, body)
561}
562
563/// 成交行的展示字符串(`TRADE_HEADERS` 同序)。**排序键与「保存数据」CSV 共用这一份口径** ——
564/// 表里看见什么,导出来就是什么,不留第二套格式化。
565/// 0.3.3 Wave F:这一份的顺序就是 `TRADE_HEADERS` 的顺序(判据 F1 钉「内容与表头同列」,
566/// 两处不同序 = CSV 与屏幕不一致)。
567fn trade_cells(t: &ek_data::trade::TradeData) -> Vec<String> {
568    vec![
569        t.symbol.clone(),
570        if t.name.is_empty() {
571            "-".to_string()
572        } else {
573            t.name.clone()
574        },
575        t.exchange.as_str().to_string(),
576        t.direction
577            .map(|d| d.as_str().to_string())
578            .unwrap_or_else(|| "-".to_string()),
579        t.offset.as_str().to_string(),
580        crate::format::price(t.price),
581        crate::format::volume(t.volume),
582        crate::format::price(t.traded_amount),
583        crate::format::time_hms_millis(t.datetime),
584        t.tradeid.clone(),
585        t.orderid.clone(),
586        t.gateway_name.clone(),
587    ]
588}
589
590/// 排序键:数字列给**零填充串**(`{:020.4}`)保证字典序 == 数值序;文本列直接比。
591/// 注意不能直接拿 `trade_cells` 的显示串当排序键 —— "101" < "12.345" < "3.5" 是字典序,
592/// 不是价格序(本轮实撞,判据 W1 当场红出来的)。
593/// 列序 0.3.3 Wave F 跟着 `TRADE_HEADERS` 走:5 = 价格,6 = 数量,7 = 成交金额,8 = 时间。
594fn trade_sort_key(t: &ek_data::trade::TradeData, col: usize) -> String {
595    match col {
596        5 => format!("{:020.4}", t.price),
597        6 => format!("{:020.4}", t.volume),
598        7 => format!("{:020.4}", t.traded_amount),
599        8 => t.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
600        c => trade_cells(t).get(c).cloned().unwrap_or_default(),
601    }
602}
603
604/// 「保存数据」:当前表(已按界面排序键排好的那份副本)写成 CSV 到 `target/ek-ui-<表>.csv`。
605/// 只写界面看见的内容,不碰后端、不碰数据模型。
606fn save_table_csv<T>(
607    table: &str,
608    headers: &[&str],
609    rows: &[&T],
610    cells: impl Fn(&T) -> Vec<String>,
611) -> std::io::Result<()> {
612    let mut s = headers.join(",");
613    s.push('\n');
614    for r in rows {
615        s.push_str(&cells(r).join(","));
616        s.push('\n');
617    }
618    let dir = std::env::current_dir()?.join("target");
619    std::fs::create_dir_all(&dir)?;
620    std::fs::write(dir.join(format!("ek-ui-{table}.csv")), s)
621}
622
623fn pinned_table_impl(
624    ui: &mut egui::Ui,
625    table_id: &str,
626    headers: &[&str],
627    grid_lines: GridLines,
628    sort: Option<(usize, bool)>,
629    resize: bool,
630    add_body: impl FnOnce(&mut egui_extras::TableBody<'_>, f32) -> usize,
631) -> Option<usize> {
632    let mut clicked: Option<usize> = None;
633    let mut header_cells: Vec<(usize, egui::Rect)> = Vec::with_capacity(headers.len());
634    let ch = Chrome::of(ui.visuals());
635    let (row_h, head_h) = table_heights(ui);
636    let sp = ui.spacing().item_spacing;
637    let m = ui.visuals().clip_rect_margin;
638    // 表头带:整行宽(Python QHeaderView 铺满表视口;0.2.28 给浮动窗短表补的
639    // header_band 铺整行是同一件事)。画在表之前 = 在表头文字之下,零布局副作用
640    // (0.2.6「资金右多一根竖线」那类 min_rect 被撑宽的病在这里不会再犯)。
641    let left = ui.cursor().min.x - sp.x * 0.5;
642    let top = ui.cursor().top();
643    let band = egui::Rect::from_min_max(
644        egui::pos2(left, top - m),
645        egui::pos2(left + ui.available_width() + sp.x, top + head_h + sp.y),
646    );
647    ui.painter().rect_filled(band, 0.0, ch.line);
648    // 带底下边线:表头带与体表的分界(Python 的表头带与体表是两种底色,天然有界;
649    // 我们只有一根带,补线才有界)。
650    ui.painter()
651        .hline(band.x_range(), band.max.y, egui::Stroke::new(1.0, ch.line));
652    // 横向溢出:整张表(表头 + 体表)包在一层横向 ScrollArea 里,见模块头注释。
653    // 滚动条必须看得见(0.2.29 实锤,s21 + chk-0229 实拍):egui 缺省
654    // `scroll.foreground_color = false` → 把手色 = `visuals.bg_fill` = 体表色
655    // (scroll_area.rs:1418-1425)→ 条其实画了,但和体表同色 = 看不见 =
656    // 毛病 5「最后一列被切掉且没有横向滚动提示」只治了一半。
657    // Qt 口径:滚动条占位(不悬浮盖内容)+ 高对比把手。
658    {
659        let scroll = &mut ui.style_mut().spacing.scroll;
660        scroll.floating = false;
661        scroll.foreground_color = true;
662        scroll.bar_width = 10.0;
663    }
664    egui::ScrollArea::horizontal()
665        .id_salt(format!("{table_id}_hscroll"))
666        .auto_shrink([false, false])
667        .show(ui, |ui| {
668            // 斑马纹跟主题(0.2.27 口径):TableBuilder 缺省就是 visuals().striped
669            // (table.rs:482),这里明写出来,免得日后又被硬开成 striped(true)。
670            // (先取出来:TableBuilder::new 已把 ui 可变借走,链上再读 ui 会撞借用检查。)
671            let striped = ui.visuals().striped;
672            let mut tb = egui_extras::TableBuilder::new(ui)
673                .id_salt(table_id)
674                .striped(striped)
675                .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
676                .sense(egui::Sense::click())
677                .auto_shrink([false, false])
678                // 列分隔线只给合约表(它也是全仓唯一能拖列宽的表:拖列边界改宽度 +
679                // 双击列边界重新按内容自适应,table.rs:864-888)。其余表不给线 =
680                // Python `setShowGrid(False)` 的口径。
681                .resizable(matches!(grid_lines, GridLines::Grid) || resize);
682            for _ in 0..headers.len() {
683                tb = tb.column(egui_extras::Column::auto().at_least(24.0));
684            }
685            tb.header(head_h, |mut hr| {
686                for (i, h) in headers.iter().enumerate() {
687                    // 表头比正文小一档(Python:13px vs 14.7px)。
688                    // 0.3.1(Wave D,BD-UI-49):表头可点 = 排序入口(照 Python QTableView 单击表头
689                    // 排序),当前排序列在表头文字后挂 ▲/▼(Python 同样是表头带箭头)。
690                    let mark = match sort {
691                        Some((c, asc)) if c == i => {
692                            if asc {
693                                " ▲"
694                            } else {
695                                " ▼"
696                            }
697                        }
698                        _ => "",
699                    };
700                    // 表头格只有 hover 感(egui_extras 的 `.sense(click())` 作用在体表格,表头格
701                    // 不吃 —— 本轮两次实撞:先「点表头没反应」,再「在格内 interact 也没反应」)。
702                    // 正解:先把每格的矩形收下来,表建完以后在同一层里补 interact。
703                    let (cell_rect, _) = hr.col(|ui| {
704                        ui.label(egui::RichText::new(format!("{h}{mark}")).strong().small());
705                    });
706                    header_cells.push((i, cell_rect));
707                }
708            })
709            .body(|mut body| {
710                let n_rows = add_body(&mut body, row_h);
711                if matches!(grid_lines, GridLines::Grid) {
712                    // 行线 = 相邻两行的间隙中点。行高是显式给定的(不是量出来的),
713                    // 所以「表体顶 + (k+1)*行高 + k*行间距 + 半行间距」就在缝里 ——
714                    // 0.2.28 那套按行数等分推算(毛病 8 的根因:把行线画到文字中心上)
715                    // 在这里没有存在必要。N 行 → N-1 根。
716                    // 表体顶 = `max_rect().min.y`:StripLayout::new 取 ui 的
717                    // available_rect_before_wrap 左上角 = 第一行的左上角(layout.rs:64-71)。
718                    let top = body.max_rect().min.y;
719                    let x = body.max_rect().x_range();
720                    // 已知遗留(0.2.30 记录,交接报告 §7.1 待办):`row_h` 是
721                    // `table_heights` 算出的**请求**行高,而 TableBuilder 实际行高取
722                    // max(请求值, 格内最高控件)—— 令牌把控件交互高从 egui 缺省 18 提到
723                    // 26 后两者不再相等,行线相对真实行边界上漂(实测 6 px,不压文字中心)。
724                    // 本轮不追:追它要同时改 `table_heights` 与每格控件高度,牵连 15 张
725                    // 快照与三张浮动窗快照。判据 s72 已按「缺口 ≤ 8 px」钉住,不许变大。
726                    let p = body.ui_mut().painter();
727                    for k in 0..n_rows.saturating_sub(1) {
728                        let k = k as f32;
729                        let y = top + (k + 1.0) * row_h + k * sp.y + sp.y * 0.5;
730                        p.hline(x, y, egui::Stroke::new(1.0, ch.line));
731                    }
732                }
733            });
734            // 表建完,在同一层(横向 ScrollArea 的内层 ui)给每格表头补一次点击感。
735            // 必须在这里做:格内的 ui 被 TableBuilder 借走,而且坐标要落在同一层里才对得上。
736            for (i, rect) in &header_cells {
737                let id = ui.id().with(("ek_table_header", table_id, *i));
738                if ui.interact(*rect, id, egui::Sense::click()).clicked() {
739                    clicked = Some(*i);
740                }
741            }
742        });
743    clicked
744}
745
746// ================= 菜单栏 =================
747
748fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
749    ui.horizontal(|ui| {
750        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
751        if ui.button(MENU_OPS).clicked() {
752            shared.set_ops_window_open(true);
753        }
754        if ui.button(MENU_ACCOUNTS).clicked() {
755            shared.set_accounts_window_open(true);
756        }
757        if ui.button(MENU_CONTRACTS).clicked() {
758            shared.set_contracts_window_open(true);
759        }
760        // 0.3.0(Wave C,BD-UI-48):dock 布局可复位 —— 拖乱了有一键回默认 2×2。
761        if ui.button("恢复布局").clicked() {
762            *shared.dock_tree() = Some(crate::dock::default_tree());
763        }
764        ui.menu_button(MENU_HELP, |ui| {
765            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
766            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
767        });
768        // 0.2.30(裁定 8,BD-UI-46):主题一键循环钮删除。界面只有一套 Qt深色
769        // (qdarkstyle 3.2.3 复刻),「可换主题」这件事本身从产品面上撤掉了。
770    });
771}
772
773// ================= 行情带 =================
774
775fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
776    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
777    let groups = shared.tick_groups();
778    if groups.is_empty() {
779        ui.label("(等待 eTick 订阅)");
780        return;
781    }
782    // 二级 tab:只列有 tick 的所
783    let sel = shared.selected_exchange();
784    let active = if groups.iter().any(|(e, _)| *e == sel) {
785        sel.clone()
786    } else {
787        groups[0].0.clone()
788    };
789    ui.horizontal(|ui| {
790        for (ex, _) in &groups {
791            if tab_label(ui, *ex == active, ex.clone()).clicked() {
792                shared.set_selected_exchange(ex.clone());
793            }
794        }
795    });
796    let ticks = groups
797        .iter()
798        .find(|(e, _)| *e == active)
799        .map(|(_, v)| v.clone())
800        .unwrap_or_default();
801    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
802    // 不需要,行情带 tab + 表格即全部)。
803    // 0.3.3(收 Wave D 欠账):行情带也吃「点表头排序」(缺省序 = 按所/symbol)。
804    let sort = shared.table_sort("tick_grid");
805    let ticks = sorted_rows(&ticks, sort, tick_sort_key);
806    let clicked = pinned_table_sorted(
807        ui,
808        "tick_grid",
809        &[
810            "代码",
811            "名称",
812            "最新价",
813            "涨跌幅",
814            "成交量",
815            "今开",
816            "最高",
817            "最低",
818            "买1价",
819            "买1量",
820            "卖1价",
821            "卖1量",
822            "时间",
823            "行情源",
824        ],
825        sort,
826        false,
827        |body, row_h| {
828            const RIGHT_COLS: [usize; 10] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
829            for t in &ticks {
830                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
831                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
832                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
833                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
834                let row_sel = shared.order_form().symbol == t.symbol;
835                body.row(row_h, |mut row| {
836                    // 选中底色改吃主题(0.2.29,BD-UI-44):`set_selected` → egui_extras
837                    // layout.rs:137 用 `visuals.selection.bg_fill`,并按半间距补缝 = 整行
838                    // 连续 → 手绘 `paint_row_bg`(硬编码半透明蓝)撤除。
839                    row.set_selected(row_sel);
840                    let mut dbl = false;
841                    macro_rules! cell {
842                        ($make:expr, $col:expr) => {{
843                            let (cell_r, inner_r) =
844                                tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
845                            let hit =
846                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
847                            if hit {
848                                shared.set_selected_exchange(active.clone());
849                                import_tick(shared, t, &active, $col);
850                            }
851                            dbl |= cell_r.double_clicked()
852                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
853                        }};
854                    }
855                    cell!(|ui| ui.button(t.symbol.clone()), 0);
856                    cell!(
857                        |ui| ui.label(if t.name.is_empty() {
858                            "-".to_string()
859                        } else {
860                            t.name.clone()
861                        }),
862                        1
863                    );
864                    cell!(|ui| ui.label(crate::format::price(t.last_price)), 2);
865                    cell!(
866                        |ui| ui.label(
867                            egui::RichText::new(crate::format::pct(t.pct_change))
868                                .color(crate::tokens::rise_fall(t.pct_change)),
869                        ),
870                        3
871                    );
872                    cell!(|ui| ui.label(crate::format::volume(t.volume)), 4);
873                    cell!(|ui| ui.label(crate::format::price(t.open_price)), 5);
874                    cell!(|ui| ui.label(crate::format::price(t.high_price)), 6);
875                    cell!(|ui| ui.label(crate::format::price(t.low_price)), 7);
876                    cell!(
877                        |ui| ui.label(
878                            egui::RichText::new(crate::format::price(t.bid_price_1))
879                                .color(crate::tokens::semantic::RISE),
880                        ),
881                        8
882                    );
883                    cell!(
884                        |ui| ui.label(
885                            egui::RichText::new(crate::format::volume(t.bid_volume_1))
886                                .color(crate::tokens::semantic::RISE),
887                        ),
888                        9
889                    );
890                    cell!(
891                        |ui| ui.label(
892                            egui::RichText::new(crate::format::price(t.ask_price_1))
893                                .color(crate::tokens::semantic::FALL),
894                        ),
895                        10
896                    );
897                    cell!(
898                        |ui| ui.label(
899                            egui::RichText::new(crate::format::volume(t.ask_volume_1))
900                                .color(crate::tokens::semantic::FALL),
901                        ),
902                        11
903                    );
904                    cell!(
905                        |ui| ui.label(if t.update_time.is_empty() {
906                            crate::format::time_hms_millis(Some(t.datetime))
907                        } else {
908                            t.update_time.clone()
909                        }),
910                        12
911                    );
912                    cell!(|ui| ui.label(t.gateway_name.clone()), 13);
913                    if dbl {
914                        shared.open_kline(t.symbol.clone());
915                    }
916                });
917            }
918            ticks.len()
919        },
920    );
921    if let Some(col) = clicked {
922        shared.toggle_table_sort("tick_grid", col);
923    }
924}
925
926/// 行情行的排序键(列序 == 行情带 14 列):数字列零填充,时间列纳秒零填充。
927fn tick_sort_key(t: &ek_data::tick::TickData, col: usize) -> String {
928    match col {
929        0 => t.symbol.clone(),
930        1 => t.name.clone(),
931        2 => format!("{:020.4}", t.last_price),
932        3 => format!("{:020.4}", t.pct_change),
933        4 => format!("{:020.4}", t.volume),
934        5 => format!("{:020.4}", t.open_price),
935        6 => format!("{:020.4}", t.high_price),
936        7 => format!("{:020.4}", t.low_price),
937        8 => format!("{:020.4}", t.bid_price_1),
938        9 => format!("{:020.4}", t.bid_volume_1),
939        10 => format!("{:020.4}", t.ask_price_1),
940        11 => format!("{:020.4}", t.ask_volume_1),
941        12 => {
942            if t.update_time.is_empty() {
943                format!("{:025}", t.datetime)
944            } else {
945                t.update_time.clone()
946            }
947        }
948        13 => t.gateway_name.clone(),
949        _ => String::new(),
950    }
951}
952
953// ================= 下单面板(常驻) =================
954
955fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
956    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
957    let mut f = shared.order_form();
958    let before = f.clone();
959    let mut err: Option<String> = None;
960
961    // 随价勾选:价格框随该合约最新 tick(勾选态)
962    if f.follow_price {
963        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
964            if t.last_price > 0.0 {
965                f.price = crate::format::price(t.last_price);
966            }
967        }
968    }
969
970    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
971    let mut ex_opts: Vec<String> = shared
972        .tick_groups()
973        .iter()
974        .map(|(e, _)| e.clone())
975        .collect();
976    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
977        if !ex_opts.iter().any(|x| x == e) {
978            ex_opts.push(e.to_string());
979        }
980    }
981    if !ex_opts.contains(&f.exchange) {
982        ex_opts.insert(0, f.exchange.clone());
983    }
984    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
985    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
986    let ts = shared.trade_snap();
987    let mut gw_opts: Vec<String> = Vec::new();
988    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
989    // 进了下拉,选它下单只会撞 `unknown:gateway`。
990    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
991    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
992    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
993    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
994    let known_gws = account_gateways(shared);
995    for g in shared
996        .accounts()
997        .iter()
998        .filter(|a| a.connected)
999        .map(|a| a.gateway.clone())
1000        .chain(
1001            ts.orders
1002                .iter()
1003                .map(|o| o.gateway_name.clone())
1004                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
1005                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
1006                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
1007        )
1008    {
1009        if !g.is_empty() && !gw_opts.contains(&g) {
1010            gw_opts.push(g);
1011        }
1012    }
1013    if !gw_opts.contains(&f.gateway)
1014        && !f.gateway.is_empty()
1015        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
1016    {
1017        gw_opts.insert(0, f.gateway.clone());
1018    }
1019    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
1020    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
1021    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
1022        f.gateway = shared.default_gateway().unwrap_or_default();
1023    }
1024    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
1025    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
1026    if f.gateway.is_empty() {
1027        if let Some(def) = shared.default_gateway() {
1028            f.gateway = def;
1029        }
1030    }
1031    let no_accounts = gw_opts.is_empty();
1032
1033    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
1034    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
1035    egui::Grid::new("order_form_grid")
1036        .num_columns(2)
1037        .show(ui, |ui| {
1038            ui.label("交易所");
1039            egui::ComboBox::from_id_salt("of_exchange")
1040                .selected_text(f.exchange.clone())
1041                .show_ui(ui, |ui| {
1042                    for e in &ex_opts {
1043                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
1044                    }
1045                });
1046            ui.end_row();
1047            ui.label("代码");
1048            let sym_resp = ui.add(
1049                egui::TextEdit::singleline(&mut f.symbol)
1050                    .hint_text("cu2709")
1051                    .desired_width(150.0),
1052            );
1053            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
1054            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
1055            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
1056            // has_focus 口径回车订阅从不触发)。
1057            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1058                let s = f.symbol.trim().to_string();
1059                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
1060                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
1061                let bare = match s.split_once('.') {
1062                    Some((_, b)) => b.trim().to_string(),
1063                    None => s,
1064                };
1065                if !bare.is_empty() {
1066                    f.symbol = bare.clone();
1067                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
1068                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
1069                    match subscribe_target(shared) {
1070                        Ok(gw) => {
1071                            shared.push_cmd(UiCmd::CtpSubscribe {
1072                                gateway: gw.clone(),
1073                                symbols: vec![bare.clone()],
1074                            });
1075                            shared
1076                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
1077                        }
1078                        Err(e) => shared.log_line(format!("[订阅] {e}")),
1079                    }
1080                }
1081            }
1082            ui.end_row();
1083            ui.label("名称");
1084            ui.label(
1085                tick_of(shared, &f.exchange, &f.symbol)
1086                    .map(|t| {
1087                        if t.name.is_empty() {
1088                            "-".to_string()
1089                        } else {
1090                            t.name
1091                        }
1092                    })
1093                    .unwrap_or_else(|| "-".to_string()),
1094            );
1095            ui.end_row();
1096            ui.label("方向");
1097            egui::ComboBox::from_id_salt("of_direction")
1098                .selected_text(f.direction.as_str())
1099                .show_ui(ui, |ui| {
1100                    for d in [Direction::LONG, Direction::SHORT] {
1101                        ui.selectable_value(&mut f.direction, d, d.as_str());
1102                    }
1103                });
1104            ui.end_row();
1105            ui.label("开平");
1106            ui.horizontal(|ui| {
1107                egui::ComboBox::from_id_salt("of_offset")
1108                    .selected_text(f.offset.as_str())
1109                    .show_ui(ui, |ui| {
1110                        for o in [
1111                            Offset::OPEN,
1112                            Offset::CLOSE,
1113                            Offset::CLOSETODAY,
1114                            Offset::CLOSEYESTERDAY,
1115                        ] {
1116                            ui.selectable_value(&mut f.offset, o, o.as_str());
1117                        }
1118                    });
1119                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
1120                ui.checkbox(&mut f.auto_split, "自动开平");
1121            });
1122            ui.end_row();
1123            ui.label("类型");
1124            egui::ComboBox::from_id_salt("of_order_type")
1125                .selected_text(f.order_type.as_str())
1126                .show_ui(ui, |ui| {
1127                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
1128                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
1129                    }
1130                });
1131            ui.end_row();
1132            ui.label("价格");
1133            ui.horizontal(|ui| {
1134                ui.add(
1135                    egui::TextEdit::singleline(&mut f.price)
1136                        .hint_text("107660")
1137                        .desired_width(90.0),
1138                );
1139                ui.checkbox(&mut f.follow_price, "随价");
1140            });
1141            ui.end_row();
1142            ui.label("数量");
1143            ui.add(
1144                egui::TextEdit::singleline(&mut f.volume)
1145                    .hint_text("1")
1146                    .desired_width(90.0),
1147            );
1148            ui.end_row();
1149            ui.label("接口");
1150            egui::ComboBox::from_id_salt("of_gateway")
1151                .selected_text(if f.gateway.is_empty() {
1152                    "(未选账户)".to_string()
1153                } else {
1154                    f.gateway.clone()
1155                })
1156                .show_ui(ui, |ui| {
1157                    for g in &gw_opts {
1158                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
1159                    }
1160                });
1161            ui.end_row();
1162            ui.label("操作");
1163            ui.horizontal(|ui| {
1164                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
1165                let send = ui
1166                    .add_enabled_ui(!no_accounts, |ui| {
1167                        crate::ui_ext::primary_button(ui, BTN_SEND_ORDER)
1168                    })
1169                    .inner
1170                    .clicked();
1171                if send {
1172                    match f.to_request() {
1173                        Ok(req) => {
1174                            let fallback = shared.default_gateway();
1175                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
1176                                Ok(gateway) => {
1177                                    shared.push_cmd(UiCmd::SendOrder {
1178                                        req,
1179                                        gateway,
1180                                        mode: if f.auto_split {
1181                                            ek_oms::LockMode::Auto
1182                                        } else {
1183                                            ek_oms::LockMode::None
1184                                        },
1185                                    });
1186                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
1187                                }
1188                                Err(e) => {
1189                                    shared.log_line(format!("[下单] {e}"));
1190                                    err = Some(e);
1191                                }
1192                            }
1193                        }
1194                        Err(e) => {
1195                            shared.log_line(format!("[下单] {e}"));
1196                            err = Some(e);
1197                        }
1198                    }
1199                }
1200                if ui.button(BTN_CANCEL_ALL).clicked() {
1201                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
1202                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
1203                    let mut n = 0usize;
1204                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
1205                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
1206                        let gw = if !o.gateway_name.is_empty() {
1207                            o.gateway_name.clone()
1208                        } else {
1209                            shared.default_gateway().unwrap_or_default()
1210                        };
1211                        if gw.is_empty() {
1212                            continue;
1213                        }
1214                        shared.push_cmd(UiCmd::CancelOrder {
1215                            orderid: o.orderid.clone(),
1216                            gateway: gw,
1217                        });
1218                        n += 1;
1219                    }
1220                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
1221                }
1222            });
1223            ui.end_row();
1224            crate::ui_ext::muted_label(ui, "代码回车 = 订阅");
1225            ui.label("");
1226            ui.end_row();
1227        });
1228    if let Some(e) = err {
1229        ui.label(egui::RichText::new(e).color(crate::tokens::semantic::ERROR));
1230    }
1231    if no_accounts {
1232        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
1233        ui.label(
1234            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1235                .color(crate::tokens::semantic::WARN),
1236        );
1237    }
1238    if f != before {
1239        shared.set_order_form(f.clone());
1240    }
1241}
1242
1243/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
1244/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
1245/// 行情带跳色行指认,盘口帧不重复报幕)。
1246/// 盘口买 / 卖侧色。**仍是硬编码**(§7.1 记的两处:卖 `0x2E9E2E` / 买 `0xE05050`),
1247/// Wave B 的设计令牌层接管后这两支并进语义色,这里先集中成常量免得散落。
1248const BOOK_ASK_COLOR: egui::Color32 = crate::tokens::semantic::FALL;
1249const BOOK_BID_COLOR: egui::Color32 = crate::tokens::semantic::RISE;
1250
1251/// 盘口一行 = 三格:档位标签 / 价 / 量。价与量走右对齐(与数据表数字列同一口径)。
1252fn book_row(
1253    row: &mut egui_extras::TableRow<'_, '_>,
1254    tag: String,
1255    color: egui::Color32,
1256    price: f64,
1257    volume: f64,
1258) {
1259    let _ = tcell(row, false, |ui| {
1260        ui.label(egui::RichText::new(tag).color(color));
1261    });
1262    let _ = tcell(row, true, |ui| {
1263        ui.label(egui::RichText::new(crate::format::price(price)).color(color));
1264    });
1265    let _ = tcell(row, true, |ui| {
1266        ui.label(egui::RichText::new(crate::format::volume(volume)).color(color));
1267    });
1268}
1269
1270fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1271    let f = shared.order_form();
1272    let Some(t) = tick_of(shared, &f.exchange, &f.symbol) else {
1273        crate::ui_ext::muted_label(ui, "(无 tick)");
1274        return;
1275    };
1276    // 0.2.29 Wave A 尾巴(BD-UI-45,毛病 6 的本体):五档盘口原来是一支文本
1277    // `ui.label("卖5  101.5  3")` —— 价与量靠**空格**对齐,位数一变(101.5 → 12345.5)
1278    // 整块就错位、贴左缘。换成 TableBuilder 三列(档 / 价 / 量),价与量右对齐成列。
1279    // 这里**不**用 `pinned_table`:下单侧是窄列 + 外层已有 ScrollArea,`pinned_table`
1280    // 的 `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训同型)。
1281    // 表头也不加:Python 侧没有五档盘口这个块(只有行情表的买1价/买1量/卖1价/卖1量
1282    // 四列),这是我们自加的常驻盘口,保持无表头。
1283    let (row_h, _head_h) = table_heights(ui);
1284    let asks: [(f64, f64); 5] = [
1285        (t.ask_price_5, t.ask_volume_5),
1286        (t.ask_price_4, t.ask_volume_4),
1287        (t.ask_price_3, t.ask_volume_3),
1288        (t.ask_price_2, t.ask_volume_2),
1289        (t.ask_price_1, t.ask_volume_1),
1290    ];
1291    let bids: [(f64, f64); 5] = [
1292        (t.bid_price_1, t.bid_volume_1),
1293        (t.bid_price_2, t.bid_volume_2),
1294        (t.bid_price_3, t.bid_volume_3),
1295        (t.bid_price_4, t.bid_volume_4),
1296        (t.bid_price_5, t.bid_volume_5),
1297    ];
1298    egui_extras::TableBuilder::new(ui)
1299        .id_salt("book_grid")
1300        .striped(false)
1301        .auto_shrink([true, true])
1302        .column(egui_extras::Column::auto())
1303        .column(egui_extras::Column::auto())
1304        .column(egui_extras::Column::auto())
1305        .body(|mut body| {
1306            for (k, (p, v)) in asks.iter().enumerate() {
1307                body.row(row_h, |mut row| {
1308                    book_row(&mut row, format!("卖{}", 5 - k), BOOK_ASK_COLOR, *p, *v);
1309                });
1310            }
1311            for (k, (p, v)) in bids.iter().enumerate() {
1312                body.row(row_h, |mut row| {
1313                    book_row(&mut row, format!("买{}", k + 1), BOOK_BID_COLOR, *p, *v);
1314                });
1315            }
1316        });
1317}
1318
1319// ================= 2×2 监控格 =================
1320
1321pub(crate) fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
1322    let tab = shared.topleft_tab();
1323    ui.horizontal(|ui| {
1324        if tab_label(ui, tab == TopLeftTab::Orders, TopLeftTab::Orders.label()).clicked() {
1325            shared.set_topleft_tab(TopLeftTab::Orders);
1326        }
1327        if tab_label(ui, tab == TopLeftTab::Active, TopLeftTab::Active.label()).clicked() {
1328            shared.set_topleft_tab(TopLeftTab::Active);
1329        }
1330    });
1331    render_orders_table(ui, shared, tab == TopLeftTab::Active);
1332}
1333
1334/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
1335/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
1336/// 活动行撤单钮 + 双击撤单,终态行只读)。
1337fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1338    let ts = shared.trade_snap();
1339    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1340    let known_gws = account_gateways(shared);
1341    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
1342    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
1343    let rows: Vec<_> = ts
1344        .orders
1345        .iter()
1346        .filter(|o| !active_only || order_active(o.status))
1347        .collect();
1348    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1349    const ORDER_HEADERS: [&str; 17] = [
1350        "代码",
1351        "名称",
1352        "交易所",
1353        "类型",
1354        "方向",
1355        "开平",
1356        "价格",
1357        "总量",
1358        "已成",
1359        "成交均价",
1360        "状态",
1361        "状态描述",
1362        "时间",
1363        "委托号",
1364        "来源",
1365        "接口",
1366        "操作",
1367    ];
1368    // 0.3.3(收 Wave D 欠账):委托表也吃「点表头排序」—— 表 id 先算出来,
1369    // 空态与非空态共用一份(排序键住在 UiShared,按表 id 分槽)。
1370    let table_id = if active_only {
1371        "active_grid"
1372    } else {
1373        "order_grid"
1374    };
1375    let sort = shared.table_sort(table_id);
1376    if rows.is_empty() {
1377        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
1378        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
1379        pinned_table(ui, table_id, &ORDER_HEADERS, |body, row_h| {
1380            body.row(row_h, |mut row| {
1381                row.col(|ui| {
1382                    ui.label(if active_only {
1383                        "(无未成交)"
1384                    } else {
1385                        "(无委托:等网关 send_order)"
1386                    });
1387                });
1388            });
1389            1
1390        });
1391        return;
1392    }
1393    // 排序只作用在「喂表前的快照副本」上(W2 口径,数据模型不被界面排序改写)。
1394    let rows = sorted_rows(&rows, sort, |o, col| order_sort_key(o, col));
1395    let clicked = pinned_table_sorted(ui, table_id, &ORDER_HEADERS, sort, false, |body, row_h| {
1396        const RIGHT_COLS: [usize; 4] = [6, 7, 8, 9];
1397        for o in &rows {
1398            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1399            // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
1400            // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
1401            let key = format!("{}.{}", o.gateway_name, o.orderid);
1402            let row_sel = shared.row_selected("orders", &key);
1403            body.row(row_h, |mut row| {
1404                row.set_selected(row_sel);
1405                let mut dbl = false;
1406                macro_rules! cell {
1407                    ($make:expr, $col:expr) => {{
1408                        let (cell_r, inner_r) = tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
1409                        let hit = cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1410                        if hit {
1411                            shared.select_row("orders", &key);
1412                        }
1413                        dbl |= cell_r.double_clicked()
1414                            || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1415                    }};
1416                }
1417                cell!(|ui| ui.label(o.symbol.clone()), 0);
1418                cell!(
1419                    |ui| ui.label(if o.name.is_empty() {
1420                        "-".to_string()
1421                    } else {
1422                        o.name.clone()
1423                    }),
1424                    1
1425                );
1426                cell!(|ui| ui.label(o.exchange.as_str().to_string()), 2);
1427                cell!(|ui| ui.label(o.order_type.as_str().to_string()), 3);
1428                cell!(
1429                    |ui| ui.label(
1430                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1431                            if o.direction == Some(Direction::SHORT) {
1432                                crate::tokens::semantic::FALL
1433                            } else {
1434                                crate::tokens::semantic::RISE
1435                            },
1436                        ),
1437                    ),
1438                    4
1439                );
1440                cell!(|ui| ui.label(o.offset.as_str().to_string()), 5);
1441                cell!(|ui| ui.label(crate::format::price(o.price)), 6);
1442                cell!(|ui| ui.label(crate::format::volume(o.volume)), 7);
1443                cell!(|ui| ui.label(crate::format::volume(o.traded)), 8);
1444                cell!(|ui| ui.label(crate::format::price(o.traded_price)), 9);
1445                cell!(
1446                    |ui| ui.label(egui::RichText::new(o.status.as_str()).color(
1447                        if o.status == ek_data::constant::Status::REJECTED {
1448                            crate::tokens::semantic::RISE
1449                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1450                            crate::tokens::semantic::FALL
1451                        } else {
1452                            crate::tokens::semantic::WARN
1453                        },
1454                    )),
1455                    10
1456                );
1457                cell!(|ui| ui.label(o.status_msg.clone()), 11);
1458                cell!(
1459                    |ui| ui.label(crate::format::time_hms_millis(o.datetime)),
1460                    12
1461                );
1462                cell!(|ui| ui.label(o.orderid.clone()), 13);
1463                cell!(
1464                    |ui| ui.label(if o.reference.is_empty() {
1465                        "-".to_string()
1466                    } else {
1467                        o.reference.clone()
1468                    }),
1469                    14
1470                );
1471                cell!(
1472                    |ui| ui.label(ledger_gw_label(&o.gateway_name, &known_gws)),
1473                    15
1474                );
1475                if dbl && order_active(o.status) {
1476                    // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
1477                    let gw = if !o.gateway_name.is_empty() {
1478                        o.gateway_name.clone()
1479                    } else {
1480                        shared.default_gateway().unwrap_or_default()
1481                    };
1482                    if !gw.is_empty() {
1483                        shared.push_cmd(UiCmd::CancelOrder {
1484                            orderid: o.orderid.clone(),
1485                            gateway: gw,
1486                        });
1487                    }
1488                }
1489                row.col(|ui| {
1490                    ui.horizontal(|ui| {
1491                        if order_active(o.status)
1492                            && ui
1493                                .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1494                                .clicked()
1495                        {
1496                            // 撤单跟随委托的来路网关(行级 gateway_name;
1497                            // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
1498                            let gw = if !o.gateway_name.is_empty() {
1499                                o.gateway_name.clone()
1500                            } else {
1501                                shared.default_gateway().unwrap_or_default()
1502                            };
1503                            if gw.is_empty() {
1504                                shared.log_line(format!(
1505                                    "[撤单] {} 无网关可撤(账户清册空)",
1506                                    o.orderid
1507                                ));
1508                            } else {
1509                                shared.push_cmd(UiCmd::CancelOrder {
1510                                    orderid: o.orderid.clone(),
1511                                    gateway: gw,
1512                                });
1513                            }
1514                        }
1515                    });
1516                });
1517            });
1518        }
1519        rows.len()
1520    });
1521    if let Some(col) = clicked {
1522        shared.toggle_table_sort(table_id, col);
1523    }
1524}
1525
1526/// 委托行的排序键(列序 == `ORDER_HEADERS`):数字列给零填充串保证字典序 == 数值序
1527/// (`价格/总量/已成/成交均价`),时间列给纳秒零填充,其余给该列的文本。
1528/// 0.3.3 收 Wave D 欠账时新增 —— 与成交表的 `trade_sort_key` 同一口径。
1529fn order_sort_key(o: &ek_data::order::OrderData, col: usize) -> String {
1530    match col {
1531        0 => o.symbol.clone(),
1532        1 => o.name.clone(),
1533        2 => o.exchange.as_str().to_string(),
1534        3 => o.order_type.as_str().to_string(),
1535        4 => o
1536            .direction
1537            .map(|d| d.as_str().to_string())
1538            .unwrap_or_default(),
1539        5 => o.offset.as_str().to_string(),
1540        6 => format!("{:020.4}", o.price),
1541        7 => format!("{:020.4}", o.volume),
1542        8 => format!("{:020.4}", o.traded),
1543        9 => format!("{:020.4}", o.traded_price),
1544        10 => o.status.as_str().to_string(),
1545        11 => o.status_msg.clone(),
1546        12 => o.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
1547        13 => o.orderid.clone(),
1548        14 => o.reference.clone(),
1549        15 => o.gateway_name.clone(),
1550        // 第 17 列是「操作」= 撤单钮,没有可排的数据 → 空串(点它排序等于不动)。
1551        _ => String::new(),
1552    }
1553}
1554
1555/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1556pub(crate) fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1557    // 单 tab(Python `tab_widget2.addTab(trade_widget, "成交")` 等价:那个区也是
1558    // QTabWidget,只有一个 tab 也带 tab 头 —— BD-UI-41 补齐)。
1559    tab_label(ui, true, "成交");
1560    let ts = shared.trade_snap();
1561    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1562    let known_gws = account_gateways(shared);
1563    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1564    // 0.3.3 Wave F(BD-UI-51):列序照 Python `TradeMonitor.headers`(widget.py:1270-1283)——
1565    // 代码在首列,成交号在第 10 列;原来我们把「成交号」放首列,是差集里唯一一条纯 UI 差集。
1566    // 列名「成交额」→「成交金额」同 Python。
1567    const TRADE_HEADERS: [&str; 12] = [
1568        "代码",
1569        "名称",
1570        "交易所",
1571        "方向",
1572        "开平",
1573        "价格",
1574        "数量",
1575        "成交金额",
1576        "时间",
1577        "成交号",
1578        "委托号",
1579        "接口",
1580    ];
1581    if ts.trades.is_empty() {
1582        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1583        pinned_table(ui, "trade_grid", &TRADE_HEADERS, |body, row_h| {
1584            body.row(row_h, |mut row| {
1585                row.col(|ui| {
1586                    crate::ui_ext::muted_label(ui, "(无成交)");
1587                });
1588            });
1589            1
1590        });
1591        return;
1592    }
1593    let sort = shared.table_sort("trade_grid");
1594    let rows = sorted_rows(&ts.trades, sort, trade_sort_key);
1595    let clicked = pinned_table_sorted(
1596        ui,
1597        "trade_grid",
1598        &TRADE_HEADERS,
1599        sort,
1600        shared.table_resize_on("trade_grid"),
1601        |body, row_h| {
1602            const RIGHT_COLS: [usize; 3] = [5, 6, 7];
1603            for t in &rows {
1604                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1605                // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1606                let key = format!("{}.{}", t.gateway_name, t.tradeid);
1607                let row_sel = shared.row_selected("trades", &key);
1608                body.row(row_h, |mut row| {
1609                    row.set_selected(row_sel);
1610                    macro_rules! cell {
1611                    ($make:expr, $col:expr) => {{
1612                        let (cell_r, inner_r) = tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
1613                        let hit = cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1614                        if hit {
1615                            shared.select_row("trades", &key);
1616                        }
1617                        // 0.3.1(Wave D,BD-UI-49)双击联动:双击成交行 = 该合约进下单面板
1618                        // (Python 双击成交/持仓行带入下单,`mainwindow.py:276` 等价)。
1619                        let dbl = cell_r.double_clicked()
1620                            || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1621                        if dbl {
1622                            let mut f = shared.order_form();
1623                            f.symbol = t.symbol.clone();
1624                            if let Some(d) = t.direction {
1625                                f.direction = d;
1626                            }
1627                            shared.set_order_form(f);
1628                        }
1629                        // 0.3.1(Wave D)右键菜单(照 Python 表上右键:调整列宽 / 保存数据)。
1630                        // 只挂首列 = 一行一颗菜单,不在每格挂一颗。
1631                        // 0.3.3(收 Wave D 欠账):菜单挂在**真控件**上 —— 首格内容是
1632                        // `Label::new(..).sense(Sense::click())`(有 AccessKit 动作的真控件),
1633                        // 菜单由它自己的 `context_menu` 开(见下面第 1 格)。原来那一套
1634                        // (收首格矩形 + 体表层自己补 `interact`)在 kittest 里收不到二级点击:
1635                        // 体表格的格吃过一次 hit-test,后补的 interact 与表不同层,
1636                        // `secondary_clicked` 永远为假。
1637                    }};
1638                }
1639                    // 首格是**真控件**(可点 + 可右键):右键菜单挂在这颗 Label 上。
1640                    cell!(
1641                        |ui| {
1642                            let r = ui
1643                                .add(egui::Label::new(t.symbol.clone()).sense(egui::Sense::click()))
1644                                .on_hover_text("右键:调整列宽 / 保存数据");
1645                            r.context_menu(|ui| {
1646                                if ui.button("调整列宽").clicked() {
1647                                    shared.toggle_table_resize("trade_grid");
1648                                    ui.close();
1649                                }
1650                                if ui.button("保存数据").clicked() {
1651                                    let _ = save_table_csv(
1652                                        "trade_grid",
1653                                        &TRADE_HEADERS,
1654                                        &rows,
1655                                        trade_cells,
1656                                    );
1657                                    ui.close();
1658                                }
1659                            });
1660                            r
1661                        },
1662                        0
1663                    );
1664                    cell!(
1665                        |ui| ui.label(if t.name.is_empty() {
1666                            "-".to_string()
1667                        } else {
1668                            t.name.clone()
1669                        }),
1670                        1
1671                    );
1672                    cell!(|ui| ui.label(t.exchange.as_str().to_string()), 2);
1673                    cell!(
1674                        |ui| ui.label(
1675                            egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-"))
1676                                .color(if t.direction == Some(Direction::SHORT) {
1677                                    crate::tokens::semantic::FALL
1678                                } else {
1679                                    crate::tokens::semantic::RISE
1680                                },),
1681                        ),
1682                        3
1683                    );
1684                    cell!(|ui| ui.label(t.offset.as_str().to_string()), 4);
1685                    cell!(|ui| ui.label(crate::format::price(t.price)), 5);
1686                    cell!(|ui| ui.label(crate::format::volume(t.volume)), 6);
1687                    cell!(|ui| ui.label(crate::format::price(t.traded_amount)), 7);
1688                    cell!(|ui| ui.label(crate::format::time_hms_millis(t.datetime)), 8);
1689                    cell!(|ui| ui.label(t.tradeid.clone()), 9);
1690                    cell!(|ui| ui.label(t.orderid.clone()), 10);
1691                    cell!(
1692                        |ui| ui.label(ledger_gw_label(&t.gateway_name, &known_gws)),
1693                        11
1694                    );
1695                });
1696            }
1697            // 右键菜单已挂在第 1 格那颗真控件上(见体表 cell!(.., 0)),
1698            // 不再需要「收格矩形 + 表外层补 interact」那一套(0.3.3 收欠账)。
1699            ts.trades.len()
1700        },
1701    );
1702    if let Some(col) = clicked {
1703        shared.toggle_table_sort("trade_grid", col);
1704    }
1705}
1706
1707/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1708/// Python mainwindow.py:276 等价,不开 K线)。
1709pub(crate) fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1710    // 单 tab(Python `tab_widget3.addTab(position_widget, "持仓")` 等价,BD-UI-41)。
1711    tab_label(ui, true, "持仓");
1712    let ts = shared.trade_snap();
1713    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1714    let known_gws = account_gateways(shared);
1715    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1716    const POS_HEADERS: [&str; 16] = [
1717        "代码",
1718        "名称",
1719        "交易所",
1720        "方向",
1721        "数量",
1722        "昨仓",
1723        "可用",
1724        "冻结",
1725        "在途",
1726        "市值",
1727        "成本",
1728        "均价",
1729        "最新价",
1730        "盈亏",
1731        "盈亏比",
1732        "接口",
1733    ];
1734    if ts.positions.is_empty() {
1735        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1736        pinned_table(ui, "pos_grid", &POS_HEADERS, |body, row_h| {
1737            body.row(row_h, |mut row| {
1738                row.col(|ui| {
1739                    crate::ui_ext::muted_label(ui, "(无持仓)");
1740                });
1741            });
1742            1
1743        });
1744        return;
1745    }
1746    // 0.3.3(收 Wave D 欠账):持仓表也吃「点表头排序」。
1747    let sort = shared.table_sort("pos_grid");
1748    let rows = sorted_rows(&ts.positions, sort, pos_sort_key);
1749    let clicked = pinned_table_sorted(ui, "pos_grid", &POS_HEADERS, sort, false, |body, row_h| {
1750        const RIGHT_COLS: [usize; 11] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
1751        for p in &rows {
1752            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1753            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1754            // 不开 K线;方向随持仓方向,Python 同口径)。
1755            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1756            let row_sel = shared.row_selected("positions", &key);
1757            body.row(row_h, |mut row| {
1758                row.set_selected(row_sel);
1759                let mut dbl = false;
1760                macro_rules! cell {
1761                    ($make:expr, $col:expr) => {{
1762                        let (cell_r, inner_r) = tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
1763                        let hit = cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1764                        if hit {
1765                            shared.select_row("positions", &key);
1766                        }
1767                        dbl |= cell_r.double_clicked()
1768                            || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1769                    }};
1770                }
1771                cell!(|ui| ui.label(p.symbol.clone()), 0);
1772                cell!(
1773                    |ui| ui.label(if p.name.is_empty() {
1774                        "-".to_string()
1775                    } else {
1776                        p.name.clone()
1777                    }),
1778                    1
1779                );
1780                cell!(|ui| ui.label(p.exchange.as_str().to_string()), 2);
1781                cell!(
1782                    |ui| ui.label(egui::RichText::new(p.direction.as_str()).color(
1783                        if p.direction == ek_data::constant::Direction::SHORT {
1784                            crate::tokens::semantic::FALL
1785                        } else {
1786                            crate::tokens::semantic::RISE
1787                        },
1788                    )),
1789                    3
1790                );
1791                cell!(|ui| ui.label(crate::format::volume(p.volume)), 4);
1792                cell!(|ui| ui.label(crate::format::volume(p.yd_volume)), 5);
1793                cell!(|ui| ui.label(crate::format::volume(p.can_use_volume)), 6);
1794                cell!(|ui| ui.label(crate::format::volume(p.frozen)), 7);
1795                cell!(|ui| ui.label(crate::format::volume(p.on_road_volume)), 8);
1796                cell!(|ui| ui.label(crate::format::price(p.market_value)), 9);
1797                // 0.3.3 Wave F(BD-UI-51)两处收口:
1798                // ① 成本 / 均价原来**取值互换** —— ek-data 里 `avg_price` = 成本价、
1799                //    `price` = 持仓均价(position.rs:22-23 / 38-39),Python 也是
1800                //    成本 ← avg_price、均价 ← price(widget.py:1491-1492)。判据 F2 抓到这条。
1801                // ② 五处裸 `format!("{:.2}")` / `{:+.1}` / `{:+.4}` 改走 `format.rs`
1802                //    (Python BaseCell 是 `round(content, 3)`,同一张表里三种精度就是毛病 3)。
1803                cell!(|ui| ui.label(crate::format::price(p.avg_price)), 10);
1804                cell!(|ui| ui.label(crate::format::price(p.price)), 11);
1805                cell!(|ui| ui.label(crate::format::price(p.last_price)), 12);
1806                cell!(
1807                    |ui| ui.label(egui::RichText::new(crate::format::price(p.pnl)).color(
1808                        if p.pnl < 0.0 {
1809                            crate::tokens::semantic::FALL
1810                        } else {
1811                            crate::tokens::semantic::RISE
1812                        }
1813                    ),),
1814                    13
1815                );
1816                cell!(|ui| ui.label(crate::format::price(p.profit_rate)), 14);
1817                cell!(
1818                    |ui| ui.label(ledger_gw_label(&p.gateway_name, &known_gws)),
1819                    15
1820                );
1821                if dbl {
1822                    let mut f = shared.order_form();
1823                    f.symbol = p.symbol.clone();
1824                    f.exchange = p.exchange.as_str().to_string();
1825                    f.direction = p.direction;
1826                    // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
1827                    if !p.gateway_name.is_empty() {
1828                        f.gateway = p.gateway_name.clone();
1829                    }
1830                    shared.set_order_form(f);
1831                    shared.log_line(format!(
1832                        "[ops] 持仓带入下单面板 {}(接口 {})",
1833                        p.symbol, p.gateway_name
1834                    ));
1835                }
1836            });
1837        }
1838        rows.len()
1839    });
1840    if let Some(col) = clicked {
1841        shared.toggle_table_sort("pos_grid", col);
1842    }
1843}
1844
1845/// 持仓行的排序键(列序 == `POS_HEADERS`;与屏幕取值同源:
1846/// 成本 = `avg_price`、均价 = `price`,0.3.3 Wave F 修的那一对)。
1847fn pos_sort_key(p: &ek_data::position::PositionData, col: usize) -> String {
1848    match col {
1849        0 => p.symbol.clone(),
1850        1 => p.name.clone(),
1851        2 => p.exchange.as_str().to_string(),
1852        3 => p.direction.as_str().to_string(),
1853        4 => format!("{:020.4}", p.volume),
1854        5 => format!("{:020.4}", p.yd_volume),
1855        6 => format!("{:020.4}", p.can_use_volume),
1856        7 => format!("{:020.4}", p.frozen),
1857        8 => format!("{:020.4}", p.on_road_volume),
1858        9 => format!("{:020.4}", p.market_value),
1859        10 => format!("{:020.4}", p.avg_price),
1860        11 => format!("{:020.4}", p.price),
1861        12 => format!("{:020.4}", p.last_price),
1862        13 => format!("{:020.4}", p.pnl),
1863        14 => format!("{:020.4}", p.profit_rate),
1864        15 => p.gateway_name.clone(),
1865        _ => String::new(),
1866    }
1867}
1868
1869pub(crate) fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1870    let tab = shared.bottomright_tab();
1871    ui.horizontal(|ui| {
1872        if tab_label(
1873            ui,
1874            tab == BottomRightTab::Account,
1875            BottomRightTab::Account.label(),
1876        )
1877        .clicked()
1878        {
1879            shared.set_bottomright_tab(BottomRightTab::Account);
1880        }
1881        if tab_label(
1882            ui,
1883            tab == BottomRightTab::Logs,
1884            BottomRightTab::Logs.label(),
1885        )
1886        .clicked()
1887        {
1888            shared.set_bottomright_tab(BottomRightTab::Logs);
1889        }
1890    });
1891    match tab {
1892        BottomRightTab::Account => {
1893            let ts = shared.trade_snap();
1894            if ts.accounts.is_empty() {
1895                crate::ui_ext::muted_label(ui, "(无资金:等网关 eAccount.)");
1896                return;
1897            }
1898            // 0.2.29(BD-UI-44):资金表跟着日志表换 TableBuilder —— 同一块 tab 容器,
1899            // 同一套固定表头 + 按内容列宽;余额/可用/冻结是数字列(右对齐成列)。
1900            pinned_table(
1901                ui,
1902                "acct_grid",
1903                &["账号", "接口", "余额", "可用", "冻结"],
1904                |body, row_h| {
1905                    const RIGHT_COLS: [usize; 3] = [2, 3, 4];
1906                    for a in &ts.accounts {
1907                        body.row(row_h, |mut row| {
1908                            macro_rules! cell {
1909                                ($make:expr, $col:expr) => {{
1910                                    let _ = tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
1911                                }};
1912                            }
1913                            cell!(|ui| ui.label(a.accountid.clone()), 0);
1914                            cell!(|ui| ui.label(a.gateway_name.clone()), 1);
1915                            // 0.3.3 Wave F:三处裸 `{:.2}` 改走 `format.rs`(同一口径,毛病 3)。
1916                            cell!(|ui| ui.label(crate::format::price(a.balance)), 2);
1917                            cell!(|ui| ui.label(crate::format::price(a.available())), 3);
1918                            cell!(|ui| ui.label(crate::format::price(a.frozen)), 4);
1919                        });
1920                    }
1921                    ts.accounts.len()
1922                },
1923            );
1924        }
1925        BottomRightTab::Logs => {
1926            render_logs(ui, shared);
1927        }
1928    }
1929}
1930
1931/// 日志格(BD-UI-41,对照 Python 版)。
1932///
1933/// Python 侧「日志」tab 里放的是 `LogMonitor` —— 一个带 **时间/信息/接口** 表头的
1934/// QTableWidget(`widget.py:1167-1180`,BaseMonitor 子类),跟着 Dock 一起伸展。
1935/// 我们原来是一坨 monospace 行 + `ScrollArea::max_height(180.0)` 硬顶:窗口最大化
1936/// 它还是 180px —— 用户报的「整体放到最大,日志那边表格还是很小」根因就是那 180。
1937/// 现在走 `pinned_table`(与委托/成交/持仓同一套容器:表头固定 + 体滚 + 横滚),
1938/// 硬顶撤除 → 格多大,表多大。
1939///
1940/// 两处偏离(记 DESIGN BD-UI-41):
1941/// ① Python 有第三列「接口」;我们的 `log_line` 调用点不带网关(正文自带 `[ops]` /
1942///   `[订阅]` 这类前缀),硬造一列空列不如不造,所以只有两列。
1943/// ② `stick_to_bottom` 撤除:Python 的 QTableWidget 也不自动跟到最后一行,
1944///   要看最新行自己滚到底(与那张表同口径)。
1945fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1946    ui.horizontal(|ui| {
1947        if ui.button(BTN_CLEAR_LOGS).clicked() {
1948            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1949        }
1950    });
1951    let (entries, spilled) = shared.logs_entries();
1952    if spilled > 0 {
1953        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
1954        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
1955        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
1956        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
1957        ui.label(format!(
1958            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
1959            crate::state::RING_CAP
1960        ));
1961    }
1962    pinned_table(ui, "log_grid", &["时间", "信息"], |body, row_h| {
1963        if entries.is_empty() {
1964            body.row(row_h, |mut row| {
1965                row.col(|ui| {
1966                    crate::ui_ext::muted_label(ui, "(无日志)");
1967                });
1968                row.col(|ui| {
1969                    ui.label("");
1970                });
1971            });
1972            return 1;
1973        }
1974        for e in &entries {
1975            body.row(row_h, |mut row| {
1976                row.col(|ui| {
1977                    ui.monospace(e.at.clone());
1978                });
1979                row.col(|ui| {
1980                    ui.monospace(e.text.clone());
1981                });
1982            });
1983        }
1984        entries.len()
1985    });
1986}
1987
1988// ================= 独立窗面(BD-UI-32/33)=================
1989
1990/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1991struct WinGeom {
1992    inner: [f32; 2],
1993    min_inner: [f32; 2],
1994    maximized: bool,
1995}
1996
1997/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1998///
1999/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
2000///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
2001///   context.rs「never used this pass」)。
2002/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
2003///   回退面关闭 = embedded 闭包返回 true。
2004/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
2005/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
2006///   Embedded 回退面无此概念 = 忽略)。
2007/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
2008///   键事件按子窗焦点路由,主窗按键不会误关)。
2009///
2010/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
2011fn show_independent_window(
2012    ctx: &egui::Context,
2013    id_salt: impl std::hash::Hash,
2014    title: &str,
2015    geom: &WinGeom,
2016    embedded: impl Fn(&egui::Context) -> bool,
2017    body: impl Fn(&mut egui::Ui) -> bool,
2018) -> bool {
2019    let closed = std::cell::Cell::new(false);
2020    let mut builder = egui::ViewportBuilder::default()
2021        .with_title(title)
2022        .with_inner_size(geom.inner)
2023        .with_min_inner_size(geom.min_inner)
2024        .with_minimize_button(true)
2025        .with_maximize_button(true)
2026        .with_close_button(true);
2027    if geom.maximized {
2028        builder = builder.with_maximized(true);
2029    }
2030    ctx.show_viewport_immediate(
2031        egui::ViewportId::from_hash_of(id_salt),
2032        builder,
2033        |ctx2, class| {
2034            if class == egui::ViewportClass::Embedded {
2035                closed.set(embedded(ctx2));
2036                return;
2037            }
2038            if ctx2.input(|i| i.viewport().close_requested()) {
2039                closed.set(true);
2040                return;
2041            }
2042            egui::CentralPanel::default().show(ctx2, |ui| {
2043                if body(ui) {
2044                    closed.set(true);
2045                }
2046            });
2047        },
2048    );
2049    closed.get()
2050}
2051
2052fn render_windows(ctx: &egui::Context, shared: &UiShared) {
2053    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
2054    if shared.ops_window_open() {
2055        let closed = show_independent_window(
2056            ctx,
2057            "ops_win",
2058            "运维窗",
2059            &WinGeom {
2060                inner: [680.0, 420.0],
2061                min_inner: [420.0, 260.0],
2062                maximized: false,
2063            },
2064            |ctx2| {
2065                let mut open = shared.ops_window_open();
2066                egui::Window::new("运维窗")
2067                    .id(egui::Id::new("ops_win"))
2068                    .default_pos([80.0, 320.0])
2069                    .default_size([640.0, 380.0])
2070                    .open(&mut open)
2071                    .show(ctx2, |ui| {
2072                        render_ops_window(ui, shared);
2073                    });
2074                shared.set_ops_window_open(open);
2075                !open
2076            },
2077            |ui| {
2078                render_ops_window(ui, shared);
2079                false
2080            },
2081        );
2082        if closed {
2083            shared.set_ops_window_open(false);
2084        }
2085    }
2086
2087    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
2088    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
2089    if shared.accounts_window_open() {
2090        let closed = show_independent_window(
2091            ctx,
2092            "account_win",
2093            "账户面板",
2094            &WinGeom {
2095                inner: [760.0, 520.0],
2096                min_inner: [520.0, 320.0],
2097                maximized: false,
2098            },
2099            |ctx2| {
2100                let mut open = shared.accounts_window_open();
2101                egui::Window::new("账户面板")
2102                    .id(egui::Id::new("account_win"))
2103                    .default_pos([640.0, 320.0])
2104                    .default_size([720.0, 470.0])
2105                    .open(&mut open)
2106                    .show(ctx2, |ui| {
2107                        render_accounts_window(ui, shared);
2108                    });
2109                shared.set_accounts_window_open(open);
2110                !open
2111            },
2112            |ui| {
2113                render_accounts_window(ui, shared);
2114                false
2115            },
2116        );
2117        if closed {
2118            shared.set_accounts_window_open(false);
2119        }
2120    }
2121
2122    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
2123    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
2124    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
2125    if shared.account_edit_open() {
2126        let closed = show_independent_window(
2127            ctx,
2128            "account_edit_win",
2129            "账户配置",
2130            &WinGeom {
2131                inner: [680.0, 340.0],
2132                min_inner: [480.0, 240.0],
2133                maximized: false,
2134            },
2135            |ctx2| {
2136                let mut open = shared.account_edit_open();
2137                let title = match shared.editing_account() {
2138                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
2139                    None => "账户配置 — 添加".to_string(),
2140                };
2141                egui::Window::new(title)
2142                    .id(egui::Id::new("account_edit_win"))
2143                    .default_pos([700.0, 380.0])
2144                    .default_size([660.0, 320.0])
2145                    .open(&mut open)
2146                    .show(ctx2, |ui| {
2147                        render_account_edit_window(ui, shared);
2148                    });
2149                if !open {
2150                    shared.end_account_edit();
2151                }
2152                !open
2153            },
2154            |ui| {
2155                render_account_edit_window(ui, shared);
2156                false
2157            },
2158        );
2159        if closed && shared.account_edit_open() {
2160            shared.end_account_edit();
2161        }
2162    }
2163
2164    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
2165    if shared.contracts_window_open() {
2166        let closed = show_independent_window(
2167            ctx,
2168            "contracts_win",
2169            "查询合约窗",
2170            &WinGeom {
2171                inner: [620.0, 460.0],
2172                min_inner: [420.0, 260.0],
2173                maximized: false,
2174            },
2175            |ctx2| {
2176                let mut open = shared.contracts_window_open();
2177                egui::Window::new("查询合约窗")
2178                    .id(egui::Id::new("contracts_win"))
2179                    .default_pos([420.0, 300.0])
2180                    .default_size([560.0, 420.0])
2181                    .open(&mut open)
2182                    .show(ctx2, |ui| {
2183                        render_contracts_window(ui, shared);
2184                    });
2185                shared.set_contracts_window_open(open);
2186                !open
2187            },
2188            |ui| {
2189                render_contracts_window(ui, shared);
2190                false
2191            },
2192        );
2193        if closed {
2194            shared.set_contracts_window_open(false);
2195        }
2196    }
2197
2198    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
2199    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
2200    // 键按子窗焦点路由,主窗按键不误关)。
2201    let mut closed: Vec<String> = Vec::new();
2202    for sym in shared.kline_symbols() {
2203        let is_closed = show_independent_window(
2204            ctx,
2205            format!("kline_win:{sym}"),
2206            &format!("K线 — {sym}"),
2207            &WinGeom {
2208                inner: [560.0, 360.0],
2209                min_inner: [360.0, 220.0],
2210                maximized: true,
2211            },
2212            |ctx2| {
2213                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
2214                let resp = egui::Window::new(format!("K线 — {sym}"))
2215                    .id(egui::Id::new(("kline_win", sym.clone())))
2216                    .default_pos([300.0, 340.0])
2217                    .default_size([540.0, 330.0])
2218                    .show(ctx2, |ui| {
2219                        let cell = shared
2220                            .market()
2221                            .cells()
2222                            .into_iter()
2223                            .find(|c| c.symbol == sym);
2224                        render_kline_cell(ui, cell.as_ref());
2225                    });
2226                esc || resp.is_none()
2227            },
2228            |ui| {
2229                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
2230                    return true;
2231                }
2232                let cell = shared
2233                    .market()
2234                    .cells()
2235                    .into_iter()
2236                    .find(|c| c.symbol == sym);
2237                render_kline_cell(ui, cell.as_ref());
2238                false
2239            },
2240        );
2241        if is_closed {
2242            closed.push(sym);
2243        }
2244    }
2245    for s in closed {
2246        shared.close_kline(&s);
2247    }
2248}
2249
2250/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
2251/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
2252fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
2253    crate::ui_ext::section_title(ui, "插件");
2254    let snap = shared.snapshot();
2255    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
2256    let account_gateways: std::collections::HashSet<String> = shared
2257        .accounts()
2258        .iter()
2259        .map(|a| a.gateway.clone())
2260        .collect();
2261    let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
2262    egui::Grid::new("plug_grid").num_columns(6).show(ui, |ui| {
2263        for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
2264            let r = ui.label(egui::RichText::new(h).strong().small());
2265            plug_heads.push((r.rect, h.to_string()));
2266        }
2267        ui.end_row();
2268        if snap.rows.is_empty() {
2269            ui.label("(快照空:等 eTimer 首拉)");
2270            ui.end_row();
2271        }
2272        for r in &snap.rows {
2273            if is_quarantined(&r.state) {
2274                ui.label(egui::RichText::new(r.name.clone()).color(crate::tokens::semantic::ERROR));
2275                ui.label(
2276                    egui::RichText::new(state_text(r))
2277                        .color(crate::tokens::semantic::ERROR)
2278                        .strong(),
2279                );
2280            } else {
2281                ui.label(r.name.clone());
2282                ui.label(state_text(r));
2283            }
2284            ui.label(r.version.clone());
2285            ui.label(format!("deps:[{}]", r.deps.join(",")));
2286            ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
2287            ui.horizontal(|ui| {
2288                // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
2289                // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
2290                // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
2291                // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
2292                // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
2293                if account_gateways.contains(&r.name) {
2294                    ui.label(
2295                        egui::RichText::new("起停归『账户』面板(带撤单守卫)")
2296                            .color(crate::tokens::semantic::INFO),
2297                    );
2298                } else {
2299                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
2300                        // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
2301                        // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
2302                        // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
2303                        shared.push_cmd(UiCmd::Start {
2304                            factory: r.name.clone(),
2305                            name: String::new(),
2306                        });
2307                    }
2308                    let armed = shared.pending_confirm();
2309                    let armed_here = ui
2310                        .small_button(remove_button_label(&r.name, armed.as_deref()))
2311                        .clicked()
2312                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
2313                    if armed_here {
2314                        shared.push_cmd(UiCmd::Remove {
2315                            name: r.name.clone(),
2316                        });
2317                    }
2318                }
2319            });
2320            ui.end_row();
2321        }
2322    });
2323    header_band(ui, &plug_heads, ui.min_rect().max.x);
2324
2325    if let Some(name) = shared.pending_confirm() {
2326        ui.horizontal(|ui| {
2327            ui.label(
2328                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
2329                    .color(crate::tokens::semantic::WARN),
2330            );
2331            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2332                shared.confirm_cancel();
2333            }
2334        });
2335    }
2336
2337    crate::ui_ext::space_group(ui);
2338    crate::ui_ext::section_title(ui, "服务");
2339    let flat: Vec<String> = snap
2340        .services
2341        .iter()
2342        .flat_map(|(n, ids)| {
2343            if ids.is_empty() {
2344                vec![format!("{n} 服务"), "-".to_string()]
2345            } else {
2346                ids.iter()
2347                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
2348                    .collect()
2349            }
2350        })
2351        .collect();
2352    if flat.is_empty() {
2353        crate::ui_ext::muted_label(ui, "(无服务目录)");
2354    } else {
2355        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
2356        // 表头(BD-UI-41,用户指出「配置那边表格也要有表头」):这张表原来六列
2357        // 纯数据没有头行。列语义 = 三对「服务名 / 实例 id」,头就照这个写。
2358        let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
2359        egui::Grid::new("svc_grid").num_columns(6).show(ui, |ui| {
2360            for _ in 0..3 {
2361                for h in ["服务", "实例"] {
2362                    let r = ui.label(egui::RichText::new(h).strong().small());
2363                    svc_heads.push((r.rect, h.to_string()));
2364                }
2365            }
2366            ui.end_row();
2367            for r in 0..rows {
2368                for c in 0..3 {
2369                    let i = c * rows * 2 + r * 2;
2370                    if i < flat.len() {
2371                        ui.label(flat[i].clone());
2372                        ui.label(flat[i + 1].clone());
2373                    } else {
2374                        ui.label("");
2375                        ui.label("");
2376                    }
2377                }
2378                ui.end_row();
2379            }
2380        });
2381        header_band(ui, &svc_heads, ui.min_rect().max.x);
2382    }
2383
2384    ui.label("依赖");
2385    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
2386        ui.label("(全部无依赖)");
2387    }
2388    for r in &snap.rows {
2389        if !r.deps.is_empty() {
2390            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
2391        }
2392    }
2393}
2394
2395/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
2396/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
2397/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
2398/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
2399/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
2400fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
2401    let accounts = shared.accounts();
2402    let locked = shared.accounts_locked();
2403    let md = shared.md_source();
2404    let snap = shared.trade_snap();
2405
2406    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
2407    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
2408    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
2409    let row_label: std::collections::BTreeMap<String, String> = accounts
2410        .iter()
2411        .map(|a| {
2412            let d = a.display_name();
2413            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
2414            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
2415        })
2416        .collect();
2417
2418    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
2419    ui.horizontal(|ui| {
2420        ui.label(
2421            egui::RichText::new(format!(
2422                "当前行情源 = {}",
2423                if md.is_empty() {
2424                    "(未指定)".to_string()
2425                } else {
2426                    md.clone()
2427                }
2428            ))
2429            .strong(),
2430        );
2431        ui.label(format!(
2432            "账户 {}/{}",
2433            accounts.len(),
2434            crate::state::MAX_ACCOUNTS
2435        ));
2436        if shared.can_add_account() {
2437            if ui.button(BTN_ACCOUNT_ADD).clicked() {
2438                shared.begin_account_edit(None);
2439            }
2440        } else {
2441            ui.label(
2442                egui::RichText::new(format!(
2443                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
2444                    crate::state::MAX_ACCOUNTS
2445                ))
2446                .color(crate::tokens::semantic::WARN),
2447            );
2448        }
2449    });
2450
2451    const ACCOUNT_HEADERS: [&str; 7] = [
2452        "网关(派生名)",
2453        "别名",
2454        "账号",
2455        "状态",
2456        "行情源",
2457        "收录(勾→实)",
2458        "操作",
2459    ];
2460    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
2461    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
2462    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
2463    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面 —— 但表头带照补
2464    // (BD-UI-41:走 `header_band`,它正是为「自动尺寸浮动窗里的短表」准备的)。
2465    let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
2466    egui::Grid::new("account_grid")
2467        .num_columns(7)
2468        .show(ui, |ui| {
2469            for h in &ACCOUNT_HEADERS {
2470                let r = ui.label(egui::RichText::new(*h).strong().small());
2471                acct_heads.push((r.rect, h.to_string()));
2472            }
2473            ui.end_row();
2474        if accounts.is_empty() {
2475            crate::ui_ext::muted_label(ui, "(无账户:上方『添加账户』开配置弹窗)");
2476            for _ in 0..6 {
2477                ui.label("");
2478            }
2479            ui.end_row();
2480        }
2481        for a in &accounts {
2482            let is_md = a.gateway == md;
2483            let name = row_label
2484                .get(&a.gateway)
2485                .cloned()
2486                .unwrap_or_else(|| a.gateway.clone());
2487            ui.label(a.gateway.clone());
2488            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
2489            ui.label(if a.alias.trim().is_empty() {
2490                "—".to_string()
2491            } else {
2492                a.alias.clone()
2493            });
2494            ui.label(a.account.clone());
2495            // 状态列(0.2.19 起两段):装配态(svctl 在场位)+ **登录态**(`{gw}.status` 读数)。
2496            // 登录成败不再靠猜:ek-ctp 0.9.0 的 `{gw}.status` 是事实源(规划 P3 / 裁定 3),
2497            // 没有读数时仍写「已装配·登录态未知」,不得写成「已连接」(规划 U5 口径不变)。
2498            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
2499            // 会把一行撑成十几行高(s39 快照实锤过)。
2500            match a.stage {
2501                crate::state::AccountStage::AssembleFailed => {
2502                    ui.add(
2503                        egui::Label::new(
2504                            egui::RichText::new(format!(
2505                                "装配失败:{}",
2506                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
2507                            ))
2508                            .color(crate::tokens::semantic::ERROR),
2509                        )
2510                        .wrap_mode(egui::TextWrapMode::Extend),
2511                    );
2512                }
2513                _ => {
2514                    let color = match a.login {
2515                        crate::state::LoginState::Online => crate::tokens::semantic::OK,
2516                        crate::state::LoginState::LoginFailed
2517                        | crate::state::LoginState::FrontDown => {
2518                            crate::tokens::semantic::ERROR
2519                        }
2520                        crate::state::LoginState::QueryStarved => {
2521                            crate::tokens::semantic::WARN
2522                        }
2523                        // Unknown / Stopped:灰 —— 没有证据与主动停会话都不该着色成"出事"
2524                        _ => crate::tokens::semantic::MUTED,
2525                    };
2526                    ui.add(
2527                        egui::Label::new(
2528                            egui::RichText::new(a.status_label()).color(color),
2529                        )
2530                        .wrap_mode(egui::TextWrapMode::Extend),
2531                    );
2532                }
2533            }
2534            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
2535            ui.horizontal(|ui| {
2536                if is_md {
2537                    ui.label(egui::RichText::new("● 行情源").strong());
2538                }
2539                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
2540                    ui.small_button(if is_md {
2541                        "已是行情源"
2542                    } else {
2543                        "设为行情源"
2544                    })
2545                });
2546                if resp.inner.clicked() {
2547                    match shared.set_md_source(&a.gateway) {
2548                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
2549                        Err(e) => shared.log_line(format!("[账户] {e}")),
2550                    }
2551                }
2552            });
2553            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
2554            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
2555            ui.vertical(|ui| {
2556                ui.add(
2557                    egui::Label::new(egui::RichText::new(format!(
2558                        "勾:{}",
2559                        classes_label(&a.form.product_classes())
2560                    )))
2561                    .wrap_mode(egui::TextWrapMode::Extend),
2562                );
2563                ui.add(
2564                    egui::Label::new(egui::RichText::new(format!(
2565                        "实:{}",
2566                        if a.effective_classes.is_empty() {
2567                            "—".to_string()
2568                        } else {
2569                            classes_label(&a.effective_classes)
2570                        }
2571                    )))
2572                    .wrap_mode(egui::TextWrapMode::Extend),
2573                );
2574            });
2575            // 操作:连接/重试(四态分离的『连接』入口)/ 断开(0.2.19 停会话)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
2576            ui.horizontal(|ui| {
2577                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2578                let idle = a.stage == crate::state::AccountStage::NotAssembled;
2579                // 会话被主动停掉(『断开』)但实例还在:恢复入口仍是这一颗『连接』
2580                // (走 `UiCmd::Connect` → svctl 回「已在场」→ 插件侧改走重投凭据,规划裁定 1)。
2581                let stopped = a.login == crate::state::LoginState::Stopped;
2582                if failed || idle || stopped {
2583                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
2584                    // (旧口径"必须先移除才能重试"作废)。
2585                    let label = if failed {
2586                        format!("重试 {name}")
2587                    } else {
2588                        format!("连接 {name}")
2589                    };
2590                    if ui.small_button(label).clicked() {
2591                        push_connect(shared, &a.gateway, &a.form);
2592                    }
2593                }
2594                // 该账户的活跃委托数(『断开』与『移除』共用同一道守卫,见下)。
2595                let open = snap
2596                    .orders
2597                    .iter()
2598                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
2599                    .count();
2600                // 『断开』(0.2.19,规划裁定 1「断开 = 停会话,移除 = 停实例」):
2601                // 只对**已装配、会话还活着、且没有活跃委托**的行开。
2602                // - 未装配没有会话可停(规划 Q21:口不存在时不留死钮;现在口已实现,钮才允许出现);
2603                // - 有活跃委托时不给断开 —— 会话一停就撤不了单、也不再回报,与『移除』
2604                //   要挡的是同一个观测黑洞(规划 §5.B),出口同样是旁边那句『先撤 N 笔』。
2605                // `&&` 短路即"条件不成立就不画这颗钮",不是画出来再禁用。
2606                if a.stage == crate::state::AccountStage::Assembled
2607                    && a.login != crate::state::LoginState::Stopped
2608                    && open == 0
2609                    && ui.small_button(format!("断开 {name}")).clicked()
2610                {
2611                    shared.push_cmd(UiCmd::Disconnect {
2612                        gateway: a.gateway.clone(),
2613                    });
2614                    shared.log_line(format!(
2615                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2616                        gateway = a.gateway
2617                    ));
2618                }
2619                if ui.small_button(format!("编辑 {name}")).clicked() {
2620                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
2621                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
2622                    shared.begin_account_edit(Some(&a.gateway));
2623                }
2624                let has_pos = snap
2625                    .positions
2626                    .iter()
2627                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2628                if open > 0 {
2629                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
2630                    ui.label(
2631                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2632                            .color(crate::tokens::semantic::WARN),
2633                    );
2634                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
2635                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
2636                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
2637                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2638                        for o in snap
2639                            .orders
2640                            .iter()
2641                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
2642                        {
2643                            shared.push_cmd(UiCmd::CancelOrder {
2644                                orderid: o.orderid.clone(),
2645                                gateway: a.gateway.clone(),
2646                            });
2647                        }
2648                        shared.log_line(format!(
2649                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2650                            a.gateway, open
2651                        ));
2652                    }
2653                } else {
2654                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
2655                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
2656                    let armed = shared.pending_confirm();
2657                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2658                    let fired = ui
2659                        .small_button(remove_button_label(
2660                            &name,
2661                            if armed_here { Some(name.as_str()) } else { None },
2662                        ))
2663                        .clicked()
2664                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2665                    if fired {
2666                        shared.push_cmd(UiCmd::Remove {
2667                            name: a.gateway.clone(),
2668                        });
2669                        shared.remove_account(&a.gateway);
2670                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
2671                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
2672                        shared.end_account_edit_if_editing(&a.gateway);
2673                        if has_pos {
2674                            shared.log_line(format!(
2675                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2676                                a.gateway
2677                            ));
2678                        }
2679                    }
2680                }
2681            });
2682            ui.end_row();
2683        }
2684    });
2685    header_band(ui, &acct_heads, ui.min_rect().max.x);
2686
2687    if let Some(gw) = shared.pending_confirm() {
2688        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
2689        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2690        ui.horizontal(|ui| {
2691            ui.label(
2692                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2693                    .color(crate::tokens::semantic::WARN),
2694            );
2695            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2696                shared.confirm_cancel();
2697            }
2698        });
2699    }
2700
2701    ui.label(if locked {
2702        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2703    } else {
2704        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2705    });
2706    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
2707    ui.label(
2708        egui::RichText::new(format!(
2709            "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2710            crate::state::STATUS_POLL_TICKS
2711        ))
2712        .color(crate::tokens::semantic::INFO),
2713    );
2714    // F4 口径显形(0.2.20):多账户下「谁查谁过滤」此前只能靠猜 —— 实连 S14b 里
2715    // 账户 B 把勾选改成「期货+期权」,公用合约表仍是 852 条、期权 0 条,用户没有任何
2716    // 办法判断是自己没生效、还是网关没查到。这里把口径写死成一句(不靠界面猜):
2717    // 勾选只管**这个网关自己**收录并上抛的部分,公用表是在场各网关的并集。
2718    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).color(crate::tokens::semantic::INFO));
2719    // 状态细节(0.2.19):凡是有话可说的行都给一句(带次数、带秒数、带原因)。
2720    // 为什么不在状态列里带:Grid 列宽实测会被长文案挤成竖排(s39 实锤)。
2721    // 为什么不只给告警行:「状态口问不到」与「纯行情账户」也是事实 ——
2722    // 灰色徽标不给原因,用户就只能猜「是没问到还是没登录」。
2723    // 只给已装配的行:会话读数属于那个实例,行都「未装配」了还印「掉线 2 次」
2724    // 就是在替一个不存在的会话说话(状态侧由 `sync_accounts_connected` 一并作废,
2725    // 这里是不对称时刻——快照还没刷——的第二道闸)。
2726    // 健康行没有细节(`count_note(…, 0) = None`),所以这一段不会随账户数无谓变长。
2727    for a in accounts
2728        .iter()
2729        .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2730    {
2731        let note = a.status_note.clone().unwrap_or_default();
2732        let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2733        let color = if alarm {
2734            crate::tokens::semantic::ERROR
2735        } else {
2736            crate::tokens::semantic::INFO
2737        };
2738        ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2739        // 0.3.2(Wave E,BD-UI-50):告警 toast **不在这里产生**。产生口只有一处
2740        // = `UiShared::note_gateway_status`(轮询一拍),判据 e6 钉着这条归属。
2741        // 在这里弹 = 账户窗没开就没有告警,而 Python 的 AlertEngine 与哪个窗在不在无关。
2742    }
2743    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
2744    if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2745        if src.form.md_addr.trim().is_empty() {
2746            ui.label(
2747                egui::RichText::new(format!(
2748                    "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2749                    src.gateway,
2750                    row_label
2751                        .get(&src.gateway)
2752                        .cloned()
2753                        .unwrap_or_else(|| src.account.clone())
2754                ))
2755                .color(crate::tokens::semantic::WARN),
2756            );
2757        }
2758    }
2759}
2760
2761/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
2762/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
2763/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
2764/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
2765/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2766fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2767    let editing: Option<crate::state::AccountRow> =
2768        shared.editing_account().and_then(|g| shared.account(&g));
2769    let editing_on = editing.is_some();
2770    let md = shared.md_source();
2771    let locked = shared.accounts_locked();
2772    let mut form: crate::state::CtpForm = shared.ctp_form();
2773    let mut alias = shared.edit_alias();
2774    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
2775    if let Some(e) = shared.edit_error() {
2776        ui.label(egui::RichText::new(format!("拦下:{e}")).color(crate::tokens::semantic::ERROR));
2777    }
2778    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
2779    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
2780    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
2781    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
2782    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
2783    ui.horizontal(|ui| {
2784        ui.label("别名");
2785        if ui
2786            .add(
2787                egui::TextEdit::singleline(&mut alias)
2788                    .hint_text("显示用,可空")
2789                    .desired_width(120.0),
2790            )
2791            .changed()
2792        {
2793            shared.set_edit_alias(alias.clone());
2794        }
2795        ui.label("broker");
2796        ui.add_enabled_ui(!editing_on, |ui| {
2797            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2798        });
2799        ui.label("td 前置");
2800        ctp_edit(
2801            ui,
2802            shared,
2803            &mut form.td_addr,
2804            "tcp://host:40001",
2805            CtpField::TdAddr,
2806        );
2807        ui.label("td 用户");
2808        ui.add_enabled_ui(!editing_on, |ui| {
2809            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2810        });
2811    });
2812    if let Some(r) = &editing {
2813        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
2814        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
2815        ui.label(
2816            egui::RichText::new(format!(
2817                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2818                r.gateway
2819            ))
2820            .color(crate::tokens::semantic::WARN),
2821        );
2822    }
2823    ui.horizontal(|ui| {
2824        ui.label("td 密码");
2825        ctp_edit(
2826            ui,
2827            shared,
2828            &mut form.td_password,
2829            "密码",
2830            CtpField::TdPassword,
2831        );
2832        ui.label("AppID");
2833        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2834        ui.label("AuthCode");
2835        ctp_edit(
2836            ui,
2837            shared,
2838            &mut form.td_auth_code,
2839            "授权码",
2840            CtpField::AuthCode,
2841        );
2842    });
2843    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 三格置灰。
2844    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
2845    let md_editable = match &editing {
2846        None => true,
2847        Some(r) => !locked || r.gateway == md,
2848    };
2849    ui.horizontal(|ui| {
2850        ui.label("md 前置");
2851        ui.add_enabled_ui(md_editable, |ui| {
2852            ctp_edit(
2853                ui,
2854                shared,
2855                &mut form.md_addr,
2856                "tcp://host:40011",
2857                CtpField::MdAddr,
2858            );
2859            ui.label("md 用户");
2860            ctp_edit(
2861                ui,
2862                shared,
2863                &mut form.md_user,
2864                "空=td 回落",
2865                CtpField::MdUser,
2866            );
2867            ui.label("md 密码");
2868            ctp_edit(
2869                ui,
2870                shared,
2871                &mut form.md_password,
2872                "空=td 回落",
2873                CtpField::MdPassword,
2874            );
2875        });
2876        if !md_editable {
2877            ui.label(
2878                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2879                    .color(crate::tokens::semantic::WARN),
2880            );
2881        }
2882    });
2883    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
2884    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
2885    ui.horizontal(|ui| {
2886        ui.label("收录勾选:");
2887        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2888        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2889        if fut || opt {
2890            shared.set_ctp_form(form.clone());
2891        }
2892        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2893    });
2894    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
2895    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
2896    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
2897    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
2898    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
2899    if let Some(r) = &editing {
2900        ui.horizontal(|ui| {
2901            ui.label("网关实际收录(只读回带):");
2902            ui.label(if r.effective_classes.is_empty() {
2903                "—(尚未装配或无回带)".to_string()
2904            } else {
2905                classes_label(&r.effective_classes)
2906            });
2907            ui.label("装配/登录状态(只读):");
2908            ui.label(format!(
2909                "{}{}",
2910                r.status_label(),
2911                r.fail_note
2912                    .as_ref()
2913                    .map(|n| format!(" —— {n}"))
2914                    .unwrap_or_default()
2915            ));
2916        });
2917    }
2918    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
2919    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2920    if shared.account_edit_dirty() {
2921        ui.label(
2922            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2923                .color(crate::tokens::semantic::WARN),
2924        );
2925    }
2926    ui.horizontal(|ui| {
2927        if ui.button("保存").clicked() {
2928            match save_account_draft(shared, &form, &alias, false) {
2929                Ok(()) => shared.end_account_edit(),
2930                Err(e) => {
2931                    shared.log_line(format!("[账户] 保存被拦:{e}"));
2932                    shared.set_edit_error(Some(e));
2933                }
2934            }
2935        }
2936        if ui.button("保存并连接").clicked() {
2937            match save_account_draft(shared, &form, &alias, true) {
2938                Ok(()) => shared.end_account_edit(),
2939                Err(e) => {
2940                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2941                    shared.set_edit_error(Some(e));
2942                }
2943            }
2944        }
2945        if ui.button("取消").clicked() {
2946            shared.end_account_edit();
2947        }
2948        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2949    });
2950}
2951
2952/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
2953fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2954    shared
2955        .accounts()
2956        .iter()
2957        .map(|a| a.gateway.clone())
2958        .collect()
2959}
2960
2961/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
2962///
2963/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
2964/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
2965/// 用户会把已摘除账户的委托当成在场账户的。
2966/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
2967/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
2968fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2969    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2970        gateway.to_string()
2971    } else {
2972        format!("{gateway}(已摘除)")
2973    }
2974}
2975
2976/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
2977fn classes_label(classes: &[String]) -> String {
2978    if classes.is_empty() {
2979        return "—".to_string();
2980    }
2981    classes
2982        .iter()
2983        .map(|c| match c.as_str() {
2984            "1" => "期货",
2985            "2" => "期权",
2986            other => other,
2987        })
2988        .collect::<Vec<_>>()
2989        .join("+")
2990}
2991
2992/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
2993///
2994/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
2995/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
2996fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2997    shared.push_cmd(UiCmd::Connect {
2998        gateway: gateway.to_string(),
2999        form: form.clone(),
3000    });
3001    shared.note_connect_sent(gateway);
3002    shared.log_line(format!(
3003        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
3004        gateway
3005    ));
3006}
3007
3008/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
3009///
3010/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
3011/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
3012///
3013/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
3014fn save_account_draft(
3015    shared: &UiShared,
3016    form: &crate::state::CtpForm,
3017    alias: &str,
3018    connect: bool,
3019) -> Result<(), String> {
3020    form.validate()?;
3021    if let Some(gw) = shared.editing_account() {
3022        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
3023        if shared.account(&gw).is_none() {
3024            return Err(format!(
3025                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
3026            ));
3027        }
3028        shared.update_account_form(&gw, form.clone());
3029        if !shared.set_account_alias(&gw, alias) {
3030            return Err(format!(
3031                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
3032            ));
3033        }
3034        shared.log_line(format!(
3035            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
3036        ));
3037        if connect {
3038            push_connect(shared, &gw, form);
3039        }
3040        return Ok(());
3041    }
3042    let mut row = crate::state::AccountRow::derive(form)?;
3043    row.alias = alias.trim().to_string();
3044    let gateway = row.gateway.clone();
3045    let account = row.account.clone();
3046    shared.add_account(row.clone())?;
3047    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
3048    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
3049    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
3050    let md = shared.md_source();
3051    let mut f = row.form.clone();
3052    if md.is_empty() && !shared.accounts_locked() {
3053        match shared.set_md_source(&gateway) {
3054            Ok(()) => {
3055                if f.md_addr.trim().is_empty() {
3056                    shared.log_line(format!(
3057                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
3058                    ));
3059                } else {
3060                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
3061                }
3062            }
3063            Err(e) => {
3064                shared.log_line(format!("[账户] {e}"));
3065                f.md_addr.clear();
3066                f.md_user.clear();
3067                f.md_password.clear();
3068            }
3069        }
3070    } else {
3071        f.md_addr.clear();
3072        f.md_user.clear();
3073        f.md_password.clear();
3074        if md.is_empty() {
3075            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
3076        }
3077    }
3078    shared.update_account_form(&gateway, f.clone());
3079    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
3080    shared.log_line(format!(
3081        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
3082    ));
3083    if connect {
3084        push_connect(shared, &gateway, &f);
3085    }
3086    Ok(())
3087}
3088
3089/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
3090/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
3091/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
3092fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
3093    let mut q = shared.contract_query();
3094    ui.horizontal(|ui| {
3095        ui.label("代码或交易所(留空 = 全部):");
3096        if ui
3097            .add(
3098                egui::TextEdit::singleline(&mut q)
3099                    .hint_text("cu 或 SHFE")
3100                    .desired_width(200.0),
3101            )
3102            .changed()
3103        {
3104            shared.set_contract_query(q.clone());
3105        }
3106    });
3107    let rows = shared.contracts(&q);
3108    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
3109    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
3110    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
3111    if rows.is_empty() {
3112        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
3113        // 0.2.29 审计修:空态分支原来走 `pinned_table`(无列线 / 列不可拖),与非空分支
3114        // `pinned_table_grid` 不一致 —— 同一张表(id_salt 相同)在空/非空之间切换时
3115        // 列语义与可拖状态都该同源。
3116        pinned_table_grid(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
3117            body.row(row_h, |mut row| {
3118                row.col(|ui| {
3119                    crate::ui_ext::muted_label(
3120                        ui,
3121                        "(无合约:先连接 CTP,合约随 eContract. 自动回流)",
3122                    );
3123                });
3124            });
3125            1
3126        });
3127        return;
3128    }
3129    let selected = shared.selected_contracts();
3130    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
3131    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
3132    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
3133    // 行溢出才出滚动条。
3134    ui.horizontal(|ui| {
3135        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
3136        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
3137            if selected.is_empty() {
3138                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
3139            } else {
3140                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
3141                let symbols: Vec<String> = selected
3142                    .iter()
3143                    .map(|k| {
3144                        k.split_once('.')
3145                            .map(|(_, s)| s.to_string())
3146                            .unwrap_or_else(|| k.clone())
3147                    })
3148                    .collect();
3149                match subscribe_target(shared) {
3150                    Ok(gw) => {
3151                        shared.push_cmd(UiCmd::CtpSubscribe {
3152                            gateway: gw.clone(),
3153                            symbols,
3154                        });
3155                        shared.log_line(format!(
3156                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
3157                            selected.len(),
3158                            gw
3159                        ));
3160                    }
3161                    Err(e) => shared.log_line(format!("[订阅] {e}")),
3162                }
3163            }
3164        }
3165    });
3166    // 网格线(BD-UI-41):Python 侧只有合约表开着网格(`widget.py:2423` / `2621` 建表时
3167    // 没调 `setShowGrid(False)`,交易表在 `367` 关了)→ 照它:合约表加,交易表不加。
3168    pinned_table_grid(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
3169        const RIGHT_COLS: [usize; 2] = [3, 4];
3170        for c in &rows {
3171            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
3172            let mut on = selected.contains(&key);
3173            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
3174            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
3175            // 行选中(动作归行,不归某一列)。
3176            let row_sel = shared.row_selected("contracts", &key);
3177            body.row(row_h, |mut row| {
3178                row.set_selected(row_sel);
3179                macro_rules! cell {
3180                    ($make:expr, $col:expr) => {{
3181                        let (cell_r, inner_r) = tcell(&mut row, RIGHT_COLS.contains(&$col), $make);
3182                        let hit = cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
3183                        if hit {
3184                            shared.select_row("contracts", &key);
3185                        }
3186                    }};
3187                }
3188                // 勾选框的 `changed()` 只在勾选框自己的 Response 上(格响应看不到它,
3189                // 见 `CellOutcome` 注释)→ 动作必须在格闭包里就地取。
3190                let mut ck_changed = false;
3191                row.col(|ui| {
3192                    if ui.checkbox(&mut on, key.clone()).changed() {
3193                        ck_changed = true;
3194                    }
3195                });
3196                if ck_changed {
3197                    shared.toggle_contract_selection(&key);
3198                    shared.select_row("contracts", &key);
3199                }
3200                cell!(|ui| ui.label(c.exchange.as_str().to_string()), 1);
3201                cell!(|ui| ui.label(c.name.clone()), 2);
3202                cell!(|ui| ui.label(crate::format::volume(c.size)), 3);
3203                cell!(|ui| ui.label(crate::format::price_tick(c.price_tick)), 4);
3204            });
3205        }
3206        rows.len()
3207    });
3208}
3209
3210/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
3211enum CtpField {
3212    Broker,
3213    TdAddr,
3214    TdUser,
3215    TdPassword,
3216    AppId,
3217    AuthCode,
3218    MdAddr,
3219    MdUser,
3220    MdPassword,
3221}
3222
3223/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
3224fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
3225    let is_secret = matches!(
3226        field,
3227        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
3228    );
3229    let mut w = egui::TextEdit::singleline(val)
3230        .hint_text(hint)
3231        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
3232        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
3233        .desired_width(210.0);
3234    if is_secret {
3235        w = w.password(true);
3236    }
3237    if ui.add(w).changed() {
3238        let mut f = shared.ctp_form();
3239        match field {
3240            CtpField::Broker => f.broker_id = val.clone(),
3241            CtpField::TdAddr => f.td_addr = val.clone(),
3242            CtpField::TdUser => f.td_user = val.clone(),
3243            CtpField::TdPassword => f.td_password = val.clone(),
3244            CtpField::AppId => f.td_app_id = val.clone(),
3245            CtpField::AuthCode => f.td_auth_code = val.clone(),
3246            CtpField::MdAddr => f.md_addr = val.clone(),
3247            CtpField::MdUser => f.md_user = val.clone(),
3248            CtpField::MdPassword => f.md_password = val.clone(),
3249        }
3250        shared.set_ctp_form(f);
3251    }
3252}
3253
3254/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
3255///
3256/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
3257fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
3258    let Some(c) = cell else {
3259        ui.label("(K线无格:等 dm 合成)");
3260        return;
3261    };
3262    if c.bars.is_empty() {
3263        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
3264        return;
3265    }
3266    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
3267    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
3268    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
3269    let mut data = egui_charts::model::bar::BarData::new();
3270    for b in &c.bars {
3271        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
3272            .with_timezone(&beijing_tz())
3273            .with_timezone(&chrono::Utc);
3274        data.push(egui_charts::model::bar::Bar::new(
3275            dt,
3276            b.open_price,
3277            b.high_price,
3278            b.low_price,
3279            b.close_price,
3280            b.volume,
3281        ));
3282    }
3283    ui.label(format!(
3284        "K线 {}@{}s({} 根)",
3285        c.symbol,
3286        c.interval,
3287        c.bars.len()
3288    ));
3289    // 0.2.30(BD-UI-46):图表色板不再自带它那套 TradingView 令牌。
3290    // egui_charts 0.2 的 `ChartConfig::default()` 读的是它自己的 RON `DESIGN_TOKENS`
3291    // (bullish `#26a69a` 青 / bearish `#F23645` 红)——**涨跌方向正好和我们的
3292    // 红涨绿跌相反**,而且它不读 egui `Visuals`,界面换什么主题都影响不到它。
3293    // 所以这里显式覆盖:涨 = RISE(红)、跌 = FALL(绿),底/网格/文字走 qdark 色板,
3294    // 让 K 线和界面是一家人(交接报告 §3 Wave B 的「ChartConfig 显式覆盖」项)。
3295    let cfg = egui_charts::config::ChartConfig {
3296        bullish_color: crate::tokens::semantic::RISE,
3297        bearish_color: crate::tokens::semantic::FALL,
3298        background_color: crate::tokens::qdark::BG1,
3299        grid_color: crate::tokens::qdark::BG4,
3300        text_color: crate::tokens::qdark::TEXT1,
3301        ..egui_charts::config::ChartConfig::default()
3302    };
3303    let mut chart = egui_charts::widget::Chart::with_config(data, cfg).visible_bars(60);
3304    chart.set_symbol(&c.symbol);
3305    chart.set_timeframe_label(&format!("{}s", c.interval));
3306    chart.enable_tracking_mode();
3307    let _ = chart.show(ui);
3308}
3309
3310#[cfg(test)]
3311mod tests {
3312    use super::*;
3313
3314    // 表头带 / tab chrome **派生规则**钉(BD-UI-41,0.2.30 收缩成一套主题):
3315    // Chrome 的每个角色必须取到令牌里那一档,不许自己挑。
3316    // (qss 原值本身由 `tests/ui_kittest.rs` 的 `u1_qdark_palette_matches_qss` 钉 ——
3317    //  色值字面量只留在测试侧,src 侧不写 `from_rgb`。)
3318    // 原来这里还有一条「不得与金融深色那一列混同」:金融深色已随主题面删除,
3319    // 判据对象不存在,删掉,不留一条空转的断言。
3320    #[test]
3321    fn chrome_derives_from_token_palette() {
3322        let v = crate::tokens::visuals();
3323        let ch = Chrome::of(&v);
3324        // qss:1959-1970 `QHeaderView::section { background #455364; color #DFE1E2 }`
3325        assert_eq!(ch.line, crate::tokens::qdark::BG4);
3326        assert_eq!(ch.header_text, crate::tokens::qdark::TEXT1);
3327        // qss:1579 `QTabBar::tab:top { background-color: #455364 }`
3328        assert_eq!(ch.tab_head_fill, crate::tokens::qdark::BG4);
3329        // qss:1592 `QTabBar::tab:top:selected { background #54687A;
3330        //   border-bottom: 3px solid #259AE9 }`
3331        assert_eq!(ch.tab_fill, crate::tokens::qdark::BG5);
3332        assert_eq!(ch.tab_underline, crate::tokens::qdark::ACCENT4);
3333    }
3334
3335    // 日志入环打戳钉(BD-UI-41):日志表格化后「时间」列的事实源是 `LogRing`,
3336    // 打戳必须与表内时间列(`fmt_dt`)同一个口径 = 北京 +08,不能跟本机时区走
3337    // (本机不是 +08 时,口径错 = 日志与成交表差几小时,交易员对不上账)。
3338    #[test]
3339    fn log_entries_stamp_beijing_wall_clock() {
3340        let mut ring = crate::state::LogRing::new(4);
3341        ring.push("[ops] 一行");
3342        let e = &ring.entries()[0];
3343        assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
3344        assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
3345        for seg in e.at.split(':') {
3346            assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
3347        }
3348        let want = chrono::Utc::now()
3349            .with_timezone(&beijing_tz())
3350            .format("%H:%M:%S")
3351            .to_string();
3352        let secs = |s: &str| -> i64 {
3353            s.split(':')
3354                .map(|x| x.parse::<i64>().unwrap())
3355                .fold(0, |acc, v| acc * 60 + v)
3356        };
3357        let (a, b) = (secs(&e.at), secs(&want));
3358        let d = (a - b).abs();
3359        // 允许跨秒抖动,并容忍跨午夜(差值接近 86400 即同一口径的另一侧)。
3360        assert!(
3361            d <= 2 || d > 86_398,
3362            "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
3363        );
3364        // 旧门面不变:`snapshot()` 仍只给正文(测试与巡回取证靠它)。
3365        assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
3366    }
3367
3368    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
3369    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
3370    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
3371    #[test]
3372    fn fmt_dt_shows_beijing_wall_clock() {
3373        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
3374            ek_data::time::civil_to_nanos(ek_data::time::Civil {
3375                year: 2026,
3376                month: 4,
3377                day: 16,
3378                hour: 1,
3379                minute: 30,
3380                second: 1,
3381                nanos: 0,
3382            }),
3383        );
3384        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
3385        let beijing = expect.with_timezone(&beijing_tz());
3386        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
3387        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
3388        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
3389        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
3390            year: 2026,
3391            month: 4,
3392            day: 15,
3393            hour: 16,
3394            minute: 0,
3395            second: 30,
3396            nanos: 0,
3397        });
3398        // 0.3.1(Wave D):格式从 `HH:MM:SS` 收成 `HH:MM:SS.mmm`(照 Python `TimeCell`),
3399        // 时区口径没动 —— 这条钉的仍是「北京墙钟」这件事,不是小数位。
3400        assert_eq!(crate::format::time_hms_millis(Some(ns)), "00:00:30.000");
3401        // None → 占位
3402        assert_eq!(crate::format::time_hms_millis(None), "-");
3403    }
3404}