Skip to main content

ek_ui/
state.rs

1//! 共享观测状态:`UiPlugin`(总线面)写、`render_body`(窗口面)读。
2//!
3//! 线程形貌:总线侧在**插件 worker 线程**写;窗口侧在 **eframe 主线程**读;
4//! 锁粒度 = 每次 `lock().clone()/snapshot()`,不跨帧持锁。
5
6use std::collections::{BTreeMap, HashSet, VecDeque};
7use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex};
9
10use ek_data::bar::BarData;
11use ek_dm::wire::BarsFrame;
12use ek_station::wire::PlugRow;
13
14/// 日志环形容量(S1b 门面;溢出不丢累计计数,见 [`LogRing::spilled`])。
15pub const RING_CAP: usize = 512;
16
17/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
18/// 0.2.27 起从 render 侧提到 state(原 `render.rs` 那份随之删除):日志时间戳
19/// 在入环时就要打,而打戳与表格时间列(`fmt_dt`)必须是同一个口径,
20/// 不能一边 +08 一边本机时区。
21pub(crate) fn beijing_tz() -> chrono::FixedOffset {
22    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
23}
24
25/// 一行日志(BD-UI-41:Python 侧 LogMonitor 是带「时间/信息/接口」表头的表格
26/// (`widget.py:1167-1180`),我们原来只存正文串、渲染侧没有时间可排)。
27/// 时间戳在入环那一刻打,北京墙钟 `%H:%M:%S`(与 `fmt_dt` 同口径)。
28#[derive(Debug, Clone)]
29pub struct LogEntry {
30    /// 北京墙钟 `HH:MM:SS`。
31    pub at: String,
32    /// 正文。
33    pub text: String,
34}
35
36/// 环形日志:push 满则挤掉最旧一条,溢出计数单调(页面显示"已丢弃 N 条")。
37#[derive(Debug)]
38pub struct LogRing {
39    lines: VecDeque<LogEntry>,
40    cap: usize,
41    spilled: u64,
42}
43
44impl Default for LogRing {
45    fn default() -> Self {
46        Self::new(RING_CAP)
47    }
48}
49
50impl LogRing {
51    /// 定容环形(测试可传小容量逼溢出)。
52    pub fn new(cap: usize) -> Self {
53        Self {
54            lines: VecDeque::with_capacity(cap),
55            cap,
56            spilled: 0,
57        }
58    }
59
60    /// 追加一行;满则挤旧 + spilled+1。
61    pub fn push(&mut self, line: impl Into<String>) {
62        if self.cap == 0 {
63            self.spilled += 1;
64            return;
65        }
66        if self.lines.len() == self.cap {
67            self.lines.pop_front();
68            self.spilled += 1;
69        }
70        let at = chrono::Utc::now()
71            .with_timezone(&beijing_tz())
72            .format("%H:%M:%S")
73            .to_string();
74        self.lines.push_back(LogEntry {
75            at,
76            text: line.into(),
77        });
78    }
79
80    /// 当前长度。
81    pub fn len(&self) -> usize {
82        self.lines.len()
83    }
84
85    /// 是否空行。
86    pub fn is_empty(&self) -> bool {
87        self.lines.is_empty()
88    }
89
90    /// 累计已丢弃条数(只有溢出才涨)。
91    pub fn spilled(&self) -> u64 {
92        self.spilled
93    }
94
95    /// 只读快照(正文;测试与巡回取证用它,不关心时间)。
96    pub fn snapshot(&self) -> Vec<String> {
97        self.lines.iter().map(|e| e.text.clone()).collect()
98    }
99
100    /// 带时间戳的快照(渲染侧表格用:时间列 + 正文列)。
101    pub fn entries(&self) -> Vec<LogEntry> {
102        self.lines.iter().cloned().collect()
103    }
104
105    /// 全清;spilled 保留(历史溢出事实不抹)。
106    pub fn clear(&mut self) {
107        self.lines.clear();
108    }
109}
110
111/// 一次 eTimer 拉取的服务/插件快照(渲染直接可用)。
112#[derive(Debug, Clone, Default)]
113pub struct BusSnap {
114    /// svctl.list 全行(svctl 独裁班;状态原文)。
115    pub rows: Vec<PlugRow>,
116    /// 逐插件服务目录(id 清单;无服务为空)。
117    pub services: Vec<(String, Vec<String>)>,
118}
119
120/// UI 操作指令(窗口面 → 总线面;eTimer 节拍被 ui 插件请辞)。
121#[derive(Debug, Clone, PartialEq)]
122pub enum UiCmd {
123    /// svctl.start(工厂名 + 实例注册名;`name` 空 = 旧行为,工厂名即注册名)。
124    /// 多账号面:`factory="ctp"` + `name=ctp_<broker>_<账号>` 建第 N 个网关实例。
125    Start { factory: String, name: String },
126    /// svctl.remove(插件名)。
127    Remove { name: String },
128    /// dm.reload 回灌(S4;push=true 逐条广播,行情面板实时见 Rows)。
129    Reload {
130        interval: i64,
131        symbol: String,
132        bars: Vec<BarData>,
133    },
134    /// 下单(S5;网关受理即 SUBMITTING 单上总线)。
135    /// S6 起网关可选(`gateway.send_order`,空 = ctp)。
136    /// 0.1.1(BD-UI-30):改走 `oms.submit_order` 五段管线(拆单/风控/逐腿
137    /// 发送/记账,parent 聚合);`mode` = 拆单四态(勾选「自动开平」= Auto,
138    /// 取消勾选 = None 原样旁路)。
139    SendOrder {
140        req: ek_data::order_request::OrderRequest,
141        gateway: String,
142        mode: ek_oms::LockMode,
143    },
144    /// 撤单(S5;携三段号所需的 orderid;S6 起网关同选)。
145    /// 0.1.1(BD-UI-30):改走 `oms.cancel_order`(守卫 4000 不可绕过;
146    /// symbol/exchange 由 oms 订单缓存解,不再随令携带)。
147    CancelOrder { orderid: String, gateway: String },
148    /// `{gw}.configure` 账户配置(多账号面:网关名随令携带,不再硬编码 `ctp`;
149    /// 出队时 `to_configure_req` 抬成线上契约)。
150    /// 0.2.18 起界面侧不再用它装配账户(装配走 `Connect`);保留作低层口与测试面。
151    CtpConfigure { gateway: String, form: CtpForm },
152    /// **四态分离的「连接」(0.2.18,规划裁定:添加 ≠ 连接)**:
153    /// 一次做完 `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
154    /// (`Assembling → Assembled / AssembleFailed`)。界面『连接』『重试』钮走这一条,
155    /// 『添加账户』只写清册(不再一键装配 → 失败不会留下"注定被拒"的半死行)。
156    Connect { gateway: String, form: CtpForm },
157    /// `{gw}.subscribe` 订阅行情(多账号面:一律路由到**行情元账号**,
158    /// 从账号没有 MD 会话,发给它只会静默成功 —— 见规划 §5.C / §9.4-21)。
159    CtpSubscribe {
160        gateway: String,
161        symbols: Vec<String>,
162    },
163    /// **`{gw}.disconnect` 停会话(0.2.19,规划裁定 1:断开 ≠ 移除)**:
164    /// 网关实例、账号配置、订阅集、状态计数全保留,『连接/重试』可恢复(重登自动重订阅)。
165    /// 『移除』走的 `UiCmd::Remove`(svctl.remove)才是停实例 —— 两个动作不得混成一个。
166    Disconnect { gateway: String },
167    /// 配置托底:段目录刷新(`cfg.list`;0.6.0 环节8,BD-CFG-17 零值面)。
168    CfgList,
169    /// 配置托底:清段(`cfg.delete`,`ns` + 空整段语义;运维窗操作,与文件回缺省)
170    CfgDelete { ns: String },
171    /// 配置托底:重存段(`cfg.save`;从明面重读 → serialize → store,
172    /// **值不变** —— 修 rev/格式,持久挂久节奏验证面;0.6.0 环节8)。
173    CfgResave { ns: String },
174}
175
176/// 行情缓存单格容量(防涨;丢头保尾,bar 流单调)。
177pub const KLINE_CELL_CAP: usize = 4096;
178
179/// 账户软上限(0.2.18 落地既有开放项:达上限『添加账户』入口禁用并提示,
180/// 界面侧不再无限长行)。
181pub const MAX_ACCOUNTS: usize = 8;
182
183/// 「查询断供」判据之一:资金/持仓查询**连续**被拒达此数(规划 Q10)。
184///
185/// 阈值住在界面,不住在网关:ek-ctp 只给 `td_query_reject_streak` 这个原料,
186/// 「连续几次算断供」是界面口径(它才知道自己多久刷一次)。
187pub const QUERY_REJECT_STREAK_LIMIT: u32 = 3;
188
189/// 「查询断供」判据之二:距最后一次**资金回报**超过这么多秒(规划 Q10)。
190///
191/// 阈值对照的是 ek-ctp 0.9.8 的轮询密度:1 秒闸 + 秒针抖动 → 资金与持仓每路 2~4 秒一次,
192/// 30 秒 = 最坏也有 7 次机会没见到资金,足够避开「刚登录还没轮到」与「合约阶段内本来不问
193/// 资金」的正常空窗,又不至于把 F2 那种断供拖很久才显形。
194pub const ACCOUNT_STALE_SECS: i64 = 30;
195
196/// 链状态行的前缀(0.5.0,用户定「链进行的状态改为事件日志推送」)—— **单源:引用网关那一份**。
197///
198/// 前缀的唯一出处是 ek-ctp 0.9.8 的 `ek_ctp::driver::CHAIN_STATE_PREFIX`(驱动落每一条 `[链] `
199/// 状态行、插件落会话复位行,用的都是它)。本 crate 0.5.0 抬 pin 到 0.9.8,就不再自己抄一份 ——
200/// 抄一份 = 网关改前缀而界面静默不认。测试钉 `u4i` 读的是这个再导出。
201pub use ek_ctp::driver::CHAIN_STATE_PREFIX;
202
203/// 管制面快照(`svctl.list`)轮询节拍(0.5 M3:事件驱动后唯一保留的拍级轮询)。
204///
205/// 4 拍 ≈ 4 秒(第一拍必问,界面刚起来不该空着):这一口给的是「谁在场 + 服务目录 +
206/// 邮箱溢出计数」,不是交易数据。0.4 之前它每拍一次,是因为交易四表搭在同一拍上;
207/// 四表改吃推送后,它降到自己那一档。
208pub const LIST_POLL_TICKS: u32 = 4;
209
210/// `{gw}.status` 每拍最多问几个账户(0.5 M3:状态轮询摊平)。
211///
212/// 摊平而不是降拍:8 个账户同拍全问,单拍上限就是 8 × 2s;每拍只问 2 个,单拍上限
213/// 就是 4s,而 8 个账户 4 拍走完一轮 —— 与快照节拍同档,且不随账户数线性长。
214pub const STATUS_ASKS_PER_TICK: usize = 2;
215
216/// 成交账本上限(0.5 偏离基线③,用户裁定接)。
217///
218/// 基线的成交表只插不删、无上限(`widget.py:1242/1267` 的 `data_key = ""`);长跑一天
219/// CTP 成交可上万行,而渲染每帧要把它们喂进表。触顶丢最旧,并在第一次触顶时弹一次告警。
220pub const TRADE_CAP: usize = 5000;
221
222/// 某个插件的邮箱容量(0.5 M6:溢出判据必须吃**那一行自己的**容量)。
223///
224/// 链上只有 ui 覆写了 `mailbox_cap`(10 万格),其余插件吃 ek-core 缺省 1024。
225/// `PlugRow` 不带容量字段(ek-station `wire.rs:30-43`),所以这张表是本 crate 侧
226/// 的唯一事实源:将来谁再覆写 `mailbox_cap`,这里不登记就会把容量说错。
227#[must_use]
228pub fn mailbox_cap_of(name: &str) -> usize {
229    if name == crate::plugin::UI_NAME {
230        crate::plugin::UI_MAILBOX_CAP
231    } else {
232        ek_core::plugin::MAILBOX_CAP
233    }
234}
235
236/// 二次确认步进:Armed = 首点待确认;Fired = 同名二点已发令;Switched = 目标迁移。
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum ConfirmStep {
239    Armed,
240    Fired,
241    Switched,
242}
243
244/// 隔离态判定(svctl.list 状态原文含 Quarantin → 红;ePlugQDown.<名> 是定向
245/// 广播,未知名无法预订阅 —— 快照行扫描是唯一闭环面,BD-UI-17)。
246#[must_use]
247pub fn is_quarantined(state: &str) -> bool {
248    state.contains("Quarantin")
249}
250
251// ================= S4:行情库(eBar./eBarUpdate. 上屏地面) =================
252
253/// 单格 = (symbol, interval) → bar 升序序列(丢头保尾;datetime 单调)。
254#[derive(Debug, Default)]
255pub struct MarketStore {
256    cells: Mutex<BTreeMap<(String, i64), Vec<BarData>>>,
257    /// eBar. 帧接收计数(帧统计,刷新不丢帧观感面)。
258    frames: AtomicU64,
259    /// eBarUpdate. 接收计数。
260    updates: AtomicU64,
261    /// dm.reload 成功计数(回灌确认面)。
262    reloads: AtomicU64,
263}
264
265/// 一格的可读视图(渲染/测试用)。
266#[derive(Debug, Clone, PartialEq)]
267pub struct CellView {
268    pub symbol: String,
269    pub interval: i64,
270    pub bars: Vec<BarData>,
271}
272
273impl MarketStore {
274    /// eBar. 收帧:逐 bar 并格(同 dt 替尾;更旧丢弃;更晚追加,溢出丢头)。
275    pub fn on_frame(&self, frame: &BarsFrame) {
276        let mut cells = self.cells.lock().unwrap();
277        for b in &frame.bars {
278            let key = (b.symbol.clone(), frame.interval);
279            let cell = cells.entry(key).or_default();
280            match cell.last() {
281                Some(last) if b.datetime < last.datetime => continue,
282                Some(last) if b.datetime == last.datetime => {
283                    let slot = cell.last_mut().expect("len>1");
284                    *slot = b.clone();
285                }
286                _ => {
287                    cell.push(b.clone());
288                    if cell.len() > KLINE_CELL_CAP {
289                        cell.remove(0);
290                    }
291                }
292            }
293        }
294        self.frames.fetch_add(1, Ordering::AcqRel);
295    }
296
297    /// eBarUpdate. 收单 bar 快照(实时门;同 dt 替尾,新 dt 追加 = in-flight bar)。
298    pub fn on_update(&self, b: &BarData) {
299        let mut cells = self.cells.lock().unwrap();
300        let iv = b
301            .interval
302            .unwrap_or(ek_data::constant::Interval::MINUTE)
303            .seconds();
304        let key = (b.symbol.clone(), iv);
305        let cell = cells.entry(key).or_default();
306        match cell.last() {
307            Some(last) if b.datetime == last.datetime => {
308                let slot = cell.last_mut().expect("len>1");
309                *slot = b.clone();
310            }
311            Some(last) if b.datetime < last.datetime => {}
312            _ => {
313                cell.push(b.clone());
314                if cell.len() > KLINE_CELL_CAP {
315                    cell.remove(0);
316                }
317            }
318        }
319        self.updates.fetch_add(1, Ordering::AcqRel);
320    }
321
322    /// dm.reload 回灌登记(RELOAD OK 计数;dm 侧行为独立)。
323    pub fn note_reload(&self) {
324        self.reloads.fetch_add(1, Ordering::AcqRel);
325    }
326
327    /// 全格只读快照(键序 = BTreeMap 序,symbol/interval 稳定)。
328    pub fn cells(&self) -> Vec<CellView> {
329        self.cells
330            .lock()
331            .unwrap()
332            .iter()
333            .map(|((sym, iv), bars)| CellView {
334                symbol: sym.clone(),
335                interval: *iv,
336                bars: bars.clone(),
337            })
338            .collect()
339    }
340
341    /// 计算(Ai) 帧统计。
342    pub fn counts(&self) -> (u64, u64, u64) {
343        (
344            self.frames.load(Ordering::Acquire),
345            self.updates.load(Ordering::Acquire),
346            self.reloads.load(Ordering::Acquire),
347        )
348    }
349}
350
351/// 回灌表单(v4:界面面已退役,总线口 `UiCmd::Reload` 保留;解析器留作
352/// 测试与脚本面)。
353#[derive(Debug, Default, Clone, PartialEq)]
354pub struct ReloadForm {
355    pub interval: String,
356    pub symbol: String,
357    pub csv: String,
358}
359
360/// 票据网关选择子解析(多账号面)。
361///
362/// 0.2.15 起**不再兜底成字面量 `"ctp"`**:多账号下"空网关"必须显式落到
363/// `fallback`(= [`UiShared::default_gateway`] 的阶梯:行级带入 → 上次选择 →
364/// 第一个已连接账户);一个账户都没有时回 `Err`,界面据此禁用下单钮。
365/// 单账号旧行为仍成立(fallback 就是那个账户的派生名)。
366pub fn parse_gateway(raw: &str, fallback: Option<&str>) -> Result<String, String> {
367    let g = raw.trim();
368    if !g.is_empty() {
369        if g.chars().any(|c| c.is_whitespace()) {
370            return Err(format!("网关解析: 含空白:{raw:?}"));
371        }
372        return Ok(g.to_string());
373    }
374    match fallback.map(|f| f.trim()).filter(|f| !f.is_empty()) {
375        Some(f) => Ok(f.to_string()),
376        None => Err("网关解析: 未选择账户(先在账户面板添加账户)".to_string()),
377    }
378}
379
380/// CTP 网关配置表单(S6 测试面;界面手工输入源 → UiCmd::CtpConfigure →
381/// ask `ctp.configure`。零配置口径:界面不读文件,值全部人敲;密码位掩码)。
382#[derive(Debug, Clone, PartialEq)]
383pub struct CtpForm {
384    pub broker_id: String,
385    pub td_addr: String,
386    pub td_user: String,
387    pub td_password: String,
388    pub td_auth_code: String,
389    pub td_app_id: String,
390    pub md_addr: String,
391    pub md_user: String,
392    pub md_password: String,
393    /// 订阅标的(逗号分隔;`订阅行情` 钮解析)。
394    pub symbols: String,
395    /// 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。
396    /// 缺省只勾期货 —— 期权默认不勾,因为默认口径与改动前一致(只期货上抛)。
397    pub accept_futures: bool,
398    pub accept_options: bool,
399}
400
401/// 手写 `Default`(不能 derive:`accept_futures` 必须为 `true`,derive 会给 `false`,
402/// 而两个都 false 在语义上是「什么都不收录」)。
403impl Default for CtpForm {
404    fn default() -> Self {
405        Self {
406            broker_id: String::new(),
407            td_addr: String::new(),
408            td_user: String::new(),
409            td_password: String::new(),
410            td_auth_code: String::new(),
411            td_app_id: String::new(),
412            md_addr: String::new(),
413            md_user: String::new(),
414            md_password: String::new(),
415            symbols: String::new(),
416            accept_futures: true,
417            accept_options: false,
418        }
419    }
420}
421
422impl CtpForm {
423    /// 勾选 → CTP 产品类码表(期货 `'1'`、期权 `'2'`)。
424    /// 两个都不勾交空表给插件:插件侧归一化一律回落默认期货 —— **空不等于全丢**
425    /// (共享合约表为空会让网关的持仓查询整条被跳过)。
426    #[must_use]
427    pub fn product_classes(&self) -> Vec<String> {
428        let mut codes = Vec::new();
429        if self.accept_futures {
430            codes.push("1".to_string());
431        }
432        if self.accept_options {
433            codes.push("2".to_string());
434        }
435        codes
436    }
437
438    /// 表单 → 线上契约(字段直映;md 回落由插件侧裁决,表单不预处理)。
439    pub fn to_configure_req(&self) -> ek_ctp::plugin::ConfigureReq {
440        ek_ctp::plugin::ConfigureReq {
441            broker_id: self.broker_id.trim().to_string(),
442            td_addr: self.td_addr.trim().to_string(),
443            td_user: self.td_user.trim().to_string(),
444            td_password: self.td_password.clone(),
445            td_auth_code: self.td_auth_code.trim().to_string(),
446            td_app_id: self.td_app_id.trim().to_string(),
447            md_addr: self.md_addr.trim().to_string(),
448            md_user: self.md_user.trim().to_string(),
449            md_password: self.md_password.clone(),
450            product_classes: self.product_classes(),
451        }
452    }
453
454    /// 订阅位解析(逗号/空白分隔;空表 = Err)。
455    pub fn parse_symbols(&self) -> Result<Vec<String>, String> {
456        let list: Vec<String> = self
457            .symbols
458            .split([',', ',', ' '])
459            .map(|s| s.trim().to_string())
460            .filter(|s| !s.is_empty())
461            .collect();
462        if list.is_empty() {
463            return Err("订阅解析: 标的位空".to_string());
464        }
465        Ok(list)
466    }
467
468    /// 添加/连接前的形制校验(规划 U7:旧口径是"前置为空照样建账户并下发两令")。
469    ///
470    /// 判据:`tcp://host:port` 形制(host 非空、端口为 1..=65535 的十进制)、
471    /// broker/账号非空、AppID/AuthCode 非空(CTP 登录必需,空值只会换来一次注定失败的登录)。
472    /// **md 前置允许为空** = 纯 TD 账户(单源纪律:非行情源不建 MD 会话)。
473    ///
474    /// 返回 `Err(第一条原因)` —— 界面红字呈现,不当『添加』按钮的硬拦截前置条件之外的东西
475    /// (校验只在『添加』『连接』两条入口上跑,低层口 `add_account` 不重复校验)。
476    pub fn validate(&self) -> Result<(), String> {
477        fn front(name: &str, v: &str) -> Result<(), String> {
478            let v = v.trim();
479            let Some(rest) = v.strip_prefix("tcp://") else {
480                return Err(format!("{name} 形制应为 tcp://host:port(当前 {:?})", v));
481            };
482            let Some((host, port)) = rest.rsplit_once(':') else {
483                return Err(format!("{name} 缺端口:{name} {:?}", v));
484            };
485            if host.trim().is_empty() {
486                return Err(format!("{name} 主机位空:{}", v));
487            }
488            let port: u32 = port
489                .parse()
490                .map_err(|_| format!("{name} 端口不是数字:{}", v))?;
491            if port == 0 || port > 65535 {
492                return Err(format!("{name} 端口越界(1..=65535):{}", v));
493            }
494            Ok(())
495        }
496        if self.broker_id.trim().is_empty() {
497            return Err("broker 位空".to_string());
498        }
499        if self.td_user.trim().is_empty() {
500            return Err("交易账号位空".to_string());
501        }
502        front("td 前置", &self.td_addr)?;
503        if !self.md_addr.trim().is_empty() {
504            front("md 前置", &self.md_addr)?;
505        }
506        if self.td_app_id.trim().is_empty() {
507            return Err("AppID 位空(CTP 登录必需)".to_string());
508        }
509        if self.td_auth_code.trim().is_empty() {
510            return Err("AuthCode 位空(CTP 登录必需)".to_string());
511        }
512        Ok(())
513    }
514}
515
516/// 账户装配四态(0.2.18,规划 §3.3「添加 ≠ 连接 ≠ 断开 ≠ 移除」)。
517///
518/// 口径边界(诚实):这一层只说**实例装配**,不说登录 —— 登录成败/掉线 ek-ctp 无状态口
519/// 可查(开放项 `ctp.status`,规划裁定 3:状态机等终态口,不做 eLog 自建过渡)。
520/// 因此 `Assembled` 的界面文案必须是「已装配·登录态未知」(短形:状态列宽有限,
521/// 长文案会被 Grid 挤成竖排 —— s39 快照实锤过),不得写成「已连接」。
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
523pub enum AccountStage {
524    /// 未装配:添加后未连接、实例已摘、或装配失败后未重试。
525    #[default]
526    NotAssembled,
527    /// 装配中:`UiCmd::Connect` 已入列,svctl 还没把这个注册名带回在场表。
528    Assembling,
529    /// 实例已装配(登录态未知)。
530    Assembled,
531    /// 装配失败:`svctl.start` 被拒/超时,或 `{gw}.configure` 被拒。可重试。
532    AssembleFailed,
533}
534
535impl AccountStage {
536    /// 界面文案(状态列 / 徽标)。
537    #[must_use]
538    pub fn label(self) -> &'static str {
539        match self {
540            AccountStage::NotAssembled => "未装配",
541            AccountStage::Assembling => "装配中…",
542            // 规划 §3.5 缓解:不让「已装配」被读成「在线」。
543            // (文案长度有实测约束:账户面板是 Grid,状态列文案过长会被挤成竖排换行。)
544            AccountStage::Assembled => "已装配·登录态未知",
545            AccountStage::AssembleFailed => "装配失败",
546        }
547    }
548}
549
550/// 登录态(0.2.19;事实源 = ek-ctp 0.9.0 `{gw}.status`,规划 P3 / 裁定 3)。
551///
552/// 与 [`AccountStage`] 是**两件事**:stage 说「实例在不在」,这一档说「会话活着没有」。
553/// 状态列把两者拼起来(`AccountRow::status_label`)。
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
555pub enum LoginState {
556    /// 还没读到读数,或读到了但没有任何登录证据。
557    ///
558    /// 网关的「句柄已建」(**`td_handle_up`**)**不等于在线** —— 掉线时它仍是真
559    /// (ek-ctp DESIGN D-STATUS ①),所以没有 `logged_in` 就只有这一档,不得显示「在线」。
560    #[default]
561    Unknown,
562    /// 网关回报 `td_logged_in`(纯行情账户看 `md_logged_in`)—— 在线的唯一硬证据。
563    Online,
564    /// 最近一次登录失败(网关在成功后会清零这一位,所以它显形 = 现在就是登不上)。
565    LoginFailed,
566    /// 柜台断过线且当前未登录(`td_disconnects > 0`;计数跨重连保留)。
567    FrontDown,
568    /// 登录着但查询连续被拒 / 资金回报超时(规划 Q10 两判据,阈值在界面)。
569    QueryStarved,
570    /// 用户主动『断开』(规划裁定 1):会话已停,实例与订阅还在,『连接』可恢复。
571    Stopped,
572}
573
574impl LoginState {
575    /// 状态列短文案(账户面板是 Grid,长文案会被挤成竖排 —— s39 快照实锤过)。
576    #[must_use]
577    pub fn label(self) -> &'static str {
578        match self {
579            LoginState::Unknown => "登录态未知",
580            LoginState::Online => "在线",
581            LoginState::LoginFailed => "登录失败",
582            LoginState::FrontDown => "掉线",
583            LoginState::QueryStarved => "查询断供",
584            LoginState::Stopped => "已断开",
585        }
586    }
587
588    /// 这一档要不要在状态列上着色(红/橙)。
589    #[must_use]
590    pub fn is_alarm(self) -> bool {
591        matches!(self, LoginState::LoginFailed | LoginState::FrontDown)
592    }
593}
594
595/// 「掉线 N 次」这类细节:N = 0 回 `None` —— 没有发生过就不该占状态列的位置。
596fn count_note(label: &str, n: u32) -> Option<String> {
597    (n > 0).then(|| format!("{label} {n} 次"))
598}
599
600/// 柜台错误原句 → **短原因**(账户行状态列用)。原句形状照 ek-ctp `map::error_text`
601/// (`map.rs:332-333`)= `{消息},代码:{id},信息:{正文}`。
602///
603/// 取「信息」的正文(去掉 CTP 侧的 `CTP:` 前缀)并把代码括在后面 = `不合法的登录(3)`。
604/// 形状对不上(网关没回带原因、或不是这条格式)就回 `None` —— 状态列宁可只写「登录失败」,
605/// 也不编一个原因出来。
606fn brief_counter_reason(full: &str) -> Option<String> {
607    let tail = full.rfind("信息:").map(|i| &full[i + "信息:".len()..])?;
608    let body = tail.trim().trim_start_matches("CTP:").trim();
609    if body.is_empty() {
610        return None;
611    }
612    let code = full
613        .rfind("代码:")
614        .map(|i| {
615            let rest = &full[i + "代码:".len()..];
616            let end = rest.find(',').unwrap_or(rest.len());
617            rest[..end].trim().to_string()
618        })
619        .filter(|c| !c.is_empty() && c.parse::<i64>().is_ok());
620    Some(match code {
621        Some(c) => format!("{body}({c})"),
622        None => body.to_string(),
623    })
624}
625
626/// 账户行(多账号面;规划 §5.B + 0.2.18 四态)。
627///
628/// 界面是账户清单的**唯一事实源**(不新增"账户注册表"插件):
629/// 网关实例的在场由 `svctl.list` 回填,身份与凭据留在这一行,**每账户一份 form**
630/// (旧「全界面共享一张表单」已退役 → 消 U4 凭据串味 / U6 取消编辑不清空)。
631#[derive(Debug, Clone, PartialEq, Default)]
632pub struct AccountRow {
633    /// 派生注册名(`ctp_<broker>_<账号>`;**寻址唯一用这个**,别名不参与)。
634    pub gateway: String,
635    /// 用户别名(显示用,可空 = 回退账号;唯一性由 `add_account` 校验,规划 Q3)。
636    pub alias: String,
637    pub broker_id: String,
638    pub account: String,
639    /// 是否行情元账号(**全局单源**——用户裁定"单数据源";已装配后锁定,规划 D3)。
640    pub md_source: bool,
641    /// 该账户自己的表单留档(编辑弹窗回填用;密码只存内存,裁定 2:不持久化)。
642    pub form: CtpForm,
643    /// 装配四态(规划 §3.3)。
644    pub stage: AccountStage,
645    /// 装配失败原因(`AssembleFailed` 时留显形;成功即清空)。
646    pub fail_note: Option<String>,
647    /// **网关实际收录回带值**(`ConfigureReply.product_classes`,规划 U9)。
648    ///
649    /// 与 `form.accept_*`(用户勾选)并排显示:勾选是意图,这一栏是事实。
650    /// 空 = 尚未装配过或旧版网关未回带。
651    pub effective_classes: Vec<String>,
652    /// 在场位(由 `svctl.list` 快照回填:该注册名在不在 = 实例已装配)。
653    /// 0.2.19 起登录成败另有事实源(`{gw}.status` → `login`),这一位只管实例。
654    pub connected: bool,
655    /// **登录态(0.2.19)**:`{gw}.status` 读数折出来的,与 `stage`/`connected` 正交。
656    ///
657    /// 默认 `Unknown`:装配成功本身不构成任何登录证据(ek-ctp D-STATUS ①)。
658    pub login: LoginState,
659    /// 状态细节(「掉线 2 次」「查询连续被拒 3 次」「资金回报已 45s 未更新」)。
660    /// `None` = 没有可说的细节(不是「一切正常」——一切正常由 `login` 说)。
661    pub status_note: Option<String>,
662    /// **柜台原因的短标签(0.4.1 十期 BD-UI-63)**:登录失败/掉线时从 `td_last_error` 里
663    /// 抠出「代码:N」与「信息:」的正文,给账户行的状态列用(红字后面直接跟着原因)。
664    ///
665    /// 为什么不直接把整句塞进状态列:柜台原句是「交易服务器登录失败,代码:3,信息:CTP:不
666    /// 合法的登录」这种长句,Grid 状态列被它挤宽就会把别的列顶窄(s39 那口径)。整句留在
667    /// `status_note` 里,窗下方「状态细节」那一行照旧给全。
668    pub login_brief: Option<String>,
669    /// **链状态(0.5.0,用户定「链进行的状态改为事件日志推送」)**:该网关最近一条 `[链] `
670    /// 状态行的正文(去掉前缀)。
671    ///
672    /// 事实源是 ek-ctp 0.9.8 推送的日志(前置已连接 / 授权通过 / 登录成功 / 结算单已确认 /
673    /// 查询合约 第N次 / 合约已到手开始轮询 / 合约未到手本轮已停止 / 会话已复位),不是轮询读数。
674    /// `None` = 这一条会话还没推过状态行(显示「—」)。**它只是显示**:不参与 `apply_status`
675    /// 那套在线/断供判据,也不参与任何下单判据。
676    pub chain_note: Option<String>,
677}
678
679impl AccountRow {
680    /// 由表单派生账户行(派生名唯一入口 = `ek_ctp::derived_gateway_name`)。
681    ///
682    /// 返回 `Err(原因)` = broker/账号非法或派生名超宿主 32 字符上限;
683    /// 界面须红字呈现,**不得静默替换**(规划 §3)。
684    pub fn derive(form: &CtpForm) -> Result<Self, String> {
685        let broker = form.broker_id.trim();
686        let account = form.td_user.trim();
687        let gateway = ek_ctp::derived_gateway_name(broker, account).ok_or_else(|| {
688            "账户: broker/账号 不能为空且只能含字母数字(且派生名 ≤32)".to_string()
689        })?;
690        Ok(Self {
691            gateway,
692            alias: String::new(),
693            broker_id: broker.to_string(),
694            account: account.to_string(),
695            md_source: false,
696            form: form.clone(),
697            stage: AccountStage::NotAssembled,
698            fail_note: None,
699            effective_classes: Vec::new(),
700            connected: false,
701            login: LoginState::Unknown,
702            status_note: None,
703            login_brief: None,
704            chain_note: None,
705        })
706    }
707
708    /// 状态列文案(装配态 + 登录态拼一起)。
709    ///
710    /// 「已装配」后面必须跟着登录态,不能光写「已装配」——0.2.18 那条「不得读成在线」
711    /// 的口径(规划 U5)现在有了真读数才真正立住:没有证据时写「已装配·登录态未知」。
712    /// 装配段一律走 `AccountStage::label()`,不在这里重抄一遍文案(两处不同式就是坑)。
713    #[must_use]
714    pub fn status_label(&self) -> String {
715        if self.stage == AccountStage::Assembled {
716            match self.login {
717                LoginState::Unknown => "已装配·登录态未知".to_string(),
718                other => format!("已装配·{}", other.label()),
719            }
720        } else {
721            self.stage.label().to_string()
722        }
723    }
724
725    /// 把 `{gw}.status` 读数折成界面登录态 + 细节(0.2.19;规划 P3 / Q10)。
726    ///
727    /// 判据顺序写明(每条都有理由):
728    /// 1. **登录失败先判** —— 网关在登录成功后会清零 `td_login_failed`,所以它显形就是现在登不上;
729    /// 2. **登录着** → 才谈「查询断供」,三条判据:连续被拒达阈 / 距最后一次资金回报超时 /
730    ///    **登录后资金回报从未到手且已超过同一个 stale 阈值**(0.2.21,实连 S15f 逼出来的第三条);
731    ///    都没中 = 在线;
732    /// 3. **没登录** → 用户主动『断开』优先(那是自己按的钮,比柜台掉线计数更贴近用户意图),
733    ///    其次才是柜台掉线(`disconnects > 0`),最后才是「未知」;
734    /// 4. **纯行情账户**(td 前置留档为空)看 MD 半边 —— 看 TD 半边永远是「未知」,那是假信息。
735    ///
736    /// 「从未发生」= `None` 不当 0 用:网关没回报过资金时刻 ≠ 「0 秒前刚回报过」。
737    pub fn apply_status(
738        &mut self,
739        st: &ek_ctp::plugin::GatewayStatus,
740        now_ms: i64,
741        reject_limit: u32,
742        stale_secs: i64,
743    ) {
744        let td_side = !self.form.td_addr.trim().is_empty();
745        let (login, note) = if td_side {
746            if st.td_login_failed && !st.td_logged_in {
747                (
748                    LoginState::LoginFailed,
749                    st.td_last_error
750                        .clone()
751                        .or_else(|| Some("登录失败(网关未回带原因)".to_string())),
752                )
753            } else if st.td_logged_in {
754                if st.td_query_reject_streak >= reject_limit {
755                    (
756                        LoginState::QueryStarved,
757                        Some(format!("查询连续被拒 {} 次", st.td_query_reject_streak)),
758                    )
759                } else if let Some(last) = st.td_last_account_ms {
760                    let idle_ms = now_ms.saturating_sub(last);
761                    if idle_ms > stale_secs.saturating_mul(1_000) {
762                        (
763                            LoginState::QueryStarved,
764                            Some(format!("资金回报已 {}s 未更新", idle_ms / 1_000)),
765                        )
766                    } else {
767                        (LoginState::Online, count_note("掉线", st.td_disconnects))
768                    }
769                } else {
770                    // 登录成功但资金一次都没到手。轮转第一拍本就要 2~6 秒(ek-ctp 0.7.0 口径),
771                    // 所以刚登录那几秒只算「尚未到手」不算断供;但**"从未到手"不能永远算在线** ——
772                    // 2026-10-22 实连 S15f 实测:账户 B `logged_in=true`、查询被拒 0 次、
773                    // 资金时刻 `None` 持续 390 秒以上,界面上却一直显示「在线 + 资金回报尚未到手」。
774                    // 超过同一个 stale 阈值就翻「查询断供」:判据原料齐了(登录时刻 + 读数时刻),
775                    // 不需要网关多给任何东西。
776                    let since_login_ms = st.td_last_login_ok_ms.map(|ok| now_ms.saturating_sub(ok));
777                    match since_login_ms {
778                        Some(ms) if ms > stale_secs.saturating_mul(1_000) => (
779                            LoginState::QueryStarved,
780                            Some(format!("资金回报从未到手(登录已 {}s)", ms / 1_000)),
781                        ),
782                        _ => (LoginState::Online, Some("资金回报尚未到手".to_string())),
783                    }
784                }
785            } else if self.login == LoginState::Stopped {
786                let extra = if st.td_disconnects > 0 {
787                    format!("(此前掉线 {} 次)", st.td_disconnects)
788                } else {
789                    String::new()
790                };
791                (
792                    LoginState::Stopped,
793                    Some(format!("会话已停{extra};『连接』可恢复")),
794                )
795            } else if st.td_disconnects > 0 {
796                (
797                    LoginState::FrontDown,
798                    Some(format!("掉线 {} 次", st.td_disconnects)),
799                )
800            } else {
801                (LoginState::Unknown, st.td_last_error.clone())
802            }
803        } else if st.md_logged_in {
804            (
805                LoginState::Online,
806                count_note("掉线", st.md_disconnects)
807                    .or_else(|| Some("纯行情账户(无 TD 会话)".to_string())),
808            )
809        } else if self.login == LoginState::Stopped {
810            (
811                LoginState::Stopped,
812                Some("会话已停;『连接』可恢复".to_string()),
813            )
814        } else if st.md_disconnects > 0 {
815            (
816                LoginState::FrontDown,
817                Some(format!("掉线 {} 次", st.md_disconnects)),
818            )
819        } else {
820            (LoginState::Unknown, st.md_last_error.clone())
821        };
822        // 0.9.1 分项接上(随 ek-ctp 0.9.1):总数与连续串**不分路**(消费侧要的是
823        // 「查询这条路连续被拒几次」),但用户要知道被拒的是哪一路 —— 合约一条没到手时,
824        // 下一笔单会被 ContractGate 以 `contract:unknown` 拒掉,而状态列只写「在线」
825        // 就是假信息。加在句尾而不是另开一行:Grid 列宽会被长文案挤成竖排(s39 实锤)。
826        let note = if td_side && st.td_instrument_rejects > 0 {
827            let add = format!("合约查询被拒 {} 次", st.td_instrument_rejects);
828            match note {
829                // 已经点名过合约查询的句子不重复加。
830                Some(base) if base.contains("合约查询被拒") => Some(base),
831                Some(base) => Some(format!("{base};{add}")),
832                None => Some(add),
833            }
834        } else {
835            note
836        };
837        self.login = login;
838        self.status_note = note;
839        // 告警态(登录失败 / 掉线)才配「柜台原因」短标签:在线、未知、已断开都不占状态列。
840        // 这一位是账户行红字后面那段原因的**唯一来源**(BD-UI-63)。
841        self.login_brief = if login.is_alarm() {
842            st.td_last_error
843                .as_deref()
844                .or(st.md_last_error.as_deref())
845                .and_then(brief_counter_reason)
846        } else {
847            None
848        };
849    }
850
851    /// 显示名:别名优先,空则回退账号(寻址仍只用 `gateway`,规划 Q3)。
852    #[must_use]
853    pub fn display_name(&self) -> String {
854        let a = self.alias.trim();
855        if a.is_empty() {
856            self.account.clone()
857        } else {
858            a.to_string()
859        }
860    }
861}
862
863/// 交易监控快照(渲染侧读的那一份;0.5 起它是四本 keyed 账的缓存视图,
864/// 不再是「eTimer 拍 ask oms 全量查询组帧」的产物)。
865#[derive(Debug, Clone, PartialEq, Default)]
866pub struct TradeSnap {
867    pub orders: Vec<ek_data::order::OrderData>,
868    pub trades: Vec<ek_data::trade::TradeData>,
869    pub positions: Vec<ek_data::position::PositionData>,
870    /// 资金帧(oms.get_all_accounts;CTP configure 登录后有数)。
871    pub accounts: Vec<ek_data::account::AccountData>,
872}
873
874/// CSV(每行 `dt,open,high,low,close,volume`;dt 为 epoch 秒)→ BarData 批。
875/// 错行报行号;空 csv = Err;升序不强制(dm.reload 自会逐行入窗)。
876pub fn parse_reload_csv(form: &ReloadForm) -> Result<(i64, String, Vec<BarData>), String> {
877    let interval: i64 = form
878        .interval
879        .trim()
880        .parse()
881        .map_err(|_| format!("回灌 解析: interval 非整数:{:?}", form.interval))?;
882    let symbol = form.symbol.trim().to_string();
883    if symbol.is_empty() {
884        return Err("回灌解析: symbol 空".to_string());
885    }
886    if form.csv.trim().is_empty() {
887        return Err("回灌解析: csv 空".to_string());
888    }
889    let mut bars = Vec::new();
890    for (i, raw) in form.csv.lines().enumerate() {
891        let line = raw.trim();
892        if line.is_empty() || line.starts_with('#') {
893            continue;
894        }
895        let parts: Vec<&str> = line.split(',').map(str::trim).collect();
896        if parts.len() != 6 {
897            return Err(format!("回灌解析: 第 {} 列数 ≠ 6", i + 1));
898        }
899        let num = |s: &str| -> Result<f64, String> {
900            s.parse::<f64>()
901                .map_err(|_| format!("回灌解析: 第 {} 列数值坏:{:?}", i + 1, s))
902        };
903        let dt_s: i64 = parts[0]
904            .parse()
905            .map_err(|_| format!("回灌解析: 第 {} 列 dt 坏:{:?}", i + 1, parts[0]))?;
906        bars.push(BarData {
907            symbol: symbol.clone(),
908            datetime: dt_s * 1_000_000_000,
909            interval: Some(
910                ek_dm::wire::interval_of_seconds(interval)
911                    .unwrap_or(ek_data::constant::Interval::MINUTE),
912            ),
913            open_price: num(parts[1])?,
914            high_price: num(parts[2])?,
915            low_price: num(parts[3])?,
916            close_price: num(parts[4])?,
917            volume: num(parts[5])?,
918            ..BarData::default()
919        });
920    }
921    if bars.is_empty() {
922        return Err("回灌解析: 无有效行".to_string());
923    }
924    Ok((interval, symbol, bars))
925}
926
927// ================= v4 布局面(带/组 tab / tick 库 / 下单表单;docs/ek-ui-layout-v2-plan.md) =================
928
929/// 左上组 tab(Python [委托|未成交];审计修正:行情是带不是 tab,K线/运维/CTP
930/// 是弹窗不是 tab,故不设全局 10 值 UiTab——那是 v2 残影)。
931#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
932pub enum TopLeftTab {
933    #[default]
934    Orders,
935    Active,
936}
937
938impl TopLeftTab {
939    pub fn label(self) -> &'static str {
940        match self {
941            Self::Orders => "委托",
942            Self::Active => "未成交",
943        }
944    }
945}
946
947/// 右下组 tab(Python [资金|日志])。
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
949pub enum BottomRightTab {
950    #[default]
951    Account,
952    Logs,
953}
954
955impl BottomRightTab {
956    pub fn label(self) -> &'static str {
957        match self {
958            Self::Account => "资金",
959            Self::Logs => "日志",
960        }
961    }
962}
963
964/// 数据区两刀的份额(0.4.1 BD-UI-54)。Python `right_splitter` = vertical 50:50,
965/// 我们的左右刀与之正交;两值都夹在 `crate::splitters::clamp_frac` 界内。
966#[derive(Debug, Clone, Copy, PartialEq)]
967pub struct DataSplit {
968    /// 左右刀:左列占比(委托|未成交 在左)。
969    pub col: f32,
970    /// 上下刀:上行占比(委托/成交 在上)。
971    pub row: f32,
972}
973
974impl Default for DataSplit {
975    fn default() -> Self {
976        Self {
977            col: crate::splitters::DATA_SPLIT_DEFAULT,
978            row: crate::splitters::DATA_SPLIT_DEFAULT,
979        }
980    }
981}
982
983/// 行情带六所固定 tab 序(0.4.1 BD-UI-54:六所各一格 = 用户裁定「每个交易所
984/// 都要一个」;原 `crate::dock::MARKET_ORDER` 随 dock 拆除,常量原样搬到这里)。
985pub const MARKET_ORDER: [ek_data::constant::Exchange; 6] = [
986    ek_data::constant::Exchange::SHFE,
987    ek_data::constant::Exchange::DCE,
988    ek_data::constant::Exchange::CZCE,
989    ek_data::constant::Exchange::CFFEX,
990    ek_data::constant::Exchange::INE,
991    ek_data::constant::Exchange::GFEX,
992];
993
994// 0.2.30(BD-UI-46,裁定 8):主题面整块删除 —— 只保留一套主题(当时 = Qt深色
995// qdarkstyle 3.2.3 复刻;0.4.1 四期 BD-UI-57 换成 shadcn Nova 深色,「只有一套」这条
996// 裁定不变,换的是值),视觉规格搬进 `crate::tokens`。原来的 `Theme` 五变体枚举、
997// `label()`、`next()` 循环、`UiShared::theme()/set_theme()/cycle_theme()`、菜单循环钮
998// 全部退役。这是一次**有意的公共面收缩**:`ek_ui::state::Theme` 不再存在。外部没有任何
999// crate 依赖 ek-ui,所以收缩不触发联动;但它不可逆,记在 CHANGELOG 0.2.30。
1000
1001/// tick 库(v2 行情 tab 唯一数据源):symbol → 最新 tick,逐 symbol 覆盖,
1002/// 有界 = 订阅面(ek-ctp 只发已订阅合约;无 bar 回落,用户裁定)。
1003#[derive(Debug, Default)]
1004pub struct TickStore {
1005    ticks: BTreeMap<String, ek_data::tick::TickData>,
1006}
1007
1008impl TickStore {
1009    /// 逐合约覆盖(审计修正:键 = en_symbol「交易所.合约」,防跨所同码相撞;
1010    /// 交易所分组取 tick 自带 exchange)。
1011    pub fn put(&mut self, t: &ek_data::tick::TickData) {
1012        self.ticks
1013            .insert(format!("{}.{}", t.exchange.as_str(), t.symbol), t.clone());
1014    }
1015
1016    /// 只读视图(交易所 → 合约排序,tab 分组顺序稳定)。
1017    pub fn rows(&self) -> Vec<ek_data::tick::TickData> {
1018        let mut v: Vec<ek_data::tick::TickData> = self.ticks.values().cloned().collect();
1019        v.sort_by(|a, b| {
1020            (a.exchange.as_str().to_string(), a.symbol.clone())
1021                .cmp(&(b.exchange.as_str().to_string(), b.symbol.clone()))
1022        });
1023        v
1024    }
1025
1026    /// 按交易所分组(tab 键只列有 tick 的所,不预置全枚举)。
1027    pub fn groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
1028        let mut out: Vec<(String, Vec<ek_data::tick::TickData>)> = Vec::new();
1029        for t in self.rows() {
1030            let ex = t.exchange.as_str().to_string();
1031            match out.last_mut() {
1032                Some((e, v)) if *e == ex => v.push(t),
1033                _ => out.push((ex, vec![t])),
1034            }
1035        }
1036        out
1037    }
1038
1039    pub fn len(&self) -> usize {
1040        self.ticks.len()
1041    }
1042
1043    pub fn is_empty(&self) -> bool {
1044        self.ticks.is_empty()
1045    }
1046}
1047
1048/// 下单面板表单(v2 常驻右侧;Python TradingWidget 字段面等价;操作面非配置面)。
1049#[derive(Debug, Clone, PartialEq)]
1050pub struct OrderForm {
1051    /// 交易所代码(下拉;from_str 抬成枚举)
1052    pub exchange: String,
1053    /// 合约代码(纯 symbol,不带前缀)
1054    pub symbol: String,
1055    pub direction: ek_data::constant::Direction,
1056    pub offset: ek_data::constant::Offset,
1057    pub order_type: ek_data::constant::OrderType,
1058    /// 输入面保持字符串(同 Python QLineEdit),委托时解析
1059    pub price: String,
1060    pub volume: String,
1061    /// 网关(下拉源 = trade_snap gateway 去重集 ∪ CTP 表单现值)
1062    pub gateway: String,
1063    /// 价格随行情勾选(勾选时价格框随选中合约最新 tick)
1064    pub follow_price: bool,
1065    /// 「自动开平」勾选(0.1.1 BD-UI-30;默认开 = LockMode::Auto 走拆单器,
1066    /// 关 = LockMode::None 原样旁路——守卫不可绕,拆单可逐请求旁路)
1067    pub auto_split: bool,
1068}
1069
1070impl Default for OrderForm {
1071    fn default() -> Self {
1072        Self {
1073            exchange: "SHFE".to_string(),
1074            symbol: String::new(),
1075            direction: ek_data::constant::Direction::LONG,
1076            offset: ek_data::constant::Offset::OPEN,
1077            order_type: ek_data::constant::OrderType::LIMIT,
1078            price: String::new(),
1079            volume: "1".to_string(),
1080            gateway: String::new(),
1081            follow_price: true,
1082            auto_split: true,
1083        }
1084    }
1085}
1086
1087impl OrderForm {
1088    /// 表单 → OrderRequest(与票据解析同一出口;错误红字就地,不进总线)。
1089    pub fn to_request(&self) -> Result<ek_data::order_request::OrderRequest, String> {
1090        use std::str::FromStr;
1091        let symbol = self.symbol.trim();
1092        if symbol.is_empty() {
1093            return Err("下单: 代码为空".to_string());
1094        }
1095        if symbol.contains('.') {
1096            return Err("下单: 代码不带交易所前缀(交易所走下拉)".to_string());
1097        }
1098        let exchange = ek_data::constant::Exchange::from_str(self.exchange.trim())
1099            .map_err(|e| format!("下单: {e}"))?;
1100        let price: f64 = self
1101            .price
1102            .trim()
1103            .parse()
1104            .map_err(|_| format!("下单: 价格无效 {:?}", self.price))?;
1105        let volume: f64 = self
1106            .volume
1107            .trim()
1108            .parse()
1109            .map_err(|_| format!("下单: 数量无效 {:?}", self.volume))?;
1110        if self.order_type != ek_data::constant::OrderType::MARKET && price <= 0.0 {
1111            return Err("下单: 限价单价格须为正".to_string());
1112        }
1113        if volume <= 0.0 {
1114            return Err("下单: 数量须为正".to_string());
1115        }
1116        Ok(ek_data::order_request::OrderRequest {
1117            symbol: symbol.to_string(),
1118            exchange,
1119            direction: self.direction,
1120            order_type: self.order_type,
1121            volume,
1122            price,
1123            offset: self.offset,
1124            ..ek_data::order_request::OrderRequest::default()
1125        })
1126    }
1127}
1128
1129/// 合约库(v4 查询合约窗数据源):en_symbol → ContractData。
1130///
1131/// 数据面 = ek-ctp 登录后自动 reqQryInstrument 回流(`eContract.` 裸键逐条 +
1132/// `eContractFinish` 完成线,driver.rs:779)——**无 ask 口,UI 只收不催**
1133/// (用户裁定:订阅不在网关配置定死,查询窗勾选订阅 / 下单代码回车)。
1134#[derive(Debug, Default)]
1135pub struct ContractStore {
1136    contracts: BTreeMap<String, ek_data::contract::ContractData>,
1137}
1138
1139impl ContractStore {
1140    pub fn put(&mut self, c: &ek_data::contract::ContractData) {
1141        self.contracts
1142            .insert(format!("{}.{}", c.exchange.as_str(), c.symbol), c.clone());
1143    }
1144
1145    /// Python ContractManager 查询口径:代码/交易所/名称模糊,留空 = 全部。
1146    pub fn query(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
1147        let p = pattern.trim();
1148        self.contracts
1149            .values()
1150            .filter(|c| {
1151                p.is_empty()
1152                    || c.symbol.contains(p)
1153                    || c.exchange.as_str().eq_ignore_ascii_case(p)
1154                    || c.name.contains(p)
1155            })
1156            .cloned()
1157            .collect()
1158    }
1159
1160    pub fn len(&self) -> usize {
1161        self.contracts.len()
1162    }
1163
1164    pub fn is_empty(&self) -> bool {
1165        self.contracts.is_empty()
1166    }
1167}
1168
1169/// 插件面板与宿主观察线程共用的观测状态。
1170#[derive(Debug, Default)]
1171pub struct UiShared {
1172    logs: Mutex<LogRing>,
1173    snap: Mutex<Arc<BusSnap>>,
1174    market: MarketStore,
1175    ctp_form: Mutex<CtpForm>,
1176    /// 交易四本账(0.5 M1:事件驱动后的唯一事实源;键 = 基线 `data_key`)。
1177    /// 值带行序 `u64` —— 基线 `insert_new_row` 是 `insertRow(0)`(新事件在前)。
1178    trade_orders: Mutex<BTreeMap<String, (u64, ek_data::order::OrderData)>>,
1179    trade_trades: Mutex<VecDeque<(u64, ek_data::trade::TradeData)>>,
1180    /// 成交去重集(键 `en_tradeid` = `{网关}.{成交号}`)。
1181    ///
1182    /// 口径来自基线**引擎层** `BaseTradeEngine.trades_filter_dict`(`engine.py:1072-1080`,
1183    /// 注释写明它是为「账户重连时柜台重推同一笔成交」准备的),与 `ek-oms` 的
1184    /// `trades_filter`(`oms.rs:203 / 260-264`)同键同口径。成交表本身没有 `data_key`
1185    /// (基线 `TradeMonitor` :1267 只插不改),所以去重只能靠这张集,不能靠行键。
1186    /// 它跟着账本走:触顶被挤掉的行同时出集(那笔再推就还能上屏),摘网关时同网关一起清。
1187    trade_ids: Mutex<HashSet<String>>,
1188    trade_positions: Mutex<BTreeMap<String, (u64, ek_data::position::PositionData)>>,
1189    trade_funds: Mutex<BTreeMap<String, (u64, ek_data::account::AccountData)>>,
1190    /// 行序计数器(每次入账递增;快照按它降序排 = 新事件在前)。
1191    trade_seq: AtomicU64,
1192    /// 成交账本触顶丢弃计数(0.5 偏离③;>0 说明更早的成交已被丢掉)。
1193    trade_dropped: AtomicU64,
1194    /// 四本账 → 快照的重建标记(入账置真,`trade_snap()` 消费)。
1195    trade_dirty: AtomicBool,
1196    /// 渲染侧读的交易快照 = 四本账的缓存视图(脏时按帧重建一次)。
1197    trade_snap: Mutex<Arc<TradeSnap>>,
1198    tick_store: Mutex<TickStore>,
1199    order_form: Mutex<OrderForm>,
1200    /// 数据区 2×2 两刀的份额(0.4.1 BD-UI-54:拆 dock 后归薄 QSplitter;
1201    /// col = 左右刀的左占比,row = 上下刀的上占比,缺省 = Python right_splitter 50:50)。
1202    data_split: Mutex<DataSplit>,
1203    /// 行情带当前 tab(按所;六所固定 tab 条,谁在前台就是谁)。
1204    selected_exchange: Mutex<String>,
1205    /// 委托|未成交 组的当前 tab。
1206    topleft_tab: Mutex<TopLeftTab>,
1207    /// 资金|日志 组的当前 tab。
1208    bottomright_tab: Mutex<BottomRightTab>,
1209    ops_window: AtomicBool,
1210    /// 账户面板(多账号面;取代旧单账号 CTP 配置窗的入口 —— 旧窗已退役,
1211    /// 旧 `ctp_window` 位随之删除,不留死状态)。
1212    accounts_window: AtomicBool,
1213    contracts_window: AtomicBool,
1214    contract_store: Mutex<ContractStore>,
1215    contract_query: Mutex<String>,
1216    contract_selected: Mutex<Vec<String>>,
1217    kline_windows: Mutex<Vec<String>>,
1218    rev: AtomicU64,
1219    /// 关窗/测试结束信号(窗口面置位,宿主观察线程消费)。
1220    stop: AtomicBool,
1221    /// svctl 操作指令队列(窗口面入,ui 插件 eTimer 出)。
1222    cmds: Mutex<VecDeque<UiCmd>>,
1223    /// 待二次确认的移除目标(单槽;再次同名点 = 发令)。
1224    pending_confirm: Mutex<Option<String>>,
1225    /// 账户清单(多账号面;界面唯一事实源)。
1226    accounts: Mutex<Vec<AccountRow>>,
1227    /// 账户配置弹窗开合(0.2.18 Commit B:『添加』与『编辑』同一弹窗两种模式)。
1228    account_edit_window: AtomicBool,
1229    /// 弹窗模式:`Some(派生名)` = 编辑该账户;**`None` = 添加新账户**(草稿位仍是 `ctp_form`)。
1230    editing_account: Mutex<Option<String>>,
1231    /// 弹窗里的别名草稿(『保存』时才写回账户行)。
1232    edit_alias: Mutex<String>,
1233    /// 弹窗内的拦截原因(校验不过 / 装配被拒):弹窗**不关**,原因上屏(规划 U3 / Q26)。
1234    edit_error: Mutex<Option<String>>,
1235    /// 行情元账号的注册名(空 = 未选;连接后锁定,规划 D3)。
1236    md_source: Mutex<String>,
1237    /// 订阅集(裸代码;断开重连后按它重放订阅,规划 §5.C)。
1238    subscriptions: Mutex<Vec<String>>,
1239    /// 表格行选中(BD-UI-31):表 id → 选中行键(每表一行;键由渲染侧构造,
1240    /// 纯界面态,不发指令;悬停跳色是渲染侧即时判断,不入状态)。
1241    row_sel: Mutex<BTreeMap<String, String>>,
1242    /// 0.3.1(Wave D,BD-UI-49)表格排序键:`表 id -> Some((列序, 升序))`,`None` = 不排序。
1243    /// 排序键住在界面状态,不进数据模型(W2 的判据就是这条 -- Python `widget.py:396-419`
1244    /// 那个「更新途中重排」的病,根因是排序作用在正在被喂的模型上)。
1245    table_sorts: Mutex<BTreeMap<String, Option<(usize, bool)>>>,
1246    /// 「列边界可拖」的开关(0.3.1 右键菜单「调整列宽」)已在 0.4.1 五期(BD-UI-58)退役:
1247    /// 可拖 + 列线是全部表的固定形制,不再是可开关的东西 → 字段与访问器一并删。
1248    /// 状态轮询游标(0.5 M3 摊平:每拍从游标起环形取 `STATUS_ASKS_PER_TICK` 个)。
1249    status_cursor: AtomicUsize,
1250    /// `svctl.list` 轮询节拍计数(每 `LIST_POLL_TICKS` 拍一次)。
1251    list_tick: AtomicU32,
1252    /// 管制面快照的「现在就问」标记(0.5 执行核查补的洞):插件上/下线与 svctl 起停
1253    /// 之后必须立刻重问一次,否则在场位与运维窗那一行最多要等 4 拍才动 —— 0.4 是每拍
1254    /// 一次,这一档降下来就把「点了移除、行 4 秒不动」变成了常态。
1255    list_due: AtomicBool,
1256    /// 「重播种一次四张表」标记(0.5 执行核查补的第三处):网关或 oms 上线之后重问一次
1257    /// 四口。摘除一个网关账户会作废它名下的委托 / 成交行,而 oms 那边那些行还在 ——
1258    /// 重新添加时不重问一次,界面就永久少了一段历史。消费方是命令线程,不是 eTimer 拍。
1259    seed_due: AtomicBool,
1260    /// `oms.pipeline_status` 待问标记(0.5 M3:改事件触发 —— 启动 / oms 上线 / 手动刷新钮)。
1261    pipeline_due: AtomicBool,
1262    /// 配置托底段目录待问标记(0.6.0 环节8;启用 = 运维窗「刷新段落目录」钮)。
1263    cfg_due: AtomicBool,
1264    /// 配置托底段目录读数(0.6.0;`None` = 没问过 → 运维窗按句提示,不是空表;
1265    /// `Some(vec)`:零值面,`NsBrief` 只有 key/形态标志,BD-CFG-17/18)。
1266    cfg_briefs: Mutex<Option<Vec<ek_cfg::wire::NsBrief>>>,
1267    /// 清段二次确认臂(0.6.0;防误触,与 svctl 移除的 ConfirmStep 同形但独立)。
1268    cfg_delete_arm: Mutex<Option<String>>,
1269    /// 推送线载荷类型异常的计数(0.5 M1:线名 → 次数)。不静默丢。
1270    bad_payload: Mutex<BTreeMap<String, u64>>,
1271    /// oms 装配显形的最新读数(`pipeline_status()` 的字符串行)。
1272    /// 空 = 没问到 / oms 不在场 → 运维窗整节不画(不是「画一个空表」)。
1273    pipeline_lines: Mutex<Vec<String>>,
1274    /// 「查询断供」阈值覆盖 `Some((连续被拒次数, 资金超时秒))`;`None` = 用缺省常量。
1275    /// 阈值住在界面不在网关(ek-ctp D-STATUS),覆盖位是给测试与后续配置面留的口子。
1276    status_thresholds: Mutex<Option<(u32, i64)>>,
1277    /// 0.3.2(Wave E,BD-UI-50)攒着待灌进 toast 队列的告警(界面层产生,渲染帧收口)。
1278    pending_alerts: Mutex<Vec<(crate::toast::AlertLevel, String)>>,
1279    /// 常驻 toast 队列(住在界面状态,不是 render 的局部变量 —— 否则 8 秒自动关与
1280    /// 同屏上限都没有意义)。`None` = 还没按我们的口径建过。
1281    toasts: crate::toast::ToastsCell,
1282    /// 已经弹过 toast 的告警键(去重:同一账户同一状态只弹一次,照 Python AlertEngine 的去重)。
1283    alerted: Mutex<std::collections::BTreeSet<String>>,
1284    /// 0.3.1(Wave D,BD-UI-49)退出确认窗是否开着(照 Python `closeEvent`:关窗先问一句)。
1285    exit_confirm_open: AtomicBool,
1286    /// 用户已在确认窗里点过「确认退出」—— 之后不再拦关闭(否则确认完也关不掉)。
1287    exit_confirmed: AtomicBool,
1288}
1289
1290impl UiShared {
1291    /// 空状态(快照空、日志空、rev 0)。
1292    pub fn new() -> Self {
1293        Self::default()
1294    }
1295
1296    // ---- v2 布局面访问器(tick 库 / 表单 / 选中 / tab) ----
1297
1298    /// 落一条 tick(eTick. 裸键上屏;逐 symbol 覆盖)。
1299    pub fn update_tick(&self, t: &ek_data::tick::TickData) {
1300        self.tick_store.lock().unwrap().put(t);
1301        self.bump();
1302    }
1303
1304    /// tick 只读视图(排序后)。
1305    pub fn tick_rows(&self) -> Vec<ek_data::tick::TickData> {
1306        self.tick_store.lock().unwrap().rows()
1307    }
1308
1309    /// tick 按交易所分组(行情 tab 二级 tab 键表)。
1310    pub fn tick_groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
1311        self.tick_store.lock().unwrap().groups()
1312    }
1313
1314    /// tick 库格数。
1315    pub fn tick_count(&self) -> usize {
1316        self.tick_store.lock().unwrap().len()
1317    }
1318
1319    /// 下单表单只读快照。
1320    pub fn order_form(&self) -> OrderForm {
1321        self.order_form.lock().unwrap().clone()
1322    }
1323
1324    /// 下单表单写(渲染侧每控件改动即写)。
1325    pub fn set_order_form(&self, f: OrderForm) {
1326        *self.order_form.lock().unwrap() = f;
1327        self.bump();
1328    }
1329
1330    /// 选中交易所(行情带当前 tab;不在六所内的取值由本口夹回首所 —— 空/脏值
1331    /// 都落到 SHFE,调用方永远拿到一个可用的 tab 名;行情行单击只带入下单面板,
1332    /// tab 归 tab 条管)。
1333    pub fn selected_exchange(&self) -> String {
1334        let s = self.selected_exchange.lock().unwrap().clone();
1335        if MARKET_ORDER.iter().any(|e| e.as_str() == s) {
1336            s
1337        } else {
1338            MARKET_ORDER[0].as_str().to_string()
1339        }
1340    }
1341
1342    /// 选中交易所写。
1343    pub fn set_selected_exchange(&self, s: impl Into<String>) {
1344        *self.selected_exchange.lock().unwrap() = s.into();
1345        self.bump();
1346    }
1347
1348    /// 左上组当前 tab。
1349    pub fn topleft_tab(&self) -> TopLeftTab {
1350        *self.topleft_tab.lock().unwrap()
1351    }
1352
1353    /// 左上组 tab 写。
1354    pub fn set_topleft_tab(&self, t: TopLeftTab) {
1355        *self.topleft_tab.lock().unwrap() = t;
1356        self.bump();
1357    }
1358
1359    /// 右下组当前 tab。
1360    pub fn bottomright_tab(&self) -> BottomRightTab {
1361        *self.bottomright_tab.lock().unwrap()
1362    }
1363
1364    /// 右下组 tab 写。
1365    pub fn set_bottomright_tab(&self, t: BottomRightTab) {
1366        *self.bottomright_tab.lock().unwrap() = t;
1367        self.bump();
1368    }
1369
1370    /// 数据区分刀份额读(渲染期)。
1371    pub fn data_split(&self) -> DataSplit {
1372        *self.data_split.lock().unwrap()
1373    }
1374
1375    /// 数据区分刀份额写(分隔条拖拽回调;写入前就地夹取到可用区,QSplitter
1376    /// minimumSize 语义 —— 调用方传多少都不出界)。
1377    pub fn set_data_split(&self, s: DataSplit) {
1378        let s = DataSplit {
1379            col: crate::splitters::clamp_frac(s.col),
1380            row: crate::splitters::clamp_frac(s.row),
1381        };
1382        let mut g = self.data_split.lock().unwrap();
1383        if g.col != s.col || g.row != s.row {
1384            *g = s;
1385            self.bump();
1386        }
1387    }
1388
1389    /// 「恢复布局」:数据区分刀回 Python right_splitter 口径(两刀各半),
1390    /// 三个 tab 态回缺省。dock 时代的「整树替换」不再存在 —— 布局骨架本来就是
1391    /// Python 等价的原生面板,没有树可换。
1392    pub fn reset_layout(&self) {
1393        *self.data_split.lock().unwrap() = DataSplit::default();
1394        *self.selected_exchange.lock().unwrap() = String::new();
1395        *self.topleft_tab.lock().unwrap() = TopLeftTab::default();
1396        *self.bottomright_tab.lock().unwrap() = BottomRightTab::default();
1397        self.bump();
1398    }
1399
1400    // ---- v4 弹窗面(egui::Window 开关;kittest 可断言) ----
1401
1402    /// 运维窗开合。
1403    pub fn ops_window_open(&self) -> bool {
1404        self.ops_window.load(Ordering::Acquire)
1405    }
1406
1407    /// 运维窗开合写。
1408    /// 某张表当前的排序键(`None` = 按数据源原序)。
1409    pub fn table_sort(&self, table: &str) -> Option<(usize, bool)> {
1410        self.table_sorts
1411            .lock()
1412            .unwrap()
1413            .get(table)
1414            .copied()
1415            .flatten()
1416    }
1417
1418    /// 表头点击:同列再点 = 翻向;异列 = 新列升序(与 Python QTableView 单击表头同口径)。
1419    pub fn toggle_table_sort(&self, table: &str, col: usize) {
1420        let mut m = self.table_sorts.lock().unwrap();
1421        let asc = match m.get(table).copied().flatten() {
1422            Some((c, a)) if c == col => !a,
1423            _ => true,
1424        };
1425        m.insert(table.to_string(), Some((col, asc)));
1426    }
1427
1428    pub fn set_ops_window_open(&self, v: bool) {
1429        self.ops_window.store(v, Ordering::Release);
1430        self.bump();
1431    }
1432
1433    // ---- 多账号面(规划 §5.B / §5.C) ----
1434
1435    /// 账户面板开合。
1436    pub fn accounts_window_open(&self) -> bool {
1437        self.accounts_window.load(Ordering::Acquire)
1438    }
1439
1440    /// 账户面板开合写。
1441    pub fn set_accounts_window_open(&self, v: bool) {
1442        self.accounts_window.store(v, Ordering::Release);
1443        self.bump();
1444    }
1445
1446    /// 账户配置弹窗开合(0.2.18 Commit B)。
1447    pub fn account_edit_open(&self) -> bool {
1448        self.account_edit_window.load(Ordering::Acquire)
1449    }
1450
1451    /// 当前弹窗模式:`Some(派生名)` = 编辑;`None` = 添加。
1452    pub fn editing_account(&self) -> Option<String> {
1453        self.editing_account.lock().unwrap().clone()
1454    }
1455
1456    /// 打开配置弹窗并载入草稿:`Some(派生名)` = 编辑模式(草稿 = 该账户留档);
1457    /// `None` = 添加模式(草稿 = `draft_from_existing()`,凭据位空)。
1458    /// 账户不在清册 = `false`(不开一个编辑不存在账户的窗)。
1459    pub fn begin_account_edit(&self, gateway: Option<&str>) -> bool {
1460        match gateway {
1461            Some(gw) => {
1462                let Some(row) = self.account(gw) else {
1463                    return false;
1464                };
1465                *self.editing_account.lock().unwrap() = Some(gw.to_string());
1466                *self.edit_alias.lock().unwrap() = row.alias.clone();
1467                self.set_ctp_form(row.form.clone());
1468            }
1469            None => {
1470                *self.editing_account.lock().unwrap() = None;
1471                *self.edit_alias.lock().unwrap() = String::new();
1472                let draft = self.draft_from_existing();
1473                self.set_ctp_form(draft);
1474            }
1475        }
1476        *self.edit_error.lock().unwrap() = None;
1477        self.account_edit_window.store(true, Ordering::Release);
1478        self.bump();
1479        true
1480    }
1481
1482    /// 关闭配置弹窗 = **放弃未保存**(规划 Q24:绝不静默写入清册留档)。
1483    pub fn end_account_edit(&self) {
1484        *self.editing_account.lock().unwrap() = None;
1485        *self.edit_alias.lock().unwrap() = String::new();
1486        *self.edit_error.lock().unwrap() = None;
1487        self.account_edit_window.store(false, Ordering::Release);
1488        let draft = self.draft_from_existing();
1489        self.set_ctp_form(draft);
1490        self.bump();
1491    }
1492
1493    /// 摘除一行账户时的连带口径:若这行正被配置弹窗**编辑**,立即关弹窗放弃。
1494    ///
1495    /// 不这么做的话:弹窗是非 modal 的,用户可以在弹窗开着时点同一行的『移除』——
1496    /// 编辑目标消失后弹窗会静默退化成「添加模式」(只读句消失、字段全部可编辑),
1497    /// 此时点『保存』会拿旧凭据建出一个**新账户**。这是执行核查抓到的坑。
1498    pub fn end_account_edit_if_editing(&self, gateway: &str) {
1499        if self.editing_account().as_deref() == Some(gateway) {
1500            self.end_account_edit();
1501        }
1502    }
1503
1504    /// 弹窗别名草稿读。
1505    pub fn edit_alias(&self) -> String {
1506        self.edit_alias.lock().unwrap().clone()
1507    }
1508
1509    /// 弹窗别名草稿写。
1510    pub fn set_edit_alias(&self, v: impl Into<String>) {
1511        *self.edit_alias.lock().unwrap() = v.into();
1512        self.bump();
1513    }
1514
1515    /// 弹窗拦截原因读(`None` = 无)。
1516    pub fn edit_error(&self) -> Option<String> {
1517        self.edit_error.lock().unwrap().clone()
1518    }
1519
1520    /// 弹窗拦截原因写(`None` = 清)。
1521    pub fn set_edit_error(&self, e: Option<String>) {
1522        *self.edit_error.lock().unwrap() = e;
1523        self.bump();
1524    }
1525
1526    /// 弹窗草稿与该账户留档是否不一致(dirty;添加模式恒为"有内容待保存")。
1527    pub fn account_edit_dirty(&self) -> bool {
1528        let editing = self.editing_account();
1529        let draft = self.ctp_form();
1530        match editing {
1531            Some(gw) => self.account(&gw).map(|a| a.form != draft).unwrap_or(false),
1532            None => draft != CtpForm::default() && !draft.td_user.trim().is_empty(),
1533        }
1534    }
1535
1536    /// 账户清单只读视图(注册序)。
1537    pub fn accounts(&self) -> Vec<AccountRow> {
1538        self.accounts.lock().unwrap().clone()
1539    }
1540
1541    /// 单行账户查询。
1542    pub fn account(&self, gateway: &str) -> Option<AccountRow> {
1543        self.accounts
1544            .lock()
1545            .unwrap()
1546            .iter()
1547            .find(|a| a.gateway == gateway)
1548            .cloned()
1549    }
1550
1551    /// 新增一行账户(**只写清册,不装配** —— 规划裁定「添加 ≠ 连接」)。
1552    ///
1553    /// `Err(原因)` 三种:派生名已存在(同 broker+账号重复添加)、别名重名(规划 Q3)、
1554    /// 达账户软上限 `MAX_ACCOUNTS`。宿主侧还有第二道 `Duplicate` 守卫;界面侧先拦是为了
1555    /// 不建出一个"注定被拒"的请求。
1556    pub fn add_account(&self, row: AccountRow) -> Result<(), String> {
1557        let mut acc = self.accounts.lock().unwrap();
1558        if acc.iter().any(|a| a.gateway == row.gateway) {
1559            return Err(format!("账户 {} 已存在(同 broker + 账号)", row.gateway));
1560        }
1561        let alias = row.alias.trim();
1562        if !alias.is_empty()
1563            && acc
1564                .iter()
1565                .any(|a| a.alias.trim().eq_ignore_ascii_case(alias))
1566        {
1567            return Err(format!("别名 {alias:?} 已被其他账户占用(别名须唯一)"));
1568        }
1569        if acc.len() >= MAX_ACCOUNTS {
1570            return Err(format!(
1571                "账户已达软上限 {MAX_ACCOUNTS} 个(先移除一个再添加)"
1572            ));
1573        }
1574        acc.push(row);
1575        drop(acc);
1576        self.bump();
1577        Ok(())
1578    }
1579
1580    /// 是否还能添加账户。
1581    #[must_use]
1582    pub fn can_add_account(&self) -> bool {
1583        self.accounts.lock().unwrap().len() < MAX_ACCOUNTS
1584    }
1585
1586    /// 改一行账户的别名(按 gateway 主键;不存在 = false)。
1587    ///
1588    /// 别名只用于显示与选择,**不参与寻址**(寻址恒用派生注册名,规划 Q3)。
1589    /// 别名须**唯一**(与 `add_account` 同一守卫):空别名 = 不设别名(合法);
1590    /// 与他人重名 = false(调用方须显形,不得静默改成重名别名)。
1591    pub fn set_account_alias(&self, gateway: &str, alias: &str) -> bool {
1592        let alias = alias.trim().to_string();
1593        let mut acc = self.accounts.lock().unwrap();
1594        if !alias.is_empty()
1595            && acc
1596                .iter()
1597                .any(|a| a.gateway != gateway && a.alias.eq_ignore_ascii_case(&alias))
1598        {
1599            return false;
1600        }
1601        match acc.iter_mut().find(|a| a.gateway == gateway) {
1602            Some(a) => {
1603                a.alias = alias;
1604                drop(acc);
1605                self.bump();
1606                true
1607            }
1608            None => false,
1609        }
1610    }
1611
1612    /// `UiCmd::Connect` 已入列 → 四态置「装配中」并清掉上一次的失败原因。
1613    ///
1614    /// 登录态一并退回 `Unknown`(0.2.19):新一轮装配开始时,上一轮的「在线/掉线」
1615    /// 不再是这一轮的事实 —— 留着会让用户在重连过程中看到「在线」。
1616    pub fn note_connect_sent(&self, gateway: &str) {
1617        let mut acc = self.accounts.lock().unwrap();
1618        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1619            a.stage = AccountStage::Assembling;
1620            a.fail_note = None;
1621            a.login = LoginState::Unknown;
1622            a.status_note = None;
1623            a.login_brief = None;
1624            // 链状态同理作废(0.5.0):上一轮会话的「查询合约中」不是这一轮的事实。
1625            a.chain_note = None;
1626            drop(acc);
1627            self.bump();
1628        }
1629    }
1630
1631    /// 装配结果回写:`Ok(())` → 「实例已装配」;`Err(原因)` → 「装配失败」+ 原因留显形。
1632    ///
1633    /// 失败**不回滚清册行**(规划 U3 定盘:行留着,给『重试』;旧口径"必须先移除才能重试"作废)。
1634    pub fn note_connect_result(&self, gateway: &str, result: Result<(), String>) {
1635        let mut acc = self.accounts.lock().unwrap();
1636        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1637            match result {
1638                Ok(()) => {
1639                    a.stage = AccountStage::Assembled;
1640                    a.fail_note = None;
1641                }
1642                Err(e) => {
1643                    a.stage = AccountStage::AssembleFailed;
1644                    a.fail_note = Some(e);
1645                }
1646            }
1647            drop(acc);
1648            self.bump();
1649        }
1650    }
1651
1652    /// 记录网关实际收录的产品类(`ConfigureReply.product_classes` 回带,规划 U9)。
1653    ///
1654    /// 只存不改用户勾选:勾选是意图,这一栏是事实,两者并排显示。
1655    pub fn note_effective_product_classes(&self, gateway: &str, classes: &[String]) {
1656        let mut acc = self.accounts.lock().unwrap();
1657        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1658            a.effective_classes = classes.to_vec();
1659            drop(acc);
1660            self.bump();
1661        }
1662    }
1663
1664    /// 链状态行落账(0.5.0,用户定):`note` = `[链] ` 前缀之后的正文,账户表「阶段」列显示它。
1665    ///
1666    /// 只认 ek-ctp 推的状态行(调用点在 `plugin.rs` 的 eLog 分支按前缀筛),普通日志不动这一栏。
1667    /// 没有对应账户行 = 什么都不做(网关还没进清册,状态行先到是正常时序)。
1668    pub fn note_chain(&self, gateway: &str, note: &str) {
1669        let mut acc = self.accounts.lock().unwrap();
1670        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1671            if a.chain_note.as_deref() == Some(note) {
1672                return; // 同一条状态重复推(例如重连时同一阶段再来一次)不 bump、不刷帧
1673            }
1674            a.chain_note = Some(note.to_string());
1675            drop(acc);
1676            self.bump();
1677        }
1678    }
1679
1680    // ---- 0.2.19 状态面(`{gw}.status` 轮询结果落账;规划 P3 / 裁定 3) ----
1681
1682    /// 当前生效的「查询断供」阈值:`(连续被拒次数, 资金回报超时秒)`。
1683    #[must_use]
1684    pub fn status_thresholds(&self) -> (u32, i64) {
1685        self.status_thresholds
1686            .lock()
1687            .unwrap()
1688            .unwrap_or((QUERY_REJECT_STREAK_LIMIT, ACCOUNT_STALE_SECS))
1689    }
1690
1691    /// 覆盖阈值(测试夹具把两判据收紧到「1 次 / 0 秒」,才好在没有真柜台的用例里显形)。
1692    pub fn set_status_thresholds(&self, reject_limit: u32, stale_secs: i64) {
1693        *self.status_thresholds.lock().unwrap() = Some((reject_limit, stale_secs));
1694        self.bump();
1695    }
1696
1697    /// 管制面快照的轮询节拍:eTimer 每拍调一次。
1698    ///
1699    /// 三个条件任一为真就问:① 被标记(插件上/下线、svctl 起停);② 第一拍(界面刚
1700    /// 起来不该空着等 4 秒);③ 每 `LIST_POLL_TICKS` 拍一次。
1701    pub fn list_poll_due(&self) -> bool {
1702        let n = self.list_tick.fetch_add(1, Ordering::Relaxed) + 1;
1703        self.list_due.swap(false, Ordering::AcqRel) || n == 1 || n.is_multiple_of(LIST_POLL_TICKS)
1704    }
1705
1706    /// 置「下一拍就重问管制面」(插件上下线、svctl 起停回执之后)。
1707    pub fn request_list_refresh(&self) {
1708        self.list_due.store(true, Ordering::Release);
1709    }
1710
1711    /// 置「重播种一次四张表」(网关或 oms 上线之后)。消费方 = 命令线程。
1712    pub fn request_trade_reseed(&self) {
1713        self.seed_due.store(true, Ordering::Release);
1714    }
1715
1716    /// 取走「重播种」标记(消费性,不是一档状态)。
1717    pub fn take_trade_reseed(&self) -> bool {
1718        self.seed_due.swap(false, Ordering::AcqRel)
1719    }
1720
1721    /// 这一拍该问哪几个账户的 `{gw}.status`(0.5 M3 摊平)。
1722    ///
1723    /// 入参 = 当前「已装配且在场」的网关名;返回不超过 `STATUS_ASKS_PER_TICK` 个,
1724    /// 从游标起环形取 —— 8 个账户 4 拍走完一轮,单拍 ask 上限恒定。
1725    pub fn status_batch(&self, present: &[String]) -> Vec<String> {
1726        // 先按名去重:账户清册允许两行共用同一个网关(第二行 `svctl.start` 回「已在场」
1727        // 而行仍在清册里),而 `{gw}.status` 是按网关寻址的同一个服务口 —— 不去重就是
1728        // 同一拍里白问两次,而且把环形节律打乱(两个格子问同一个网关,别的网关没人问)。
1729        let mut uniq: Vec<String> = Vec::with_capacity(present.len());
1730        for gw in present {
1731            if !uniq.iter().any(|u| u == gw) {
1732                uniq.push(gw.clone());
1733            }
1734        }
1735        let n = uniq.len();
1736        if n == 0 {
1737            return Vec::new();
1738        }
1739        let start = self.status_cursor.fetch_add(1, Ordering::Relaxed) % n;
1740        let take = STATUS_ASKS_PER_TICK.min(n);
1741        let mut out = Vec::with_capacity(take);
1742        for i in 0..take {
1743            out.push(uniq[(start + i) % n].clone());
1744        }
1745        out
1746    }
1747
1748    /// 置「该问一次 oms 装配」(0.5 M3:事件触发而不是节拍)。
1749    pub fn request_pipeline_refresh(&self) {
1750        self.pipeline_due.store(true, Ordering::Release);
1751    }
1752    /// 消费「该问一次 oms 装配」标记(取一次即清)。
1753    pub fn take_pipeline_due(&self) -> bool {
1754        self.pipeline_due.swap(false, Ordering::AcqRel)
1755    }
1756
1757    /// 置「该问一次段落目录」(0.6.0 环节8;运维窗钮)。
1758    pub fn request_cfg_refresh(&self) {
1759        self.cfg_due.store(true, Ordering::Release);
1760    }
1761    /// 消费「该问一次段落目录」标记(取一次即清)。
1762    pub fn take_cfg_due(&self) -> bool {
1763        self.cfg_due.swap(false, Ordering::AcqRel)
1764    }
1765    /// 段落目录回写(0.6.0)。
1766    pub fn set_cfg_briefs(&self, b: Vec<ek_cfg::wire::NsBrief>) {
1767        *self.cfg_briefs.lock().unwrap() = Some(b);
1768        self.bump();
1769    }
1770    /// 段落目录只读视图(0.6.0;`None` = 从未问过)。
1771    pub fn cfg_briefs(&self) -> Option<Vec<ek_cfg::wire::NsBrief>> {
1772        self.cfg_briefs.lock().unwrap().clone()
1773    }
1774
1775    /// 清段二次确认臂(0.6.0;`Some(ns)` = 已点过一次,下一帧再点同一行才真发)。
1776    pub fn cfg_delete_armed(&self) -> Option<String> {
1777        self.cfg_delete_arm.lock().unwrap().clone()
1778    }
1779    pub fn arm_cfg_delete(&self, ns: Option<String>) {
1780        *self.cfg_delete_arm.lock().unwrap() = ns;
1781        self.bump();
1782    }
1783
1784    /// `oms.pipeline_status` 读数回写(装配事实,不是业务事实)。
1785    pub fn set_pipeline_lines(&self, lines: Vec<String>) {
1786        *self.pipeline_lines.lock().unwrap() = lines;
1787        self.bump();
1788    }
1789
1790    /// 最新一次的 oms 装配显形读数(空 = 运维窗不画这一节)。
1791    pub fn pipeline_lines(&self) -> Vec<String> {
1792        self.pipeline_lines.lock().unwrap().clone()
1793    }
1794
1795    /// 某条推送线的载荷类型不对(0.5 M1)。
1796    ///
1797    /// 链上这几条线的载荷类型不是编译期保证:`ePosition.` 的载荷「由广发方确定」
1798    /// (ek-oms plugin.rs:329 同样只能 downcast 试)。收不进去就计数,并在**第一次**
1799    /// 显形一条日志 —— 静默丢等于「界面少了一路数据而没人知道」。
1800    pub fn note_bad_payload(&self, line: &str) {
1801        let mut book = self.bad_payload.lock().unwrap();
1802        let n = book.entry(line.to_string()).or_insert(0);
1803        *n += 1;
1804        if *n == 1 {
1805            self.log_line(format!(
1806                "[ui] {line} 载荷类型异常(第一次):这一路的行进不了账本"
1807            ));
1808        }
1809    }
1810
1811    /// 某条推送线累计收不进去多少条(测试判据)。
1812    #[must_use]
1813    pub fn bad_payload_count(&self, line: &str) -> u64 {
1814        self.bad_payload
1815            .lock()
1816            .unwrap()
1817            .get(line)
1818            .copied()
1819            .unwrap_or(0)
1820    }
1821
1822    /// 插件邮箱溢出的显形(0.4.5,P7d;0.5 M6 把容量判据改成逐行各自的容量)。
1823    ///
1824    /// `PlugRow.spilled` 是 ek-core 的累计计数(`EntryStats.spilled`),运维窗那一列
1825    /// 一直有,但**当场不响**:ek-core 的 stderr 诊断每 128 条才打一条
1826    /// (`SPILL_REPORT_EVERY`)。这一条把它变成屏上的一次 toast。
1827    /// 去重口径与 BD-UI-50 同(告警是状态口问出来的事实,不是渲染帧的副作用):
1828    /// 每个插件第一次溢出弹一次;跨过**该插件自己的**邮箱容量再弹一次(持续溢出不刷屏,
1829    /// 但「溢满一整轮」是另一件事 —— 那时丢的已经不是零星几条而是一段时间的行情)。
1830    pub fn note_plugin_spill(&self, rows: &[ek_station::wire::PlugRow]) {
1831        for r in rows {
1832            if r.spilled == 0 {
1833                continue;
1834            }
1835            let cap = mailbox_cap_of(&r.name);
1836            if self.alert_once(format!("spill:{}", r.name)) {
1837                self.push_alert(
1838                    crate::toast::AlertLevel::Warning,
1839                    format!(
1840                        "插件 {}:邮箱溢出 {} 条(刷新停顿时行情会堆;溢出丢的是新到的事件)",
1841                        r.name, r.spilled
1842                    ),
1843                );
1844            }
1845            if r.spilled >= cap as u64 && self.alert_once(format!("spill-full:{}", r.name)) {
1846                self.push_alert(
1847                    crate::toast::AlertLevel::Error,
1848                    format!(
1849                        "插件 {}:溢出 {} 条 ≥ 该插件邮箱容量 {}(丢的是一段时期的事件,不是零星几条)",
1850                        r.name, r.spilled, cap
1851                    ),
1852                );
1853            }
1854        }
1855    }
1856
1857    /// `{gw}.status` 读数回写 → 该行登录态与细节重算。
1858    ///
1859    /// 0.3.2(Wave E,BD-UI-50)这里同时是**告警的唯一产生口**:轮询一拍问出告警态就灌 toast。
1860    /// 为什么住这儿而不是账户面板的渲染里(Python `AlertEngine` 的对照口径):
1861    /// 告警是「状态口问出来的事实」,不是「某张窗画出来的东西」——账户窗没开也必须知道掉线了。
1862    /// 去重键 = 网关 + 登录态:同一状态只弹一次,状态变了才算新告警(否则每轮轮询一条,
1863    /// 8 秒自动关与同屏上限 3 都没有意义)。
1864    pub fn note_gateway_status(&self, gateway: &str, st: &ek_ctp::plugin::GatewayStatus) {
1865        let (limit, stale) = self.status_thresholds();
1866        let now = ek_ctp::map::now_epoch_ms();
1867        // 先在账户锁里把「这一拍是不是告警态」读出来,出锁再发 toast
1868        // (accounts / alerted / pending_alerts 是三颗独立的锁,不嵌套持有)。
1869        let mut alarm: Option<(LoginState, String)> = None;
1870        let mut found = false;
1871        {
1872            let mut acc = self.accounts.lock().unwrap();
1873            if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1874                found = true;
1875                a.apply_status(st, now, limit, stale);
1876                if a.login.is_alarm() || a.login == LoginState::QueryStarved {
1877                    alarm = Some((
1878                        a.login,
1879                        a.status_note
1880                            .clone()
1881                            .unwrap_or_else(|| a.login.label().to_string()),
1882                    ));
1883                }
1884            }
1885        }
1886        if found {
1887            self.bump();
1888        }
1889        if let Some((login, note)) = alarm
1890            && self.alert_once(format!("{gateway}|{}", login.label()))
1891        {
1892            self.push_alert(
1893                crate::toast::AlertLevel::Error,
1894                format!("账户 {gateway}:{note}"),
1895            );
1896        }
1897    }
1898
1899    /// 状态口问不到(网关不在场 / ask 超时):登录态退回 `Unknown` 并写明原因。
1900    ///
1901    /// **不得保留上一轮的「在线」**:问不到就是没有证据,留着旧读数等于替网关谎称在线。
1902    pub fn note_status_unreachable(&self, gateway: &str, why: &str) {
1903        let mut acc = self.accounts.lock().unwrap();
1904        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1905            a.login = LoginState::Unknown;
1906            a.status_note = Some(format!("状态口问不到:{why}"));
1907            a.login_brief = None;
1908            drop(acc);
1909            self.bump();
1910        }
1911    }
1912
1913    /// 『断开』已发令 → 该行立刻显形「已断开」(不等下一轮轮询;用户按了就该马上看到)。
1914    pub fn mark_account_stopped(&self, gateway: &str) {
1915        let mut acc = self.accounts.lock().unwrap();
1916        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
1917            a.login = LoginState::Stopped;
1918            a.status_note = Some("会话已停;『连接』可恢复".to_string());
1919            a.login_brief = None;
1920            drop(acc);
1921            self.bump();
1922        }
1923    }
1924
1925    /// 装配前把表单落到该账户留档,并按**全局单源纪律**裁决 md 位:
1926    /// 非行情源账户 → `md_addr` 就地清空(**留档与下发同一份**,消 U4 的"只清下发不清留档")。
1927    pub fn prepare_connect_form(&self, gateway: &str, form: CtpForm) -> CtpForm {
1928        let source = self.md_source();
1929        let mut f = form;
1930        if source.as_str() != gateway {
1931            f.md_addr.clear();
1932            f.md_user.clear();
1933            f.md_password.clear();
1934        }
1935        self.update_account_form(gateway, f.clone());
1936        f
1937    }
1938
1939    /// 『添加账户』弹窗的草稿位:沿用当前在场账户的**非凭据字段**
1940    /// (broker / AppID / AuthCode / 收录勾选),凭据位(账号、密码、前置)一律清空
1941    /// (规划 Q20 的零风险缓解:少敲几遍,但绝不把上一家的密码带进下一家)。
1942    #[must_use]
1943    pub fn draft_from_existing(&self) -> CtpForm {
1944        let acc = self.accounts.lock().unwrap();
1945        let mut draft = CtpForm::default();
1946        if let Some(a) = acc.last() {
1947            draft.broker_id = a.form.broker_id.clone();
1948            draft.td_auth_code = a.form.td_auth_code.clone();
1949            draft.td_app_id = a.form.td_app_id.clone();
1950            draft.accept_futures = a.form.accept_futures;
1951            draft.accept_options = a.form.accept_options;
1952        }
1953        draft
1954    }
1955
1956    /// 更新一行账户的表单留档(按 gateway 主键;不存在 = false)。
1957    pub fn update_account_form(&self, gateway: &str, form: CtpForm) -> bool {
1958        let mut acc = self.accounts.lock().unwrap();
1959        match acc.iter_mut().find(|a| a.gateway == gateway) {
1960            Some(a) => {
1961                a.form = form;
1962                drop(acc);
1963                self.bump();
1964                true
1965            }
1966            None => false,
1967        }
1968    }
1969
1970    /// 摘除一行账户(界面侧清册;网关实例由 `UiCmd::Remove` 走 svctl 摘)。
1971    pub fn remove_account(&self, gateway: &str) -> bool {
1972        let mut acc = self.accounts.lock().unwrap();
1973        let before = acc.len();
1974        acc.retain(|a| a.gateway != gateway);
1975        let removed = acc.len() != before;
1976        if removed {
1977            // 元账号被摘 → 源位清空(等用户重新指定);订阅集保留(规划 §5.C)。
1978            let mut md = self.md_source.lock().unwrap();
1979            if md.as_str() == gateway {
1980                md.clear();
1981            }
1982            drop(acc);
1983            self.bump();
1984        }
1985        removed
1986    }
1987
1988    /// 用 svctl 快照回填账户在场位与四态;回带**本轮新装配**的网关名(订阅重放用)。
1989    ///
1990    /// 四态口径:svctl 带回这个名字 = 实例已装配(清掉失败原因);
1991    /// 名字不在场且原先记着「已装配」= 回到「未装配」(被摘/宿主崩了);
1992    /// 「装配中」与「装配失败」是界面侧记的,**不被在场表覆盖**。
1993    ///
1994    /// 0.2.19 附带一条:实例不在场时**登录态读数一并作废** —— 会话读数属于那个实例,
1995    /// 实例没了还留着「在线/掉线 2 次」,就是在替一个不存在的会话说话。
1996    pub fn sync_accounts_connected(&self, rows: &[PlugRow]) -> Vec<String> {
1997        let mut newly = Vec::new();
1998        {
1999            let mut acc = self.accounts.lock().unwrap();
2000            for a in acc.iter_mut() {
2001                let up = rows.iter().any(|r| r.name == a.gateway);
2002                if up && !a.connected {
2003                    newly.push(a.gateway.clone());
2004                }
2005                a.connected = up;
2006                if up {
2007                    a.stage = AccountStage::Assembled;
2008                    a.fail_note = None;
2009                } else {
2010                    // 实例不在场 = 这一条会话没了:链状态同理作废(0.5.0「阶段」栏)。
2011                    // 留着就是替一个不存在的会话说话(与上面「登录态读数一并作废」同一条理由)。
2012                    a.chain_note = None;
2013                    if a.stage == AccountStage::Assembled {
2014                        a.stage = AccountStage::NotAssembled;
2015                        a.login = LoginState::Unknown;
2016                        a.status_note = None;
2017                        a.login_brief = None;
2018                    }
2019                }
2020            }
2021        }
2022        if !newly.is_empty() {
2023            self.bump();
2024        }
2025        newly
2026    }
2027
2028    /// 行情元账号注册名(空 = 未选)。
2029    pub fn md_source(&self) -> String {
2030        self.md_source.lock().unwrap().clone()
2031    }
2032
2033    /// 是否已有账户装配 —— 元账号锁定判定(规划 D3:连接后不允许切换)。
2034    pub fn accounts_locked(&self) -> bool {
2035        self.accounts.lock().unwrap().iter().any(|a| a.connected)
2036    }
2037
2038    /// 指定行情元账号(仅配置阶段;任何账户已连接 = 拒,界面须提示"先断开")。
2039    pub fn set_md_source(&self, gateway: &str) -> Result<(), String> {
2040        {
2041            let acc = self.accounts.lock().unwrap();
2042            if acc.iter().any(|a| a.connected) {
2043                return Err("元账号已锁定:有账户在线,切换行情源需先断开该账户".to_string());
2044            }
2045            if !acc.iter().any(|a| a.gateway == gateway) {
2046                return Err(format!("账户 {gateway} 不在清册"));
2047            }
2048        }
2049        let mut acc = self.accounts.lock().unwrap();
2050        for a in acc.iter_mut() {
2051            a.md_source = a.gateway == gateway;
2052        }
2053        drop(acc);
2054        let mut md = self.md_source.lock().unwrap();
2055        *md = gateway.to_string();
2056        self.bump();
2057        Ok(())
2058    }
2059
2060    /// 订阅集只读视图。
2061    pub fn subscriptions(&self) -> Vec<String> {
2062        self.subscriptions.lock().unwrap().clone()
2063    }
2064
2065    /// 登记订阅(去重保序;断开重连后按它重放)。
2066    pub fn note_subscriptions(&self, symbols: &[String]) {
2067        {
2068            let mut subs = self.subscriptions.lock().unwrap();
2069            for s in symbols {
2070                let s = s.trim();
2071                if !s.is_empty() && !subs.iter().any(|x| x == s) {
2072                    subs.push(s.to_string());
2073                }
2074            }
2075        }
2076        self.bump();
2077    }
2078
2079    /// 下单默认账户阶梯(替代旧"空 → 字面量 ctp"兜底,规划 §5.B;
2080    /// **0.2.18 四态后修正,规划 Q4**):
2081    /// 上次手动选择(须仍在清册且已装配)→ 第一个已装配账户;
2082    /// **未装配账户不得进默认** —— 旧阶梯的"退到第一个账户"会把只添加了还没连接的
2083    /// 账户选成下单归属,发出去只会撞 `unknown:gateway`。
2084    /// 一个都没有 = `None`(界面据此禁用下单钮)。
2085    pub fn default_gateway(&self) -> Option<String> {
2086        let chosen = {
2087            let f = self.order_form.lock().unwrap();
2088            f.gateway.trim().to_string()
2089        };
2090        if !chosen.is_empty() && self.account(&chosen).is_some_and(|a| a.connected) {
2091            return Some(chosen);
2092        }
2093        let acc = self.accounts.lock().unwrap();
2094        acc.iter().find(|a| a.connected).map(|a| a.gateway.clone())
2095    }
2096
2097    /// 已开 K线窗合约表(多开,去重)。
2098    pub fn kline_symbols(&self) -> Vec<String> {
2099        self.kline_windows.lock().unwrap().clone()
2100    }
2101
2102    /// 开 K线窗(去重;日志留痕可断言)。
2103    pub fn open_kline(&self, symbol: impl Into<String>) {
2104        let s = symbol.into();
2105        let mut v = self.kline_windows.lock().unwrap();
2106        if !v.contains(&s) {
2107            v.push(s.clone());
2108        }
2109        drop(v);
2110        self.log_line(format!("[kline] 打开 K线窗 {s}"));
2111    }
2112
2113    /// 关 K线窗(窗关闭钮 → 出表)。
2114    pub fn close_kline(&self, symbol: &str) {
2115        self.kline_windows.lock().unwrap().retain(|s| s != symbol);
2116        self.bump();
2117    }
2118
2119    // ---- v4 查询合约窗面(合约库 / 查询串 / 勾选集) ----
2120
2121    /// 查询合约窗开合。
2122    pub fn contracts_window_open(&self) -> bool {
2123        self.contracts_window.load(Ordering::Acquire)
2124    }
2125
2126    /// 查询合约窗开合写。
2127    pub fn set_contracts_window_open(&self, v: bool) {
2128        self.contracts_window.store(v, Ordering::Release);
2129        self.bump();
2130    }
2131
2132    /// 落一条合约(`eContract.` 裸键上屏)。
2133    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
2134        self.contract_store.lock().unwrap().put(c);
2135        self.bump();
2136    }
2137
2138    /// 合约查询(Python ContractManager 口径:留空 = 全部)。
2139    pub fn contracts(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
2140        self.contract_store.lock().unwrap().query(pattern)
2141    }
2142
2143    /// 合约库格数。
2144    pub fn contract_count(&self) -> usize {
2145        self.contract_store.lock().unwrap().len()
2146    }
2147
2148    /// 查询串读写。
2149    pub fn contract_query(&self) -> String {
2150        self.contract_query.lock().unwrap().clone()
2151    }
2152
2153    /// 查询串写。
2154    pub fn set_contract_query(&self, q: impl Into<String>) {
2155        *self.contract_query.lock().unwrap() = q.into();
2156        self.bump();
2157    }
2158
2159    /// 勾选翻转(en_symbol)。
2160    pub fn toggle_contract_selection(&self, en_symbol: &str) {
2161        let mut v = self.contract_selected.lock().unwrap();
2162        match v.iter().position(|s| s == en_symbol) {
2163            Some(i) => {
2164                v.remove(i);
2165            }
2166            None => v.push(en_symbol.to_string()),
2167        }
2168        self.bump();
2169    }
2170
2171    /// 当前勾选集。
2172    pub fn selected_contracts(&self) -> Vec<String> {
2173        self.contract_selected.lock().unwrap().clone()
2174    }
2175
2176    /// 追加一行日志(总线面/宿主观察线程写)。
2177    pub fn log_line(&self, line: impl Into<String>) {
2178        self.logs.lock().unwrap().push(line);
2179        self.bump();
2180    }
2181
2182    /// 清空日志(渲染"清空日志"按钮 / 测试)。
2183    pub fn clear_logs(&self) {
2184        self.logs.lock().unwrap().clear();
2185        self.bump();
2186    }
2187
2188    /// 日志只读快照 + 溢出计数。
2189    pub fn logs_snapshot(&self) -> (Vec<String>, u64) {
2190        let r = self.logs.lock().unwrap();
2191        (r.snapshot(), r.spilled())
2192    }
2193
2194    /// 带时间戳的日志快照(BD-UI-41 渲染侧:日志表格的「时间」列)。
2195    pub fn logs_entries(&self) -> (Vec<LogEntry>, u64) {
2196        let r = self.logs.lock().unwrap();
2197        (r.entries(), r.spilled())
2198    }
2199
2200    /// 日志当前长度(kittest 断言用)。
2201    pub fn logs_len(&self) -> usize {
2202        self.logs.lock().unwrap().len()
2203    }
2204
2205    /// 落一次服务快照(eTimer 节流回调)。
2206    pub fn set_snapshot(&self, snap: BusSnap) {
2207        *self.snap.lock().unwrap() = Arc::new(snap);
2208        self.bump();
2209    }
2210
2211    /// 当前快照(Arc 克隆,零拷贝走锁)。
2212    pub fn snapshot(&self) -> Arc<BusSnap> {
2213        Arc::clone(&self.snap.lock().unwrap())
2214    }
2215
2216    /// revision:任何写入 +1(插件面 ePlug 事件也用;测试判定"有事件进来过")。
2217    pub fn rev(&self) -> u64 {
2218        self.rev.load(Ordering::Acquire)
2219    }
2220
2221    /// 手动推一次 revision(总线面 ePlugUp/Down 记账用)。
2222    pub fn bump(&self) {
2223        self.rev.fetch_add(1, Ordering::AcqRel);
2224    }
2225
2226    /// 置关窗信号(eframe App 关窗钩子调用)。
2227    pub fn request_stop(&self) {
2228        self.stop.store(true, Ordering::Release);
2229    }
2230
2231    /// 观察线程轮询用。
2232    /// 0.3.2(Wave E,BD-UI-50)告警入口:界面层调它,渲染帧灌进 toast 队列。
2233    pub fn push_alert(&self, level: crate::toast::AlertLevel, text: impl Into<String>) {
2234        self.pending_alerts
2235            .lock()
2236            .unwrap_or_else(std::sync::PoisonError::into_inner)
2237            .push((level, text.into()));
2238    }
2239
2240    /// 取走待灌的告警(render 每帧一次)。
2241    pub(crate) fn take_alerts(&self) -> Vec<(crate::toast::AlertLevel, String)> {
2242        let mut q = self
2243            .pending_alerts
2244            .lock()
2245            .unwrap_or_else(std::sync::PoisonError::into_inner);
2246        std::mem::take(&mut *q)
2247    }
2248
2249    /// 常驻 toast 队列。
2250    pub(crate) fn toasts(&self) -> std::sync::MutexGuard<'_, Option<egui_notify::Toasts>> {
2251        self.toasts.lock()
2252    }
2253
2254    /// 去重:这条告警第一次出现返回 true(同一键不再重复弹)。
2255    pub fn alert_once(&self, key: impl Into<String>) -> bool {
2256        let key = key.into();
2257        self.alerted
2258            .lock()
2259            .unwrap_or_else(std::sync::PoisonError::into_inner)
2260            .insert(key)
2261    }
2262
2263    /// 待灌进 toast 的告警条数(状态层判据用;灌进队列由渲染帧收口)。
2264    #[must_use]
2265    pub fn pending_alert_count(&self) -> usize {
2266        self.pending_alerts
2267            .lock()
2268            .unwrap_or_else(std::sync::PoisonError::into_inner)
2269            .len()
2270    }
2271
2272    /// 已弹过的告警键数(去重账本;判据用它核「同一状态不重复弹」)。
2273    #[must_use]
2274    pub fn alerted_count(&self) -> usize {
2275        self.alerted
2276            .lock()
2277            .unwrap_or_else(std::sync::PoisonError::into_inner)
2278            .len()
2279    }
2280
2281    /// 退出确认窗:开 / 关 / 是否已确认放行(0.3.1 BD-UI-49)。
2282    pub fn exit_confirm_open(&self) -> bool {
2283        self.exit_confirm_open
2284            .load(std::sync::atomic::Ordering::Relaxed)
2285    }
2286
2287    pub fn open_exit_confirm(&self) {
2288        self.exit_confirm_open
2289            .store(true, std::sync::atomic::Ordering::Relaxed);
2290    }
2291
2292    pub fn close_exit_confirm(&self) {
2293        self.exit_confirm_open
2294            .store(false, std::sync::atomic::Ordering::Relaxed);
2295    }
2296
2297    pub fn exit_confirmed(&self) -> bool {
2298        self.exit_confirmed
2299            .load(std::sync::atomic::Ordering::Relaxed)
2300    }
2301
2302    pub fn confirm_exit(&self) {
2303        self.exit_confirmed
2304            .store(true, std::sync::atomic::Ordering::Relaxed);
2305    }
2306
2307    pub fn is_stop(&self) -> bool {
2308        self.stop.load(Ordering::Acquire)
2309    }
2310
2311    // ---- svctl 操作面(S2) ----
2312
2313    /// 入一条操作指令(窗口面离场动作唯一通道;左位不直调)。
2314    pub fn push_cmd(&self, cmd: UiCmd) {
2315        self.cmds.lock().unwrap().push_back(cmd);
2316        self.bump();
2317    }
2318
2319    /// 取走全部指令(0.5 M5:命令线程辞行)。
2320    pub fn drain_cmds(&self) -> Vec<UiCmd> {
2321        self.cmds.lock().unwrap().drain(..).collect()
2322    }
2323
2324    /// 队列里是否有待执行指令(命令线程的「有没有活」判据,不消费)。
2325    #[must_use]
2326    pub fn cmd_pending(&self) -> bool {
2327        !self.cmds.lock().unwrap().is_empty()
2328    }
2329
2330    /// 当前待二次确认目标(Some = 二次确认中)。
2331    pub fn pending_confirm(&self) -> Option<String> {
2332        self.pending_confirm.lock().unwrap().clone()
2333    }
2334
2335    /// 确认步进:同名二点 = 发令(Fired),异名 = 迁移(Switched),首点 = 进入待确认。
2336    pub fn confirm_step(&self, name: &str) -> ConfirmStep {
2337        let mut g = self.pending_confirm.lock().unwrap();
2338        match g.take() {
2339            Some(p) if p == name => ConfirmStep::Fired,
2340            Some(_) => {
2341                *g = Some(name.to_string());
2342                ConfirmStep::Switched
2343            }
2344            None => {
2345                *g = Some(name.to_string());
2346                ConfirmStep::Armed
2347            }
2348        }
2349    }
2350
2351    /// 清除待确认(取消钮)。
2352    pub fn confirm_cancel(&self) {
2353        *self.pending_confirm.lock().unwrap() = None;
2354        self.bump();
2355    }
2356
2357    // ---- S4 行情面 ----
2358
2359    /// 行情库直读(读写两用:worker 写,渲染读)。
2360    pub fn market(&self) -> &MarketStore {
2361        &self.market
2362    }
2363
2364    // ---- S5 交易面 ----
2365
2366    /// 全量播种四本账(**测试夹具的入口**;0.5 起生产侧不再走这一条 —— 启动一次四问
2367    /// 走 `note_*` 合并式播种,见 `plugin.rs::reconcile_trade`)。
2368    ///
2369    /// 与 `note_*` 的分工:这一条是「把已有的一份账交给界面」(先清后灌),`note_*` 是
2370    /// 「一批新事件进来」。行序按入参**反序**赋 seq,正是为了让重建后的 `trade_snap()`
2371    /// 与喂进来的顺序一致 —— 播种不是「一批新事件」,只有新事件才走「在前」。
2372    pub fn seed_trade_snap(&self, ts: TradeSnap) {
2373        {
2374            let mut book = self.trade_orders.lock().unwrap();
2375            book.clear();
2376            for o in ts.orders.into_iter().rev() {
2377                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2378                book.insert(o.en_orderid(), (seq, o));
2379            }
2380        }
2381        {
2382            let mut book = self.trade_trades.lock().unwrap();
2383            book.clear();
2384            let mut ids = self.trade_ids.lock().unwrap();
2385            ids.clear();
2386            for t in ts.trades.into_iter().rev() {
2387                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2388                ids.insert(t.en_tradeid());
2389                book.push_front((seq, t));
2390            }
2391        }
2392        {
2393            let mut book = self.trade_positions.lock().unwrap();
2394            book.clear();
2395            for p in ts.positions.into_iter().rev() {
2396                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2397                book.insert(p.en_positionid(), (seq, p));
2398            }
2399        }
2400        {
2401            let mut book = self.trade_funds.lock().unwrap();
2402            book.clear();
2403            for a in ts.accounts.into_iter().rev() {
2404                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2405                book.insert(a.en_accountid(), (seq, a));
2406            }
2407        }
2408        self.trade_dirty.store(true, Ordering::Release);
2409        self.bump();
2410    }
2411
2412    /// 一笔委托入账(基线 OrderMonitor:有 `en_orderid` 就更新可变列,否则新行)。
2413    ///
2414    /// 可变列口径照基线 `widget.py:1341-1357` 的 `update` 标记:随回报走的是
2415    /// `name / traded / traded_price / status / status_msg / datetime`;`symbol /
2416    /// exchange / type / direction / offset / price / volume / orderid / reference /
2417    /// gateway_name` 插入后不再被后续回报改写 —— CTP 的后续回报里这些字段可能给空,
2418    /// 整行覆盖会把「总量」写成 0(`BaseCell.set_content` 的 `if self.is_update` 挡的就是这个)。
2419    pub fn note_order(&self, o: &ek_data::order::OrderData) {
2420        let key = o.en_orderid();
2421        {
2422            let mut book = self.trade_orders.lock().unwrap();
2423            match book.get_mut(&key) {
2424                Some((_, cur)) => {
2425                    cur.name.clone_from(&o.name);
2426                    cur.traded = o.traded;
2427                    cur.traded_price = o.traded_price;
2428                    cur.status = o.status;
2429                    cur.status_msg.clone_from(&o.status_msg);
2430                    cur.datetime = o.datetime;
2431                }
2432                None => {
2433                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2434                    book.insert(key, (seq, o.clone()));
2435                }
2436            }
2437        }
2438        self.trade_dirty.store(true, Ordering::Release);
2439        self.bump();
2440    }
2441
2442    /// 一笔成交入账(0.5 裁定:按 `en_tradeid` 去重,新成交在前)。
2443    ///
2444    /// 基线的两层口径在这里合成一条:表格层 `TradeMonitor.data_key = ""`(:1267,只插不改、
2445    /// 不按键更新),引擎层 `trades_filter_dict`(`engine.py:1072-1080`,同一笔成交只算一次,
2446    /// 注释写明防的是账户重连时柜台重推)。我们的成交表就是用户看到的那本账,而它现在有两个
2447    /// 事件源(网关推送 + 启动 / 上线播种),所以引擎层那一条必须落到这里,否则同一笔会出两行
2448    /// —— 实连演示里量出来了:摘账户再重加之后,元账号 3 行成交变 4 行、纯 TD 账号 3 行变 6 行。
2449    pub fn note_trade(&self, t: &ek_data::trade::TradeData) {
2450        let id = t.en_tradeid();
2451        if self.trade_ids.lock().unwrap().contains(&id) {
2452            return;
2453        }
2454        let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2455        let dropped = {
2456            let mut book = self.trade_trades.lock().unwrap();
2457            book.push_front((seq, t.clone()));
2458            self.trade_ids.lock().unwrap().insert(id);
2459            let over = book.len().saturating_sub(TRADE_CAP);
2460            for _ in 0..over {
2461                // 挤掉最旧一行同时把它出集:那笔之后再推,还能上屏(去重集不许比账本大)。
2462                if let Some((_, old)) = book.pop_back() {
2463                    self.trade_ids.lock().unwrap().remove(&old.en_tradeid());
2464                }
2465            }
2466            over
2467        };
2468        if dropped > 0 {
2469            let total = self
2470                .trade_dropped
2471                .fetch_add(dropped as u64, Ordering::AcqRel)
2472                + dropped as u64;
2473            // 只在第一次触顶弹一次:触顶之后每笔都丢,弹下去就是刷屏。
2474            if total == dropped as u64 {
2475                self.push_alert(
2476                    crate::toast::AlertLevel::Warning,
2477                    format!("成交表已达 {TRADE_CAP} 行上限:更早的成交不再显示"),
2478                );
2479            }
2480        }
2481        self.trade_dirty.store(true, Ordering::Release);
2482        self.bump();
2483    }
2484
2485    /// 一笔持仓入账(基线 PositionMonitor:键 `en_positionid`,可变列照 :1480-1497)。
2486    ///
2487    /// 「平仓后残留」由渲染侧处理(基线 :1514-1517 的 `volume == 0 → hideRow`):
2488    /// 账本留着这一行、把 volume 写成 0,不删行。
2489    pub fn note_position(&self, p: &ek_data::position::PositionData) {
2490        let key = p.en_positionid();
2491        {
2492            let mut book = self.trade_positions.lock().unwrap();
2493            match book.get_mut(&key) {
2494                Some((_, cur)) => {
2495                    cur.volume = p.volume;
2496                    cur.yd_volume = p.yd_volume;
2497                    cur.can_use_volume = p.can_use_volume;
2498                    cur.frozen = p.frozen;
2499                    cur.on_road_volume = p.on_road_volume;
2500                    cur.market_value = p.market_value;
2501                    cur.avg_price = p.avg_price;
2502                    cur.price = p.price;
2503                    cur.last_price = p.last_price;
2504                    cur.pnl = p.pnl;
2505                    cur.profit_rate = p.profit_rate;
2506                    cur.name.clone_from(&p.name);
2507                }
2508                None => {
2509                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2510                    book.insert(key, (seq, p.clone()));
2511                }
2512            }
2513        }
2514        self.trade_dirty.store(true, Ordering::Release);
2515        self.bump();
2516    }
2517
2518    /// 一笔资金入账(基线 AccountMonitor:键 `en_accountid`,可变列 balance / frozen)。
2519    pub fn note_funds(&self, a: &ek_data::account::AccountData) {
2520        let key = a.en_accountid();
2521        {
2522            let mut book = self.trade_funds.lock().unwrap();
2523            match book.get_mut(&key) {
2524                Some((_, cur)) => {
2525                    cur.balance = a.balance;
2526                    cur.frozen = a.frozen;
2527                }
2528                None => {
2529                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
2530                    book.insert(key, (seq, a.clone()));
2531                }
2532            }
2533        }
2534        self.trade_dirty.store(true, Ordering::Release);
2535        self.bump();
2536    }
2537
2538    /// 某网关的行全部作废(0.5 M1:摘除 / 下线后不留旧委托旧仓)。返回清掉的行数。
2539    pub fn clear_gateway_rows(&self, gateway: &str) -> usize {
2540        let mut removed = 0usize;
2541        {
2542            let mut book = self.trade_orders.lock().unwrap();
2543            let before = book.len();
2544            book.retain(|_, (_, o)| o.gateway_name != gateway);
2545            removed += before - book.len();
2546        }
2547        {
2548            let mut book = self.trade_trades.lock().unwrap();
2549            let before = book.len();
2550            book.retain(|(_, t)| t.gateway_name != gateway);
2551            removed += before - book.len();
2552            // 去重集跟着账本清:这个网关之后再推同一笔成交,还能上屏。
2553            // 前缀带点(`en_tradeid` = `{网关}.{成交号}`)→ 名字互为前缀的两个网关不会误清。
2554            let prefix = format!("{gateway}.");
2555            self.trade_ids
2556                .lock()
2557                .unwrap()
2558                .retain(|id| !id.starts_with(&prefix));
2559        }
2560        {
2561            let mut book = self.trade_positions.lock().unwrap();
2562            let before = book.len();
2563            book.retain(|_, (_, p)| p.gateway_name != gateway);
2564            removed += before - book.len();
2565        }
2566        {
2567            let mut book = self.trade_funds.lock().unwrap();
2568            let before = book.len();
2569            book.retain(|_, (_, a)| a.gateway_name != gateway);
2570            removed += before - book.len();
2571        }
2572        if removed > 0 {
2573            self.trade_dirty.store(true, Ordering::Release);
2574            self.bump();
2575        }
2576        removed
2577    }
2578
2579    /// 成交账本触顶丢弃计数(0.5 偏离③;0 = 从没丢过)。
2580    #[must_use]
2581    pub fn trade_dropped(&self) -> u64 {
2582        self.trade_dropped.load(Ordering::Acquire)
2583    }
2584
2585    /// 交易监控快照读(渲染):四本账的缓存视图,脏时按帧重建一次。
2586    ///
2587    /// 脏标记由入账置真、这里消费 —— 回报潮里一百条事件也只重建一次(每帧一次),
2588    /// 不是每条事件一次。锁序:先缓存后账本;入账侧只持账本锁,不反向。
2589    pub fn trade_snap(&self) -> Arc<TradeSnap> {
2590        let mut cache = self.trade_snap.lock().unwrap();
2591        if self.trade_dirty.swap(false, Ordering::AcqRel) {
2592            *cache = Arc::new(self.build_trade_snap());
2593        }
2594        Arc::clone(&cache)
2595    }
2596
2597    /// 四本账 → 快照。行序 = seq 降序(基线 `insertRow(0)` 的新事件在前),
2598    /// 同 seq 用键升序兜底,保证可断言。
2599    fn build_trade_snap(&self) -> TradeSnap {
2600        fn by_seq<T>(rows: &BTreeMap<String, (u64, T)>) -> Vec<T>
2601        where
2602            T: Clone,
2603        {
2604            let mut v: Vec<(u64, &String, &T)> =
2605                rows.iter().map(|(k, (s, v))| (*s, k, v)).collect();
2606            v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
2607            v.into_iter().map(|(_, _, v)| v.clone()).collect()
2608        }
2609        TradeSnap {
2610            orders: by_seq(&self.trade_orders.lock().unwrap()),
2611            trades: self
2612                .trade_trades
2613                .lock()
2614                .unwrap()
2615                .iter()
2616                .map(|(_, t)| t.clone())
2617                .collect(),
2618            positions: by_seq(&self.trade_positions.lock().unwrap()),
2619            accounts: by_seq(&self.trade_funds.lock().unwrap()),
2620        }
2621    }
2622
2623    // ---- S6 CTP 配置面 ----
2624
2625    /// CTP 配置表单读(渲染克隆面)。
2626    pub fn ctp_form(&self) -> CtpForm {
2627        self.ctp_form.lock().unwrap().clone()
2628    }
2629
2630    /// CTP 配置表单写。
2631    pub fn set_ctp_form(&self, f: CtpForm) {
2632        *self.ctp_form.lock().unwrap() = f;
2633        self.bump();
2634    }
2635
2636    // ---- BD-UI-31 表格行选中面 ----
2637
2638    /// 该行是否为所属表的选中行(渲染判断;无键 = 未选)。
2639    pub fn row_selected(&self, table: &str, key: &str) -> bool {
2640        self.row_sel
2641            .lock()
2642            .unwrap()
2643            .get(table)
2644            .is_some_and(|k| k == key)
2645    }
2646
2647    /// 设所属表的选中行(每表一行;同键重复点 = 保持)。
2648    pub fn select_row(&self, table: &str, key: &str) {
2649        self.row_sel
2650            .lock()
2651            .unwrap()
2652            .insert(table.to_string(), key.to_string());
2653    }
2654}