1use 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#[derive(Default)]
26pub struct TableState {
27 pub toast: Option<String>,
29}
30
31impl TableState {
32 #[must_use]
34 pub fn new() -> Self {
35 Self::default()
36 }
37}
38
39pub 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 let host = crate::mount::CfgBridge::new(shared);
47
48 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 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 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 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 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 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 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 ui.label(if a.alias.trim().is_empty() {
165 "—".to_string()
166 } else {
167 a.alias.clone()
168 });
169 ui.label(a.account.clone());
171 ui.label(if a.broker_id.trim().is_empty() {
173 "—".to_string()
174 } else {
175 a.broker_id.clone()
176 });
177 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 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 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 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 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 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 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 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 let setup = shared.in_setup();
348 if setup {
349 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 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 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 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
513fn 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
525pub 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}