ek-ui 0.9.37

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
//! 交易网关 —— 宿主模块窗(0.9.34「一模块一菜单一窗」波)。
//!
//! 形态(用户裁定):**一个菜单项「交易网关」= 一扇窗 = 一张表,一行一个账户**。
//! 它是原「账户面板」的升级版,不是新增的第二个编辑面 —— 身份键仍然只有这一处能改。
//!
//! 四组列:
//! - **身份**:网关(派生名)/ 别名 / 账号 / 经纪商 / td 前置 / md 前置;
//! - **运行期参数**:收录(产品类)—— **按行原子写**:只写该行 `ctp.{gw}` 段;
//! - **运行状态**:连接 / 阶段(链)/ 行情源 / 入向异常计数(ek-ctp 0.12.0 显形面);
//! - **操作**:连接 / 断开 / 自动连 / 编辑 / 移除(只投 `UiCmd`,不直接动引擎)。
//!
//! **未装配也能用**:档案行(新增/编辑/删除)不依赖 ctp 装配 —— 先建档案再连接。
//! 运行状态列在未装配时显示「未装配」,不是空表。

use ek_core::cfg_face::CfgHost;
use ek_core::section::{Field, SectionMap};

use crate::render::{
    BTN_ACCOUNT_ADD, BTN_CANCEL_CONFIRM, OpTone, classes_label, grid_chrome, header_band,
    op_button, paint_row_band, push_connect, table_heights,
};
use crate::state::{ConfirmStep, UiCmd, UiShared};

/// 表格态(行编辑缓冲等)。当前只留位:行内编辑走 `egui::menu_button` 的即时态。
#[derive(Default)]
pub struct TableState {
    /// 最近一次按行保存的回执(窗底一行,成功/失败都显形)。
    pub toast: Option<String>,
}

