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