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