impl TableState {
    /// 空态。
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

/// 渲染交易网关表。
pub fn render(ui: &mut egui::Ui, st: &mut TableState, shared: &UiShared) {
    let accounts = shared.accounts();
    let locked = shared.accounts_locked();
    let md = shared.md_source();
    let snap = shared.trade_snap();
    // 宿主桥(写口单源:cfg.save 总线,cfg 缺席 = 直写托底)。
    let host = crate::mount::CfgBridge::new(shared);

    // 行显示名(别名优先)只用于「确认行」点名;按钮一律光杆(行即对象)。
    let row_label: std::collections::BTreeMap<String, String> = accounts
        .iter()
        .map(|a| {
            let d = a.display_name();
            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
        })
        .collect();

    // 工具条:行情源一览 + 计数 + 『添加账户』(主钮)。
    ui.horizontal(|ui| {
        ui.label(
            egui::RichText::new(format!(
                "交易网关 · 当前行情源 = {}",
                if md.is_empty() {
                    "(未指定)".to_string()
                } else {
                    md.clone()
                }
            ))
            .strong(),
        );
        ui.label(format!("账户 {} 个", accounts.len()));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            if shared.can_add_account() && op_button(ui, BTN_ACCOUNT_ADD, OpTone::Primary).clicked()
            {
                shared.begin_account_edit(None);
            }
        });
    });

    const COLS: usize = 12;
    const HEADERS: [&str; COLS] = [
        "网关",
        "别名",
        "账号",
        "经纪商",
        "td 前置",
        "md 前置",
        "收录",
        "连接",
        "阶段",
        "行情源",
        "异常",
        "操作",
    ];
    // 四组口径(用户裁定「身份 / 运行期参数 / 运行状态 / 操作」):表头保持单词
    // (列名是检索锚点,不加组前缀),分组用这一句图例交代。
    ui.label(
        egui::RichText::new(
            "列分组:身份(网关·别名·账号·经纪商·前置) │ 运行期参数 │ 运行状态(连接·阶段·行情源·异常) │ 操作",
        )
        .small()
        .weak(),
    );
    let mut heads: Vec<(egui::Rect, String)> = Vec::new();
    let mut rows: Vec<f32> = Vec::new();
    let col_w = crate::ui_ext::grid_col_width(ui, "gateway_grid", COLS);
    let row_h = table_heights(ui).0;
    let band_x1 = ui.max_rect().max.x;
    egui::Grid::new("gateway_grid")
        .num_columns(COLS)
        .min_col_width(col_w)
        .min_row_height(row_h)
        .show(ui, |ui| {
            for h in &HEADERS {
                // 表头格 = 透明占位(字的唯一落笔点 = `header_band`,与主界面同源)。
                let r = ui.label(
                    egui::RichText::new(*h)
                        .strong()
                        .small()
                        .color(egui::Color32::TRANSPARENT),
                );
                heads.push((r.rect, h.to_string()));
            }
            ui.end_row();
            // 空态 = 只有表头行。
            for a in &accounts {
                let row_y = ui.cursor().min.y;
                let row_x0 = ui.cursor().min.x;
                paint_row_band(ui, row_x0, row_y, band_x1);
                let is_md = a.gateway == md;
                // ── 身份·网关(派生名 + 档案徽标)。
                let badge = if a.restored {
                    if a.password_ref {
                        ("档案", crate::tokens::muted(ui.ctx()), "档案行:以存档参数连接;密码走密库(值永不回显);改地址走『编辑』")
                    } else {
                        ("缺密码", crate::tokens::error(ui.ctx()), "缺密码引用:连接会被柜台拒;『编辑』重输密码后『保存并连接』补录")
                    }
                } else {
                    ("", crate::tokens::muted(ui.ctx()), "")
                };
                ui.horizontal(|ui| {
                    ui.label(egui::RichText::new(a.gateway.clone()).strong());
                    if !badge.0.is_empty() {
                        let b = ui.add(
                            egui::Label::new(egui::RichText::new(badge.0).color(badge.1).small())
                                .sense(egui::Sense::hover()),
                        );
                        b.on_hover_text(badge.2);
                    }
                });
                // ── 身份·别名(空 = —)。
                ui.label(if a.alias.trim().is_empty() {
                    "—".to_string()
                } else {
                    a.alias.clone()
                });
                // ── 身份·账号。
                ui.label(a.account.clone());
                // ── 身份·经纪商。
                ui.label(if a.broker_id.trim().is_empty() {
                    "—".to_string()
                } else {
                    a.broker_id.clone()
                });
                // ── 身份·td 前置(整串直显,悬停给全串)。
                let td = a.form.td_addr.trim();
                ui.add(
                    egui::Label::new(egui::RichText::new(if td.is_empty() {
                        "—".to_string()
                    } else {
                        td.to_string()
                    }))
                    .sense(egui::Sense::hover())
                    .wrap_mode(egui::TextWrapMode::Extend),
                )
                .on_hover_text(format!("td 前置:{td};要改地址端口 = 该行『编辑』→『保存并连接』"));
                // ── 身份·md 前置(空 = 无行情侧;行情源行必须填)。
                let md_addr = a.form.md_addr.trim();
                ui.add(
                    egui::Label::new(egui::RichText::new(if md_addr.is_empty() {
                        "—".to_string()
                    } else {
                        md_addr.to_string()
                    }))
                    .sense(egui::Sense::hover())
                    .wrap_mode(egui::TextWrapMode::Extend),
                )
                .on_hover_text(format!("md 前置:{md_addr};空 = 该账户无行情侧"));
                // ── 参数·收录(意图 = 档案段 product_classes;事实 = 网关回带)。
                // **按行原子**:只写该行 `ctp.{gw}` 段,绝不"保存全部"。
                ui.horizontal(|ui| {
                    let shown = if a.effective_classes.is_empty() {
                        classes_label(&a.form.product_classes())
                    } else {
                        classes_label(&a.effective_classes)
                    };
                    ui.label(shown);
                    ui.menu_button("改", |ui| {
                        let mut fut = a.form.accept_futures;
                        let mut opt = a.form.accept_options;
                        if ui.checkbox(&mut fut, "期货(1)").changed() {
                            let codes = pick_codes(fut, opt);
                            match save_product_classes(shared, &host, &a.gateway, &codes) {
                                Ok(()) => {
                                    st.toast = Some(format!(
                                        "[网关] {} 收录已存:{}(只写该行档案段;重连后生效)",
                                        a.gateway,
                                        classes_label(&codes)
                                    ));
                                    ui.close();
                                }
                                Err(e) => st.toast = Some(format!("[网关] 收录保存失败:{e}")),
                            }
                        }
                        if ui.checkbox(&mut opt, "期权(2)").changed() {
                            let codes = pick_codes(fut, opt);
                            match save_product_classes(shared, &host, &a.gateway, &codes) {
                                Ok(()) => {
                                    st.toast = Some(format!(
                                        "[网关] {} 收录已存:{}(只写该行档案段;重连后生效)",
                                        a.gateway,
                                        classes_label(&codes)
                                    ));
                                    ui.close();
                                }
                                Err(e) => st.toast = Some(format!("[网关] 收录保存失败:{e}")),
                            }
                        }
                        ui.label(
                            egui::RichText::new("都不勾 = 回落默认期货")
                                .small()
                                .weak(),
                        );
                    })
                    .response
                    .on_hover_text(
                        "收录 = 该账户收哪些产品类;只改这一行,不影响其它账户。\n\
                         想立即生效:点该行『断开』再『连接』。",
                    );
                });
                // ── 状态·连接(装配态 + 登录读数;告警带柜台短因)。
                match a.stage {
                    crate::state::AccountStage::AssembleFailed => {
                        ui.add(
                            egui::Label::new(
                                egui::RichText::new(format!(
                                    "装配失败:{}",
                                    a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
                                ))
                                .color(crate::tokens::error(ui.ctx())),
                            )
                            .wrap_mode(egui::TextWrapMode::Extend),
                        );
                    }
                    _ => {
                        let color = match a.login {
                            crate::state::LoginState::Online => crate::tokens::ok(ui.ctx()),
                            crate::state::LoginState::LoginFailed
                            | crate::state::LoginState::FrontDown => {
                                crate::tokens::error(ui.ctx())
                            }
                            crate::state::LoginState::QueryStarved => {
                                crate::tokens::warn(ui.ctx())
                            }
                            _ => crate::tokens::muted(ui.ctx()),
                        };
                        let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
                            (true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
                            _ => a.status_label(),
                        };
                        ui.add(
                            egui::Label::new(egui::RichText::new(text).color(color))
                                .wrap_mode(egui::TextWrapMode::Extend),
                        );
                    }
                }
                // ── 状态·阶段(网关推送的链状态;只显形不参与判据)。
                ui.add(
                    egui::Label::new(
                        egui::RichText::new(a.chain_note.clone().unwrap_or_else(|| "—".to_string()))
                            .color(crate::tokens::muted(ui.ctx())),
                    )
                    .wrap_mode(egui::TextWrapMode::Extend),
                );
                // ── 状态·行情源。
                ui.horizontal(|ui| {
                    if is_md {
                        ui.label(egui::RichText::new("● 行情源").strong());
                    }
                    let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
                        op_button(
                            ui,
                            if is_md { "已是行情源" } else { "设为行情源" },
                            OpTone::Ghost,
                        )
                    });
                    if resp.inner.clicked() {
                        match shared.set_md_source(&a.gateway) {
                            Ok(()) => shared.log_line(format!("[网关] 行情源 = {}", a.gateway)),
                            Err(e) => shared.log_line(format!("[网关] {e}")),
                        }
                    }
                });
                // ── 状态·异常(入向零丢弃的显形面:非零 = 有报文被折算,要看)。
                let (td_an, md_an) = (a.td_anomaly, a.md_anomaly);
                if td_an == 0 && md_an == 0 {
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new("—").color(crate::tokens::muted(ui.ctx())),
                        )
                        .wrap_mode(egui::TextWrapMode::Extend),
                    );
                } else {
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new(format!("td {td_an} / md {md_an}"))
                                .color(crate::tokens::warn(ui.ctx())),
                        )
                        .wrap_mode(egui::TextWrapMode::Extend),
                    )
                    .on_hover_text(
                        "入向异常计数:柜台报文里认不出的交易所码/状态码/时间戳/方向/缺委托号的成交…\n\
                         网关不丢包,一律折算成可读值并在这里记数(ek-ctp 0.12.0 口径)。",
                    );
                }
                // ── 操作(光杆钮;行即对象)。
                ui.horizontal(|ui| {
                    let failed = a.stage == crate::state::AccountStage::AssembleFailed;
                    let idle = a.stage == crate::state::AccountStage::NotAssembled;
                    let stopped = a.login == crate::state::LoginState::Stopped;
                    // 0.9.37(用户实测「登录上去也没有了」):配置相点「连接/断开/移除」
                    // 只是入列 —— 消费这些指令的 ui-cmd 线程要等 ui 插件起跑才存在。
                    // 旧样子 = 点了没反应。现在置灰 + 悬停说清楚,别让人以为按钮坏了。
                    let setup = shared.in_setup();
                    if setup {
                        // 配置相不发指令:消费 `UiCmd` 的 ui-cmd 线程只在起跑后存在,
                        // 画一颗点了没反应的钮 = 骗人。这里只留一句说明(0.9.37)。
                        ui.add(
                            egui::Label::new(
                                egui::RichText::new("启动运行后可连")
                                    .color(crate::tokens::muted(ui.ctx())),
                            )
                            .wrap_mode(egui::TextWrapMode::Extend),
                        )
                        .on_hover_text("配置相不发指令:点上方「启动运行」,起来后这行才有「连接」");
                    }
                    if !setup && (failed || idle || stopped) {
                        let label = if failed { "重试" } else { "连接" };
                        if op_button(ui, label, OpTone::Primary).clicked() {
                            if a.restored {
                                if stopped {
                                    shared.push_cmd(UiCmd::Remove {
                                        name: a.gateway.clone(),
                                    });
                                    shared.push_cmd(UiCmd::Start {
                                        factory: "ctp".to_string(),
                                        name: a.gateway.clone(),
                                    });
                                    shared.log_line(format!(
                                        "[网关] {gw} 重试:摘重起(存档直连;活跃委托时摘被拒会显形)",
                                        gw = a.gateway
                                    ));
                                } else {
                                    shared.push_cmd(UiCmd::Start {
                                        factory: "ctp".to_string(),
                                        name: a.gateway.clone(),
                                    });
                                    shared.log_line(format!(
                                        "[网关] {gw}:已按存档起连(svctl.start;密码走密库,不回显;参数以存档段为准 —— 改地址请『编辑』→『保存并连接』)",
                                        gw = a.gateway
                                    ));
                                }
                            } else {
                                push_connect(shared, &a.gateway, &a.form);
                            }
                        }
                    }
                    let open = snap
                        .orders
                        .iter()
                        .filter(|o| o.gateway_name == a.gateway && o.is_active())
                        .count();
                    if !setup
                        && a.stage == crate::state::AccountStage::Assembled
                        && a.login != crate::state::LoginState::Stopped
                        && open == 0
                        && op_button(ui, "断开", OpTone::Ghost).clicked()
                    {
                        shared.push_cmd(UiCmd::Disconnect {
                            gateway: a.gateway.clone(),
                        });
                        shared.log_line(format!(
                            "[网关] 断开 {gw}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
                            gw = a.gateway
                        ));
                    }
                    let mut auto_on = a.auto_connect;
                    let auto_resp = ui.checkbox(&mut auto_on, "自动连");
                    if auto_resp.changed() {
                        match shared.set_auto_connect(&a.gateway, auto_on) {
                            Ok(()) => shared.log_line(format!(
                                "[网关] {} 自动连接 = {}(落盘请求已置:手点才写 ui 段)",
                                a.gateway,
                                if auto_on { "开" } else { "关" }
                            )),
                            Err(e) => shared.log_line(format!("[网关] {e}")),
                        }
                    }
                    if op_button(ui, "编辑", OpTone::Ghost).clicked() {
                        shared.begin_account_edit(Some(&a.gateway));
                    }
                    if open > 0 {
                        if op_button(ui, &format!("先撤 {open} 笔"), OpTone::Danger).clicked() {
                            for o in snap
                                .orders
                                .iter()
                                .filter(|o| o.gateway_name == a.gateway && o.is_active())
                            {
                                shared.push_cmd(UiCmd::CancelOrder {
                                    orderid: o.orderid.clone(),
                                    gateway: a.gateway.clone(),
                                });
                            }
                            shared.log_line(format!(
                                "[网关] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
                                a.gateway, open
                            ));
                        }
                    } else {
                        let armed = shared.pending_confirm();
                        let armed_here = armed.as_deref() == Some(a.gateway.as_str());
                        let label = if armed_here { "确认移除?" } else { "移除" };
                        let tone = if armed_here { OpTone::Confirm } else { OpTone::Danger };
                        if !setup
                            && op_button(ui, label, tone).clicked()
                            && shared.confirm_step(&a.gateway) == ConfirmStep::Fired
                        {
                            shared.push_cmd(UiCmd::Remove {
                                name: a.gateway.clone(),
                            });
                            shared.remove_account(&a.gateway);
                            shared.end_account_edit_if_editing(&a.gateway);
                            let has_pos = snap.positions.iter().any(|p| {
                                p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0
                            });
                            if has_pos {
                                shared.log_line(format!(
                                    "[网关] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
                                    a.gateway
                                ));
                            }
                        }
                    }
                });
                rows.push(ui.min_rect().max.y);
                ui.end_row();
            }
        });
    let band = header_band(ui, &heads, ui.min_rect().max.x);
    grid_chrome(ui, band, &heads, &rows);

    // 按行保存回执(窗底一行;成功/失败都显形,不静默)。
    if let Some(msg) = &st.toast {
        let color = if msg.contains("失败") {
            crate::tokens::error(ui.ctx())
        } else {
            crate::tokens::ok(ui.ctx())
        };
        ui.label(egui::RichText::new(msg.as_str()).color(color));
    }

    // 二次确认提示(唯一保留的表下句)。
    if let Some(gw) = shared.pending_confirm() {
        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
        ui.horizontal(|ui| {
            ui.label(
                egui::RichText::new(format!("移除确认中:再点该行的『确认移除』完成({shown})"))
                    .color(crate::tokens::warn(ui.ctx())),
            );
            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
                shared.confirm_cancel();
            }
        });
    }

    // 元账号 md 前置为空 = 行情永不出数(业务告警,只在真为空时出现一行)。
    if let Some(src) = accounts.iter().find(|a| a.gateway == md)
        && src.form.md_addr.trim().is_empty()
    {
        ui.label(
            egui::RichText::new(format!(
                "元账号 {} 的 md 前置为空:点该行『编辑』补填 md 前置,否则行情不会出数",
                src.gateway
            ))
            .color(crate::tokens::warn(ui.ctx())),
        );
    }
}

