Skip to main content

ek_ui/
gateway.rs

1//! 交易网关 —— 宿主模块窗(0.9.34「一模块一菜单一窗」波)。
2//!
3//! 形态(用户裁定):**一个菜单项「交易网关」= 一扇窗 = 一张表,一行一个账户**。
4//! 它是原「账户面板」的升级版,不是新增的第二个编辑面 —— 身份键仍然只有这一处能改。
5//!
6//! 四组列:
7//! - **身份**:网关(派生名)/ 别名 / 账号 / 经纪商 / td 前置 / md 前置;
8//! - **运行期参数**:收录(产品类)—— **按行原子写**:只写该行 `ctp.{gw}` 段;
9//! - **运行状态**:连接 / 阶段(链)/ 行情源 / 入向异常计数(ek-ctp 0.12.0 显形面);
10//! - **操作**:连接 / 断开 / 自动连 / 编辑 / 移除(只投 `UiCmd`,不直接动引擎)。
11//!
12//! **未装配也能用**:档案行(新增/编辑/删除)不依赖 ctp 装配 —— 先建档案再连接。
13//! 运行状态列在未装配时显示「未装配」,不是空表。
14
15use ek_core::cfg_face::CfgHost;
16use ek_core::section::{Field, SectionMap};
17
18use crate::render::{
19    BTN_ACCOUNT_ADD, BTN_CANCEL_CONFIRM, OpTone, classes_label, grid_chrome, header_band,
20    op_button, paint_row_band, push_connect, table_heights,
21};
22use crate::state::{ConfirmStep, UiCmd, UiShared};
23
24/// 表格态(行编辑缓冲等)。当前只留位:行内编辑走 `egui::menu_button` 的即时态。
25#[derive(Default)]
26pub struct TableState {
27    /// 最近一次按行保存的回执(窗底一行,成功/失败都显形)。
28    pub toast: Option<String>,
29}
30
31impl TableState {
32    /// 空态。
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37}
38
39/// 渲染交易网关表。
40pub fn render(ui: &mut egui::Ui, st: &mut TableState, shared: &UiShared) {
41    let accounts = shared.accounts();
42    let locked = shared.accounts_locked();
43    let md = shared.md_source();
44    let snap = shared.trade_snap();
45    // 宿主桥(写口单源:cfg.save 总线,cfg 缺席 = 直写托底)。
46    let host = crate::mount::CfgBridge::new(shared);
47
48    // 行显示名(别名优先)只用于「确认行」点名;按钮一律光杆(行即对象)。
49    let row_label: std::collections::BTreeMap<String, String> = accounts
50        .iter()
51        .map(|a| {
52            let d = a.display_name();
53            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
54            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
55        })
56        .collect();
57
58    // 档案根读不动 = **整窗顶部挂橙条**(0.9.39,用户实测「账户登录上去也没有了」):
59    // 空表必须能自证"是读不出,不是丢了"。悬停给完整错误。
60    if let Some(err) = shared.store_err() {
61        ui.add(
62            egui::Label::new(
63                egui::RichText::new("⚠ 档案读不出来,账户表为空(数据未丢)")
64                    .color(crate::tokens::warn(ui.ctx())),
65            )
66            .wrap_mode(egui::TextWrapMode::Extend),
67        )
68        .on_hover_text(err);
69    }
70    // 工具条:行情源一览 + 计数 + 『添加账户』(主钮)。
71    ui.horizontal(|ui| {
72        ui.label(
73            egui::RichText::new(format!(
74                "交易网关 · 当前行情源 = {}",
75                if md.is_empty() {
76                    "(未指定)".to_string()
77                } else {
78                    md.clone()
79                }
80            ))
81            .strong(),
82        );
83        ui.label(format!("账户 {} 个", accounts.len()));
84        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
85            if shared.can_add_account() && op_button(ui, BTN_ACCOUNT_ADD, OpTone::Primary).clicked()
86            {
87                shared.begin_account_edit(None);
88            }
89        });
90    });
91
92    const COLS: usize = 12;
93    const HEADERS: [&str; COLS] = [
94        "网关",
95        "别名",
96        "账号",
97        "经纪商",
98        "td 前置",
99        "md 前置",
100        "收录",
101        "连接",
102        "阶段",
103        "行情源",
104        "异常",
105        "操作",
106    ];
107    // 四组口径(用户裁定「身份 / 运行期参数 / 运行状态 / 操作」):表头保持单词
108    // (列名是检索锚点,不加组前缀),分组用这一句图例交代。
109    ui.label(
110        egui::RichText::new(
111            "列分组:身份(网关·别名·账号·经纪商·前置) │ 运行期参数 │ 运行状态(连接·阶段·行情源·异常) │ 操作",
112        )
113        .small()
114        .weak(),
115    );
116    let mut heads: Vec<(egui::Rect, String)> = Vec::new();
117    let mut rows: Vec<f32> = Vec::new();
118    let col_w = crate::ui_ext::grid_col_width(ui, "gateway_grid", COLS);
119    let row_h = table_heights(ui).0;
120    let band_x1 = ui.max_rect().max.x;
121    egui::Grid::new("gateway_grid")
122        .num_columns(COLS)
123        .min_col_width(col_w)
124        .min_row_height(row_h)
125        .show(ui, |ui| {
126            for h in &HEADERS {
127                // 表头格 = 透明占位(字的唯一落笔点 = `header_band`,与主界面同源)。
128                let r = ui.label(
129                    egui::RichText::new(*h)
130                        .strong()
131                        .small()
132                        .color(egui::Color32::TRANSPARENT),
133                );
134                heads.push((r.rect, h.to_string()));
135            }
136            ui.end_row();
137            // 空态 = 只有表头行。
138            for a in &accounts {
139                let row_y = ui.cursor().min.y;
140                let row_x0 = ui.cursor().min.x;
141                paint_row_band(ui, row_x0, row_y, band_x1);
142                let is_md = a.gateway == md;
143                // ── 身份·网关(派生名 + 档案徽标)。
144                let badge = if a.restored {
145                    if a.password_ref {
146                        ("档案", crate::tokens::muted(ui.ctx()), "档案行:以存档参数连接;密码走密库(值永不回显);改地址走『编辑』")
147                    } else {
148                        ("缺密码", crate::tokens::error(ui.ctx()), "缺密码引用:连接会被柜台拒;『编辑』重输密码后『保存并连接』补录")
149                    }
150                } else {
151                    ("", crate::tokens::muted(ui.ctx()), "")
152                };
153                ui.horizontal(|ui| {
154                    ui.label(egui::RichText::new(a.gateway.clone()).strong());
155                    if !badge.0.is_empty() {
156                        let b = ui.add(
157                            egui::Label::new(egui::RichText::new(badge.0).color(badge.1).small())
158                                .sense(egui::Sense::hover()),
159                        );
160                        b.on_hover_text(badge.2);
161                    }
162                });
163                // ── 身份·别名(空 = —)。
164                ui.label(if a.alias.trim().is_empty() {
165                    "—".to_string()
166                } else {
167                    a.alias.clone()
168                });
169                // ── 身份·账号。
170                ui.label(a.account.clone());
171                // ── 身份·经纪商。
172                ui.label(if a.broker_id.trim().is_empty() {
173                    "—".to_string()
174                } else {
175                    a.broker_id.clone()
176                });
177                // ── 身份·td 前置(整串直显,悬停给全串)。
178                let td = a.form.td_addr.trim();
179                ui.add(
180                    egui::Label::new(egui::RichText::new(if td.is_empty() {
181                        "—".to_string()
182                    } else {
183                        td.to_string()
184                    }))
185                    .sense(egui::Sense::hover())
186                    .wrap_mode(egui::TextWrapMode::Extend),
187                )
188                .on_hover_text(format!("td 前置:{td};要改地址端口 = 该行『编辑』→『保存并连接』"));
189                // ── 身份·md 前置(空 = 无行情侧;行情源行必须填)。
190                let md_addr = a.form.md_addr.trim();
191                ui.add(
192                    egui::Label::new(egui::RichText::new(if md_addr.is_empty() {
193                        "—".to_string()
194                    } else {
195                        md_addr.to_string()
196                    }))
197                    .sense(egui::Sense::hover())
198                    .wrap_mode(egui::TextWrapMode::Extend),
199                )
200                .on_hover_text(format!("md 前置:{md_addr};空 = 该账户无行情侧"));
201                // ── 参数·收录(意图 = 档案段 product_classes;事实 = 网关回带)。
202                // **按行原子**:只写该行 `ctp.{gw}` 段,绝不"保存全部"。
203                ui.horizontal(|ui| {
204                    let shown = if a.effective_classes.is_empty() {
205                        classes_label(&a.form.product_classes())
206                    } else {
207                        classes_label(&a.effective_classes)
208                    };
209                    ui.label(shown);
210                    ui.menu_button("改", |ui| {
211                        let mut fut = a.form.accept_futures;
212                        let mut opt = a.form.accept_options;
213                        if ui.checkbox(&mut fut, "期货(1)").changed() {
214                            let codes = pick_codes(fut, opt);
215                            match save_product_classes(shared, &host, &a.gateway, &codes) {
216                                Ok(()) => {
217                                    st.toast = Some(format!(
218                                        "[网关] {} 收录已存:{}(只写该行档案段;重连后生效)",
219                                        a.gateway,
220                                        classes_label(&codes)
221                                    ));
222                                    ui.close();
223                                }
224                                Err(e) => st.toast = Some(format!("[网关] 收录保存失败:{e}")),
225                            }
226                        }
227                        if ui.checkbox(&mut opt, "期权(2)").changed() {
228                            let codes = pick_codes(fut, opt);
229                            match save_product_classes(shared, &host, &a.gateway, &codes) {
230                                Ok(()) => {
231                                    st.toast = Some(format!(
232                                        "[网关] {} 收录已存:{}(只写该行档案段;重连后生效)",
233                                        a.gateway,
234                                        classes_label(&codes)
235                                    ));
236                                    ui.close();
237                                }
238                                Err(e) => st.toast = Some(format!("[网关] 收录保存失败:{e}")),
239                            }
240                        }
241                        ui.label(
242                            egui::RichText::new("都不勾 = 回落默认期货")
243                                .small()
244                                .weak(),
245                        );
246                    })
247                    .response
248                    .on_hover_text(
249                        "收录 = 该账户收哪些产品类;只改这一行,不影响其它账户。\n\
250                         想立即生效:点该行『断开』再『连接』。",
251                    );
252                });
253                // ── 状态·连接(装配态 + 登录读数;告警带柜台短因)。
254                match a.stage {
255                    crate::state::AccountStage::AssembleFailed => {
256                        ui.add(
257                            egui::Label::new(
258                                egui::RichText::new(format!(
259                                    "装配失败:{}",
260                                    a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
261                                ))
262                                .color(crate::tokens::error(ui.ctx())),
263                            )
264                            .wrap_mode(egui::TextWrapMode::Extend),
265                        );
266                    }
267                    _ => {
268                        let color = match a.login {
269                            crate::state::LoginState::Online => crate::tokens::ok(ui.ctx()),
270                            crate::state::LoginState::LoginFailed
271                            | crate::state::LoginState::FrontDown => {
272                                crate::tokens::error(ui.ctx())
273                            }
274                            crate::state::LoginState::QueryStarved => {
275                                crate::tokens::warn(ui.ctx())
276                            }
277                            _ => crate::tokens::muted(ui.ctx()),
278                        };
279                        let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
280                            (true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
281                            _ => a.status_label(),
282                        };
283                        ui.add(
284                            egui::Label::new(egui::RichText::new(text).color(color))
285                                .wrap_mode(egui::TextWrapMode::Extend),
286                        );
287                    }
288                }
289                // ── 状态·阶段(网关推送的链状态;只显形不参与判据)。
290                ui.add(
291                    egui::Label::new(
292                        egui::RichText::new(a.chain_note.clone().unwrap_or_else(|| "—".to_string()))
293                            .color(crate::tokens::muted(ui.ctx())),
294                    )
295                    .wrap_mode(egui::TextWrapMode::Extend),
296                );
297                // ── 状态·行情源。
298                ui.horizontal(|ui| {
299                    if is_md {
300                        ui.label(egui::RichText::new("● 行情源").strong());
301                    }
302                    let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
303                        op_button(
304                            ui,
305                            if is_md { "已是行情源" } else { "设为行情源" },
306                            OpTone::Ghost,
307                        )
308                    });
309                    if resp.inner.clicked() {
310                        match shared.set_md_source(&a.gateway) {
311                            Ok(()) => shared.log_line(format!("[网关] 行情源 = {}", a.gateway)),
312                            Err(e) => shared.log_line(format!("[网关] {e}")),
313                        }
314                    }
315                });
316                // ── 状态·异常(入向零丢弃的显形面:非零 = 有报文被折算,要看)。
317                let (td_an, md_an) = (a.td_anomaly, a.md_anomaly);
318                if td_an == 0 && md_an == 0 {
319                    ui.add(
320                        egui::Label::new(
321                            egui::RichText::new("—").color(crate::tokens::muted(ui.ctx())),
322                        )
323                        .wrap_mode(egui::TextWrapMode::Extend),
324                    );
325                } else {
326                    ui.add(
327                        egui::Label::new(
328                            egui::RichText::new(format!("td {td_an} / md {md_an}"))
329                                .color(crate::tokens::warn(ui.ctx())),
330                        )
331                        .wrap_mode(egui::TextWrapMode::Extend),
332                    )
333                    .on_hover_text(
334                        "入向异常计数:柜台报文里认不出的交易所码/状态码/时间戳/方向/缺委托号的成交…\n\
335                         网关不丢包,一律折算成可读值并在这里记数(ek-ctp 0.12.0 口径)。",
336                    );
337                }
338                // ── 操作(光杆钮;行即对象)。
339                ui.horizontal(|ui| {
340                    let failed = a.stage == crate::state::AccountStage::AssembleFailed;
341                    let idle = a.stage == crate::state::AccountStage::NotAssembled;
342                    let stopped = a.login == crate::state::LoginState::Stopped;
343                    // 0.9.37(用户实测「登录上去也没有了」):配置相点「连接/断开/移除」
344                    // 只是入列 —— 消费这些指令的 ui-cmd 线程要等 ui 插件起跑才存在,
345                    // 旧样子 = 点了没反应。0.9.41 复审:这里**不画**这三颗钮(置灰也能点,
346                    // 仍会入列未消费),只留一句说明;悬停补全原因。
347                    let setup = shared.in_setup();
348                    if setup {
349                        // 配置相不发指令:消费 `UiCmd` 的 ui-cmd 线程只在起跑后存在,
350                        // 画一颗点了没反应的钮 = 骗人。这里只留一句说明(0.9.37)。
351                        ui.add(
352                            egui::Label::new(
353                                egui::RichText::new("启动运行后可连")
354                                    .color(crate::tokens::muted(ui.ctx())),
355                            )
356                            .wrap_mode(egui::TextWrapMode::Extend),
357                        )
358                        .on_hover_text("配置相不发指令:点上方「启动运行」,起来后这行才有「连接」");
359                    }
360                    if !setup && (failed || idle || stopped) {
361                        let label = if failed { "重试" } else { "连接" };
362                        if op_button(ui, label, OpTone::Primary).clicked() {
363                            if a.restored {
364                                if stopped {
365                                    shared.push_cmd(UiCmd::Remove {
366                                        name: a.gateway.clone(),
367                                    });
368                                    shared.push_cmd(UiCmd::Start {
369                                        factory: "ctp".to_string(),
370                                        name: a.gateway.clone(),
371                                    });
372                                    shared.log_line(format!(
373                                        "[网关] {gw} 重试:摘重起(存档直连;活跃委托时摘被拒会显形)",
374                                        gw = a.gateway
375                                    ));
376                                } else {
377                                    shared.push_cmd(UiCmd::Start {
378                                        factory: "ctp".to_string(),
379                                        name: a.gateway.clone(),
380                                    });
381                                    shared.log_line(format!(
382                                        "[网关] {gw}:已按存档起连(svctl.start;密码走密库,不回显;参数以存档段为准 —— 改地址请『编辑』→『保存并连接』)",
383                                        gw = a.gateway
384                                    ));
385                                }
386                            } else {
387                                push_connect(shared, &a.gateway, &a.form);
388                            }
389                        }
390                    }
391                    let open = snap
392                        .orders
393                        .iter()
394                        .filter(|o| o.gateway_name == a.gateway && o.is_active())
395                        .count();
396                    if !setup
397                        && a.stage == crate::state::AccountStage::Assembled
398                        && a.login != crate::state::LoginState::Stopped
399                        && open == 0
400                        && op_button(ui, "断开", OpTone::Ghost).clicked()
401                    {
402                        shared.push_cmd(UiCmd::Disconnect {
403                            gateway: a.gateway.clone(),
404                        });
405                        shared.log_line(format!(
406                            "[网关] 断开 {gw}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
407                            gw = a.gateway
408                        ));
409                    }
410                    let mut auto_on = a.auto_connect;
411                    let auto_resp = ui.checkbox(&mut auto_on, "自动连");
412                    if auto_resp.changed() {
413                        match shared.set_auto_connect(&a.gateway, auto_on) {
414                            Ok(()) => shared.log_line(format!(
415                                "[网关] {} 自动连接 = {}(落盘请求已置:手点才写 ui 段)",
416                                a.gateway,
417                                if auto_on { "开" } else { "关" }
418                            )),
419                            Err(e) => shared.log_line(format!("[网关] {e}")),
420                        }
421                    }
422                    if op_button(ui, "编辑", OpTone::Ghost).clicked() {
423                        shared.begin_account_edit(Some(&a.gateway));
424                    }
425                    if open > 0 {
426                        if op_button(ui, &format!("先撤 {open} 笔"), OpTone::Danger).clicked() {
427                            for o in snap
428                                .orders
429                                .iter()
430                                .filter(|o| o.gateway_name == a.gateway && o.is_active())
431                            {
432                                shared.push_cmd(UiCmd::CancelOrder {
433                                    orderid: o.orderid.clone(),
434                                    gateway: a.gateway.clone(),
435                                });
436                            }
437                            shared.log_line(format!(
438                                "[网关] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
439                                a.gateway, open
440                            ));
441                        }
442                    } else {
443                        let armed = shared.pending_confirm();
444                        let armed_here = armed.as_deref() == Some(a.gateway.as_str());
445                        let label = if armed_here { "确认移除?" } else { "移除" };
446                        let tone = if armed_here { OpTone::Confirm } else { OpTone::Danger };
447                        if !setup
448                            && op_button(ui, label, tone).clicked()
449                            && shared.confirm_step(&a.gateway) == ConfirmStep::Fired
450                        {
451                            shared.push_cmd(UiCmd::Remove {
452                                name: a.gateway.clone(),
453                            });
454                            shared.remove_account(&a.gateway);
455                            shared.end_account_edit_if_editing(&a.gateway);
456                            let has_pos = snap.positions.iter().any(|p| {
457                                p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0
458                            });
459                            if has_pos {
460                                shared.log_line(format!(
461                                    "[网关] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
462                                    a.gateway
463                                ));
464                            }
465                        }
466                    }
467                });
468                rows.push(ui.min_rect().max.y);
469                ui.end_row();
470            }
471        });
472    let band = header_band(ui, &heads, ui.min_rect().max.x);
473    grid_chrome(ui, band, &heads, &rows);
474
475    // 按行保存回执(窗底一行;成功/失败都显形,不静默)。
476    if let Some(msg) = &st.toast {
477        let color = if msg.contains("失败") {
478            crate::tokens::error(ui.ctx())
479        } else {
480            crate::tokens::ok(ui.ctx())
481        };
482        ui.label(egui::RichText::new(msg.as_str()).color(color));
483    }
484
485    // 二次确认提示(唯一保留的表下句)。
486    if let Some(gw) = shared.pending_confirm() {
487        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
488        ui.horizontal(|ui| {
489            ui.label(
490                egui::RichText::new(format!("移除确认中:再点该行的『确认移除』完成({shown})"))
491                    .color(crate::tokens::warn(ui.ctx())),
492            );
493            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
494                shared.confirm_cancel();
495            }
496        });
497    }
498
499    // 元账号 md 前置为空 = 行情永不出数(业务告警,只在真为空时出现一行)。
500    if let Some(src) = accounts.iter().find(|a| a.gateway == md)
501        && src.form.md_addr.trim().is_empty()
502    {
503        ui.label(
504            egui::RichText::new(format!(
505                "元账号 {} 的 md 前置为空:点该行『编辑』补填 md 前置,否则行情不会出数",
506                src.gateway
507            ))
508            .color(crate::tokens::warn(ui.ctx())),
509        );
510    }
511}
512
513/// 勾选 → 产品类码(`1` 期货 / `2` 期权;都不勾 = 空 = 回落默认期货)。
514fn pick_codes(futures: bool, options: bool) -> Vec<String> {
515    let mut v = Vec::new();
516    if futures {
517        v.push("1".to_string());
518    }
519    if options {
520        v.push("2".to_string());
521    }
522    v
523}
524
525/// **按行原子**写收录:读该行段 → 改 `product_classes` → 整段写回(只碰该 `ns`)。
526///
527/// 不走"保存全部":一个窗十行账户 = 十个独立段,没有跨段事务;改 A 行绝不触碰 B 行。
528/// `host` 由调用方给(窗体用 [`crate::mount::CfgBridge::new`];判据可钉存储根)。
529pub fn save_product_classes(
530    shared: &UiShared,
531    host: &dyn CfgHost,
532    gateway: &str,
533    codes: &[String],
534) -> Result<(), String> {
535    let ns = format!("ctp.{gateway}");
536    let mut map = match host.cfg_load(&ns)? {
537        Some(m) => m,
538        None => SectionMap::new(1),
539    };
540    map = map.set("product_classes", Field::plain(codes.join(",")));
541    host.cfg_save(&ns, &map)?;
542    shared.log_line(format!(
543        "[网关] {gateway} 收录已存:{}(只写 ctp.{gateway} 段;重连后生效)",
544        classes_label(codes)
545    ));
546    Ok(())
547}