/// 勾选 → 产品类码(`1` 期货 / `2` 期权;都不勾 = 空 = 回落默认期货)。
fn pick_codes(futures: bool, options: bool) -> Vec<String> {
    let mut v = Vec::new();
    if futures {
        v.push("1".to_string());
    }
    if options {
        v.push("2".to_string());
    }
    v
}

/// **按行原子**写收录:读该行段 → 改 `product_classes` → 整段写回(只碰该 `ns`)。
///
/// 不走"保存全部":一个窗十行账户 = 十个独立段,没有跨段事务;改 A 行绝不触碰 B 行。
/// `host` 由调用方给(窗体用 [`crate::mount::CfgBridge::new`];判据可钉存储根)。
pub fn save_product_classes(
    shared: &UiShared,
    host: &dyn CfgHost,
    gateway: &str,
    codes: &[String],
) -> Result<(), String> {
    let ns = format!("ctp.{gateway}");
    let mut map = match host.cfg_load(&ns)? {
        Some(m) => m,
        None => SectionMap::new(1),
    };
    map = map.set("product_classes", Field::plain(codes.join(",")));
    host.cfg_save(&ns, &map)?;
    shared.log_line(format!(
        "[网关] {gateway} 收录已存:{}(只写 ctp.{gateway} 段;重连后生效)",
        classes_label(codes)
    ));
    Ok(())
}