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, AtomicU8, 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/// 日志环形容量(A/D 裁定 2026-10-28:E15 实证 512 被一次 506 行的状态洪水
15/// 灌满 → 启动链日志被挤出,T5 假红。显示窗抬到 4096(与 K线格同哲学:
16/// 够看 + 有界);**全量事实由溢出落盘兜住**([`LogRing`] spilling 面,
17/// 每所一笔一行,追加写,永不回环 —— 见 `LogRing::push` 的 spill 分支)。
18pub const RING_CAP: usize = 4096;
19
20/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
21/// 0.2.27 起从 render 侧提到 state(原 `render.rs` 那份随之删除):日志时间戳
22/// 在入环时就要打,而打戳与表格时间列(`fmt_dt`)必须是同一个口径,
23/// 不能一边 +08 一边本机时区。
24pub(crate) fn beijing_tz() -> chrono::FixedOffset {
25    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
26}
27
28/// 一行日志(BD-UI-41:Python 侧 LogMonitor 是带「时间/信息/接口」表头的表格
29/// (`widget.py:1167-1180`),我们原来只存正文串、渲染侧没有时间可排)。
30/// 时间戳在入环那一刻打,北京墙钟 `%H:%M:%S`(与 `fmt_dt` 同口径)。
31#[derive(Debug, Clone)]
32pub struct LogEntry {
33    /// 北京墙钟 `HH:MM:SS`。
34    pub at: String,
35    /// 正文。
36    pub text: String,
37}
38
39/// 环形日志:push 满则挤掉最旧一条,溢出计数单调(页面显示"已丢弃 N 条")。
40/// spilling 面(A/D 裁定):**进环的每一笔都追加写盘**(`%APPDATA%\ek\logs\`
41/// 按天文件;`EK_UI_LOG_DIR` 可重定向)—— 显示有界,事实全量,回去翻盘不翻环。
42#[derive(Debug)]
43pub struct LogRing {
44    lines: VecDeque<LogEntry>,
45    cap: usize,
46    spilled: u64,
47    spill_dir: Option<std::path::PathBuf>,
48}
49
50/// 缺省落盘目录(可用 `EK_UI_LOG_DIR` 重定向;测试用例自设临时目录)。
51pub(crate) fn default_spill_dir() -> std::path::PathBuf {
52    if let Ok(d) = std::env::var("EK_UI_LOG_DIR")
53        && !d.trim().is_empty()
54    {
55        return std::path::PathBuf::from(d);
56    }
57    std::env::var("APPDATA")
58        .map(|r| std::path::PathBuf::from(r).join("ek").join("logs"))
59        .unwrap_or_else(|_| std::path::PathBuf::from(".ek-logs"))
60}
61
62impl Default for LogRing {
63    fn default() -> Self {
64        Self::new_spilling(RING_CAP, default_spill_dir())
65    }
66}
67
68impl LogRing {
69    /// 定容环形(纯显示,不落盘;测试可传小容量逼溢出)。
70    pub fn new(cap: usize) -> Self {
71        Self {
72            lines: VecDeque::with_capacity(cap),
73            cap,
74            spilled: 0,
75            spill_dir: None,
76        }
77    }
78
79    /// 定容环形 + **全量落盘**(A/D 裁定):进环事件每一笔都追加写
80    /// `{dir}\ekapp-{YYYYMMDD}.log`(北京墙钟打戳;写失败静默降级 ——
81    /// 磁盘/权限问题不得影响界面,计数面照旧有 `spilled`)。
82    pub fn new_spilling(cap: usize, dir: std::path::PathBuf) -> Self {
83        Self {
84            lines: VecDeque::with_capacity(cap),
85            cap,
86            spilled: 0,
87            spill_dir: Some(dir),
88        }
89    }
90
91    /// 落盘写(每笔一行:`{at} | {text}`;失败忽略)。
92    fn spill_write(&self, entry: &LogEntry) {
93        let Some(dir) = self.spill_dir.as_ref() else {
94            return;
95        };
96        let day = chrono::Local::now().format("%Y%m%d");
97        let path = dir.join(format!("ekapp-{day}.log"));
98        let _ = std::fs::create_dir_all(dir);
99        if let Ok(mut f) = std::fs::OpenOptions::new()
100            .append(true)
101            .create(true)
102            .open(&path)
103        {
104            use std::io::Write as _;
105            let _ = writeln!(f, "{} | {}", entry.at, entry.text);
106        }
107    }
108
109    /// 追加一行;满则挤旧 + spilled+1;spilling 环下每笔同步写盘。
110    pub fn push(&mut self, line: impl Into<String>) {
111        let at = chrono::Utc::now()
112            .with_timezone(&beijing_tz())
113            .format("%H:%M:%S")
114            .to_string();
115        let entry = LogEntry {
116            at,
117            text: line.into(),
118        };
119        if self.spill_dir.is_some() {
120            self.spill_write(&entry);
121        }
122        if self.cap == 0 {
123            self.spilled += 1;
124            return;
125        }
126        if self.lines.len() == self.cap {
127            self.lines.pop_front();
128            self.spilled += 1;
129        }
130        self.lines.push_back(entry);
131    }
132
133    /// 当前长度。
134    pub fn len(&self) -> usize {
135        self.lines.len()
136    }
137
138    /// 是否空行。
139    pub fn is_empty(&self) -> bool {
140        self.lines.is_empty()
141    }
142
143    /// 累计已丢弃条数(只有溢出才涨)。
144    pub fn spilled(&self) -> u64 {
145        self.spilled
146    }
147
148    /// 只读快照(正文;测试与巡回取证用它,不关心时间)。
149    pub fn snapshot(&self) -> Vec<String> {
150        self.lines.iter().map(|e| e.text.clone()).collect()
151    }
152
153    /// 带时间戳的快照(渲染侧表格用:时间列 + 正文列)。
154    pub fn entries(&self) -> Vec<LogEntry> {
155        self.lines.iter().cloned().collect()
156    }
157
158    /// 全清;spilled 保留(历史溢出事实不抹)。
159    pub fn clear(&mut self) {
160        self.lines.clear();
161    }
162}
163
164/// 一次 eTimer 拉取的服务/插件快照(渲染直接可用)。
165#[derive(Debug, Clone, Default)]
166pub struct BusSnap {
167    /// svctl.list 全行(svctl 独裁班;状态原文)。
168    pub rows: Vec<PlugRow>,
169    /// 逐插件服务目录(id 清单;无服务为空)。
170    pub services: Vec<(String, Vec<String>)>,
171}
172
173/// UI 操作指令(窗口面 → 总线面;eTimer 节拍被 ui 插件请辞)。
174#[derive(Debug, Clone, PartialEq)]
175pub enum UiCmd {
176    /// svctl.start(工厂名 + 实例注册名;`name` 空 = 旧行为,工厂名即注册名)。
177    /// 多账号面:`factory="ctp"` + `name=ctp_<broker>_<账号>` 建第 N 个网关实例。
178    Start { factory: String, name: String },
179    /// svctl.remove(插件名)。
180    Remove { name: String },
181    /// dm.reload 回灌(S4;push=true 逐条广播,行情面板实时见 Rows)。
182    Reload {
183        interval: i64,
184        symbol: String,
185        bars: Vec<BarData>,
186    },
187    /// 下单(S5;网关受理即 SUBMITTING 单上总线)。
188    /// S6 起网关可选(`gateway.send_order`,空 = ctp)。
189    /// 0.1.1(BD-UI-30):改走 `oms.submit_order` 五段管线(拆单/风控/逐腿
190    /// 发送/记账,parent 聚合);`mode` = 拆单四态(勾选「自动开平」= Auto,
191    /// 取消勾选 = None 原样旁路)。
192    SendOrder {
193        req: ek_data::order_request::OrderRequest,
194        gateway: String,
195        mode: ek_oms::LockMode,
196    },
197    /// 撤单(S5;携三段号所需的 orderid;S6 起网关同选)。
198    /// 0.1.1(BD-UI-30):改走 `oms.cancel_order`(守卫 4000 不可绕过;
199    /// symbol/exchange 由 oms 订单缓存解,不再随令携带)。
200    CancelOrder { orderid: String, gateway: String },
201    /// `{gw}.configure` 账户配置(多账号面:网关名随令携带,不再硬编码 `ctp`;
202    /// 出队时 `to_configure_req` 抬成线上契约)。
203    /// 0.2.18 起界面侧不再用它装配账户(装配走 `Connect`);保留作低层口与测试面。
204    CtpConfigure { gateway: String, form: CtpForm },
205    /// **四态分离的「连接」(0.2.18,规划裁定:添加 ≠ 连接)**:
206    /// 一次做完 `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
207    /// (`Assembling → Assembled / AssembleFailed`)。界面『连接』『重试』钮走这一条,
208    /// 『添加账户』只写清册(不再一键装配 → 失败不会留下"注定被拒"的半死行)。
209    Connect { gateway: String, form: CtpForm },
210    /// `{gw}.subscribe` 订阅行情(多账号面:一律路由到**行情元账号**,
211    /// 从账号没有 MD 会话,发给它只会静默成功 —— 见规划 §5.C / §9.4-21)。
212    CtpSubscribe {
213        gateway: String,
214        symbols: Vec<String>,
215    },
216    /// **`{gw}.disconnect` 停会话(0.2.19,规划裁定 1:断开 ≠ 移除)**:
217    /// 网关实例、账号配置、订阅集、状态计数全保留,『连接/重试』可恢复(重登自动重订阅)。
218    /// 『移除』走的 `UiCmd::Remove`(svctl.remove)才是停实例 —— 两个动作不得混成一个。
219    Disconnect { gateway: String },
220    /// 配置托底:段目录刷新(`cfg.list`;0.6.0 环节8,BD-CFG-17 零值面)。
221    CfgList,
222    /// 配置托底:清段(`cfg.delete`,`ns` + 空整段语义;运维窗操作,与文件回缺省)
223    CfgDelete { ns: String },
224    /// 配置托底:重存段(`cfg.save`;从明面重读 → serialize → store,
225    /// **值不变** —— 修 rev/格式,持久挂久节奏验证面;0.6.0 环节8)。
226    CfgResave { ns: String },
227
228    // ---------------- 策略交易(0.9.47;统一「一插件多实例」表) ----------------
229    /// 添加策略(弹窗提交)。
230    StratAdd {
231        /// 策略类名。
232        class_name: String,
233        /// 实例名。
234        name: String,
235        /// 参数(键 → 编码串)。
236        params: Vec<(String, String)>,
237    },
238    /// 初始化。
239    StratInit { name: String },
240    /// 启动。
241    StratStart { name: String },
242    /// 暂停。
243    StratPause { name: String },
244    /// 恢复(暂停后继续)。
245    StratResume { name: String },
246    /// 停止(内部先全撤)。
247    StratStop { name: String },
248    /// 改参数(仅非运行/非暂停)。
249    StratEdit {
250        /// 实例名。
251        name: String,
252        /// 新参数(键 → 编码串)。
253        params: Vec<(String, String)>,
254    },
255    /// 移除。
256    StratRemove { name: String },
257    /// 全部启动(只启已初始化的)。
258    StratStartAll,
259    /// 全部停止。
260    StratStopAll,
261    /// 重推一次全量快照(界面表异常时的兜底)。
262    StratResync,
263    /// 查一次当前参数值(编辑弹窗打开时)。
264    StratSettings { name: String },
265    /// 查一次最新价(弹窗预填)。
266    StratPrice { symbol: String },
267    /// 补一次类元数据:**仅当缓存为空**才真的 ask(s32a/s32b)。
268    StratMetaSync,
269    /// 设默认交易/行情网关(设置面提交;空串 = 自动解析)。
270    StratSetEngine {
271        /// 交易网关。
272        trade_gateway: String,
273        /// 行情网关。
274        md_gateway: String,
275    },
276}
277
278/// 策略命令的回执(`add` / `edit` 这类"窗内要看到结果"的命令用这一条回带)。
279///
280/// 纪律:命令线程 ask 完把回执放进 [`UiShared`] 的单槽,渲染线程**取走即清** ——
281/// 窗内不 ask、也不轮询,只读这一格。
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct StratFeedback {
284    /// 动作:`"add"` / `"edit"`。
285    pub op: &'static str,
286    /// 目标实例名。
287    pub name: String,
288    /// 引擎是否受理。
289    pub ok: bool,
290    /// 中文消息(失败必非空)。
291    pub msg: String,
292}
293
294/// 行情缓存单格容量(防涨;丢头保尾,bar 流单调)。
295pub const KLINE_CELL_CAP: usize = 4096;
296
297/// 账户软上限(0.2.18 落地既有开放项:达上限『添加账户』入口禁用并提示,
298/// 界面侧不再无限长行)。
299/// 账户数**防御性顶**(0.9.10 用户裁定「我们从来没有限制过账户数」):
300/// 业务侧无限制,这条只挡荒谬状态(误操作连击/异常脚本),顶到 64 = 事实上摸不到。
301pub const MAX_ACCOUNTS: usize = 64;
302
303/// 「查询断供」判据之一:资金/持仓查询**连续**被拒达此数(规划 Q10)。
304///
305/// 阈值住在界面,不住在网关:ek-ctp 只给 `td_query_reject_streak` 这个原料,
306/// 「连续几次算断供」是界面口径(它才知道自己多久刷一次)。
307pub const QUERY_REJECT_STREAK_LIMIT: u32 = 3;
308
309/// 「查询断供」判据之二:距最后一次**资金回报**超过这么多秒(规划 Q10)。
310///
311/// 阈值对照的是 ek-ctp 0.9.8 的轮询密度:1 秒闸 + 秒针抖动 → 资金与持仓每路 2~4 秒一次,
312/// 30 秒 = 最坏也有 7 次机会没见到资金,足够避开「刚登录还没轮到」与「合约阶段内本来不问
313/// 资金」的正常空窗,又不至于把 F2 那种断供拖很久才显形。
314pub const ACCOUNT_STALE_SECS: i64 = 30;
315
316/// 链状态行的前缀(0.5.0,用户定「链进行的状态改为事件日志推送」)—— **单源:引用网关那一份**。
317///
318/// 前缀的唯一出处是 ek-ctp 0.9.8 的 `ek_ctp::driver::CHAIN_STATE_PREFIX`(驱动落每一条 `[链] `
319/// 状态行、插件落会话复位行,用的都是它)。本 crate 0.5.0 抬 pin 到 0.9.8,就不再自己抄一份 ——
320/// 抄一份 = 网关改前缀而界面静默不认。测试钉 `u4i` 读的是这个再导出。
321pub use ek_ctp::driver::CHAIN_STATE_PREFIX;
322
323/// 管制面快照(`svctl.list`)轮询节拍(0.5 M3:事件驱动后唯一保留的拍级轮询)。
324///
325/// 4 拍 ≈ 4 秒(第一拍必问,界面刚起来不该空着):这一口给的是「谁在场 + 服务目录 +
326/// 邮箱溢出计数」,不是交易数据。0.4 之前它每拍一次,是因为交易四表搭在同一拍上;
327/// 四表改吃推送后,它降到自己那一档。
328pub const LIST_POLL_TICKS: u32 = 4;
329
330/// `{gw}.status` 每拍最多问几个账户(0.5 M3:状态轮询摊平)。
331///
332/// 摊平而不是降拍:8 个账户同拍全问,单拍上限就是 8 × 2s;每拍只问 2 个,单拍上限
333/// 就是 4s,而 8 个账户 4 拍走完一轮 —— 与快照节拍同档,且不随账户数线性长。
334pub const STATUS_ASKS_PER_TICK: usize = 2;
335
336/// 成交账本上限(0.5 偏离基线③,用户裁定接)。
337///
338/// 基线的成交表只插不删、无上限(`widget.py:1242/1267` 的 `data_key = ""`);长跑一天
339/// CTP 成交可上万行,而渲染每帧要把它们喂进表。触顶丢最旧,并在第一次触顶时弹一次告警。
340pub const TRADE_CAP: usize = 5000;
341
342/// 水位阈下限(0.7.0 背压刀):积压越过软顶的 1/16 且不少于此数 → 黄。
343pub const PENDING_WARN_FLOOR: u64 = 4_096;
344/// 水位阈下限(0.7.0 背压刀):积压越过软顶的 1/4 且不少于此数 → 红。
345pub const PENDING_CRIT_FLOOR: u64 = 32_768;
346
347/// 某插件的水位阈(0.7.0:**吃这一行自己的软顶**,比例 + 下限)。
348///
349/// 0.5 M6 时代界面自带一张「谁覆写了容量」的登记表(`mailbox_cap_of`)——
350/// ek-station 0.4.0 起 `PlugRow.soft_cap` 把这条事实随快照带到界面上,
351/// 登记表就是第二份事实源(两处会飘),**退役**。
352/// 阈值口径:比例随软顶缩放(1/16 黄、1/4 红),下限取旧口径的定数
353/// (4096 / 32768),避免小软顶的插件刚起步就满屏红。
354#[must_use]
355pub fn pending_thresholds(soft_cap: u64) -> (u64, u64) {
356    let warn = (soft_cap / 16).max(PENDING_WARN_FLOOR);
357    let crit = (soft_cap / 4).max(PENDING_CRIT_FLOOR);
358    (warn.min(soft_cap.max(1)), crit.min(soft_cap.max(1)))
359}
360
361/// 二次确认步进:Armed = 首点待确认;Fired = 同名二点已发令;Switched = 目标迁移。
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum ConfirmStep {
364    Armed,
365    Fired,
366    Switched,
367}
368
369/// 隔离态判定(svctl.list 状态原文含 Quarantin → 红;ePlugQDown.<名> 是定向
370/// 广播,未知名无法预订阅 —— 快照行扫描是唯一闭环面,BD-UI-17)。
371#[must_use]
372pub fn is_quarantined(state: &str) -> bool {
373    state.contains("Quarantin")
374}
375
376// ================= S4:行情库(eBar./eBarUpdate. 上屏地面) =================
377
378/// K线历史补数单(fen 请求形态;tq 桥消费)。
379#[derive(Debug, Clone)]
380pub struct KlineFetch {
381    pub symbol: String,
382    pub interval_secs: i64,
383}
384
385/// 单格 = (symbol, interval) → bar 升序序列(丢头保尾;datetime 单调)。
386#[derive(Debug, Default)]
387pub struct MarketStore {
388    cells: Mutex<BTreeMap<(String, i64), Vec<BarData>>>,
389    /// eBar. 帧接收计数(帧统计,刷新不丢帧观感面)。
390    frames: AtomicU64,
391    /// eBarUpdate. 接收计数。
392    updates: AtomicU64,
393    /// dm.reload 成功计数(回灌确认面)。
394    reloads: AtomicU64,
395}
396
397/// 一格的可读视图(渲染/测试用)。
398#[derive(Debug, Clone, PartialEq)]
399pub struct CellView {
400    pub symbol: String,
401    pub interval: i64,
402    pub bars: Vec<BarData>,
403}
404
405impl MarketStore {
406    /// eBar. 收帧:逐 bar 并格(同 dt 替尾;更旧丢弃;更晚追加,溢出丢头)。
407    pub fn on_frame(&self, frame: &BarsFrame) {
408        let mut cells = self.cells.lock().unwrap();
409        for b in &frame.bars {
410            let key = (b.symbol.clone(), frame.interval);
411            let cell = cells.entry(key).or_default();
412            match cell.last() {
413                Some(last) if b.datetime < last.datetime => continue,
414                Some(last) if b.datetime == last.datetime => {
415                    let slot = cell.last_mut().expect("len>1");
416                    *slot = b.clone();
417                }
418                _ => {
419                    cell.push(b.clone());
420                    if cell.len() > KLINE_CELL_CAP {
421                        cell.remove(0);
422                    }
423                }
424            }
425        }
426        self.frames.fetch_add(1, Ordering::AcqRel);
427    }
428
429    /// eBarUpdate. 收单 bar 快照(实时门;同 dt 替尾,新 dt 追加 = in-flight bar)。
430    pub fn on_update(&self, b: &BarData) {
431        let mut cells = self.cells.lock().unwrap();
432        let iv = b
433            .interval
434            .unwrap_or(ek_data::constant::Interval::MINUTE)
435            .seconds();
436        let key = (b.symbol.clone(), iv);
437        let cell = cells.entry(key).or_default();
438        match cell.last() {
439            Some(last) if b.datetime == last.datetime => {
440                let slot = cell.last_mut().expect("len>1");
441                *slot = b.clone();
442            }
443            Some(last) if b.datetime < last.datetime => {}
444            _ => {
445                cell.push(b.clone());
446                if cell.len() > KLINE_CELL_CAP {
447                    cell.remove(0);
448                }
449            }
450        }
451        self.updates.fetch_add(1, Ordering::AcqRel);
452    }
453
454    /// `{gw}.klines` 历史回包并格(I2 第一刀)。
455    ///
456    /// 与 on_frame 不同:**回填补数比现有尾巴更旧是常态**,走全格
457    /// 「升序合并 + 同 dt 去重 + 超限丢头(保最新 KLINE_CELL_CAP 根)」。
458    /// 请求方负责 error/truncated 显形;这里纯数据并格。
459    pub fn note_history(&self, symbol: &str, iv_secs: i64, bars: &[BarData]) {
460        let mut cells = self.cells.lock().unwrap();
461        let cell = cells.entry((symbol.to_string(), iv_secs)).or_default();
462        cell.extend(bars.iter().cloned());
463        cell.sort_unstable_by_key(|b| b.datetime);
464        cell.dedup_by(|a, b| a.datetime == b.datetime);
465        if cell.len() > KLINE_CELL_CAP {
466            let excess = cell.len() - KLINE_CELL_CAP;
467            cell.drain(0..excess);
468        }
469        self.frames.fetch_add(1, Ordering::AcqRel);
470    }
471
472    /// dm.reload 回灌登记(RELOAD OK 计数;dm 侧行为独立)。
473    pub fn note_reload(&self) {
474        self.reloads.fetch_add(1, Ordering::AcqRel);
475    }
476
477    /// 全格只读快照(键序 = BTreeMap 序,symbol/interval 稳定)。
478    pub fn cells(&self) -> Vec<CellView> {
479        self.cells
480            .lock()
481            .unwrap()
482            .iter()
483            .map(|((sym, iv), bars)| CellView {
484                symbol: sym.clone(),
485                interval: *iv,
486                bars: bars.clone(),
487            })
488            .collect()
489    }
490
491    /// K线窗查找键面(F1 键统一,0.9.9):窗键 = 视觉裸符号(如 `rb2605`),格键 =
492    /// **带交易所前缀**(note_history 的生命线,如 `SHFE.rb2605`)。
493    /// 查找 = 精确命中 or **同码剥前缀命中**(`X.{sym}` 收尾即认)——
494    /// 修实时病:E15 波前窗永远找不到前缀格,历史画不出。
495    pub fn find_cell(&self, sym: &str) -> Option<CellView> {
496        self.cells()
497            .into_iter()
498            .find(|c| c.symbol == sym || c.symbol.ends_with(&format!(".{sym}")))
499    }
500
501    /// 计算(Ai) 帧统计。
502    pub fn counts(&self) -> (u64, u64, u64) {
503        (
504            self.frames.load(Ordering::Acquire),
505            self.updates.load(Ordering::Acquire),
506            self.reloads.load(Ordering::Acquire),
507        )
508    }
509}
510
511/// 回灌表单(v4:界面面已退役,总线口 `UiCmd::Reload` 保留;解析器留作
512/// 测试与脚本面)。
513#[derive(Debug, Default, Clone, PartialEq)]
514pub struct ReloadForm {
515    pub interval: String,
516    pub symbol: String,
517    pub csv: String,
518}
519
520/// 票据网关选择子解析(多账号面)。
521///
522/// 0.2.15 起**不再兜底成字面量 `"ctp"`**:多账号下"空网关"必须显式落到
523/// `fallback`(= [`UiShared::default_gateway`] 的阶梯:行级带入 → 上次选择 →
524/// 第一个已连接账户);一个账户都没有时回 `Err`,界面据此禁用下单钮。
525/// 单账号旧行为仍成立(fallback 就是那个账户的派生名)。
526pub fn parse_gateway(raw: &str, fallback: Option<&str>) -> Result<String, String> {
527    let g = raw.trim();
528    if !g.is_empty() {
529        if g.chars().any(|c| c.is_whitespace()) {
530            return Err(format!("网关解析: 含空白:{raw:?}"));
531        }
532        return Ok(g.to_string());
533    }
534    match fallback.map(|f| f.trim()).filter(|f| !f.is_empty()) {
535        Some(f) => Ok(f.to_string()),
536        None => Err("网关解析: 未选择账户(先在账户面板添加账户)".to_string()),
537    }
538}
539
540/// CTP 网关配置表单(S6 测试面;界面手工输入源 → UiCmd::CtpConfigure →
541/// ask `ctp.configure`。零配置口径:界面不读文件,值全部人敲;密码位掩码)。
542#[derive(Debug, Clone, PartialEq)]
543pub struct CtpForm {
544    pub broker_id: String,
545    pub td_addr: String,
546    pub td_user: String,
547    pub td_password: String,
548    pub td_auth_code: String,
549    pub td_app_id: String,
550    pub md_addr: String,
551    pub md_user: String,
552    pub md_password: String,
553    /// 订阅标的(逗号分隔;`订阅行情` 钮解析)。
554    pub symbols: String,
555    /// 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。
556    /// 缺省只勾期货 —— 期权默认不勾,因为默认口径与改动前一致(只期货上抛)。
557    pub accept_futures: bool,
558    pub accept_options: bool,
559}
560
561/// 手写 `Default`(不能 derive:`accept_futures` 必须为 `true`,derive 会给 `false`,
562/// 而两个都 false 在语义上是「什么都不收录」)。
563impl Default for CtpForm {
564    fn default() -> Self {
565        Self {
566            broker_id: String::new(),
567            td_addr: String::new(),
568            td_user: String::new(),
569            td_password: String::new(),
570            td_auth_code: String::new(),
571            td_app_id: String::new(),
572            md_addr: String::new(),
573            md_user: String::new(),
574            md_password: String::new(),
575            symbols: String::new(),
576            accept_futures: true,
577            accept_options: false,
578        }
579    }
580}
581
582impl CtpForm {
583    /// 勾选 → CTP 产品类码表(期货 `'1'`、期权 `'2'`)。
584    /// 两个都不勾交空表给插件:插件侧归一化一律回落默认期货 —— **空不等于全丢**
585    /// (共享合约表为空会让网关的持仓查询整条被跳过)。
586    #[must_use]
587    pub fn product_classes(&self) -> Vec<String> {
588        let mut codes = Vec::new();
589        if self.accept_futures {
590            codes.push("1".to_string());
591        }
592        if self.accept_options {
593            codes.push("2".to_string());
594        }
595        codes
596    }
597
598    /// 表单 → 线上契约(字段直映;md 回落由插件侧裁决,表单不预处理)。
599    pub fn to_configure_req(&self) -> ek_ctp::plugin::ConfigureReq {
600        ek_ctp::plugin::ConfigureReq {
601            broker_id: self.broker_id.trim().to_string(),
602            td_addr: self.td_addr.trim().to_string(),
603            td_user: self.td_user.trim().to_string(),
604            td_password: self.td_password.clone().into(),
605            td_auth_code: self.td_auth_code.trim().to_string(),
606            td_app_id: self.td_app_id.trim().to_string(),
607            md_addr: self.md_addr.trim().to_string(),
608            md_user: self.md_user.trim().to_string(),
609            md_password: self.md_password.clone().into(),
610            product_classes: self.product_classes(),
611        }
612    }
613
614    /// 订阅位解析(逗号/空白分隔;空表 = Err)。
615    pub fn parse_symbols(&self) -> Result<Vec<String>, String> {
616        let list: Vec<String> = self
617            .symbols
618            .split([',', ',', ' '])
619            .map(|s| s.trim().to_string())
620            .filter(|s| !s.is_empty())
621            .collect();
622        if list.is_empty() {
623            return Err("订阅解析: 标的位空".to_string());
624        }
625        Ok(list)
626    }
627
628    /// 添加/连接前的形制校验(规划 U7:旧口径是"前置为空照样建账户并下发两令")。
629    ///
630    /// 判据:`tcp://host:port` 形制(host 非空、端口为 1..=65535 的十进制)、
631    /// broker/账号非空、AppID/AuthCode 非空(CTP 登录必需,空值只会换来一次注定失败的登录)。
632    /// **md 前置允许为空** = 纯 TD 账户(单源纪律:非行情源不建 MD 会话)。
633    ///
634    /// 返回 `Err(第一条原因)` —— 界面红字呈现,不当『添加』按钮的硬拦截前置条件之外的东西
635    /// (校验只在『添加』『连接』两条入口上跑,低层口 `add_account` 不重复校验)。
636    pub fn validate(&self) -> Result<(), String> {
637        fn front(name: &str, v: &str) -> Result<(), String> {
638            let v = v.trim();
639            let Some(rest) = v.strip_prefix("tcp://") else {
640                return Err(format!("{name} 形制应为 tcp://host:port(当前 {:?})", v));
641            };
642            let Some((host, port)) = rest.rsplit_once(':') else {
643                return Err(format!("{name} 缺端口:{name} {:?}", v));
644            };
645            if host.trim().is_empty() {
646                return Err(format!("{name} 主机位空:{}", v));
647            }
648            let port: u32 = port
649                .parse()
650                .map_err(|_| format!("{name} 端口不是数字:{}", v))?;
651            if port == 0 || port > 65535 {
652                return Err(format!("{name} 端口越界(1..=65535):{}", v));
653            }
654            Ok(())
655        }
656        if self.broker_id.trim().is_empty() {
657            return Err("broker 位空".to_string());
658        }
659        if self.td_user.trim().is_empty() {
660            return Err("交易账号位空".to_string());
661        }
662        front("td 前置", &self.td_addr)?;
663        if !self.md_addr.trim().is_empty() {
664            front("md 前置", &self.md_addr)?;
665        }
666        if self.td_app_id.trim().is_empty() {
667            return Err("AppID 位空(CTP 登录必需)".to_string());
668        }
669        if self.td_auth_code.trim().is_empty() {
670            return Err("AuthCode 位空(CTP 登录必需)".to_string());
671        }
672        Ok(())
673    }
674}
675
676/// 账户装配四态(0.2.18,规划 §3.3「添加 ≠ 连接 ≠ 断开 ≠ 移除」)。
677///
678/// 口径边界(诚实):这一层只说**实例装配**,不说登录 —— 登录成败/掉线 ek-ctp 无状态口
679/// 可查(开放项 `ctp.status`,规划裁定 3:状态机等终态口,不做 eLog 自建过渡)。
680/// 因此 `Assembled` 的界面文案必须是「已装配·登录态未知」(短形:状态列宽有限,
681/// 长文案会被 Grid 挤成竖排 —— s39 快照实锤过),不得写成「已连接」。
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
683pub enum AccountStage {
684    /// 未装配:添加后未连接、实例已摘、或装配失败后未重试。
685    #[default]
686    NotAssembled,
687    /// 装配中:`UiCmd::Connect` 已入列,svctl 还没把这个注册名带回在场表。
688    Assembling,
689    /// 实例已装配(登录态未知)。
690    Assembled,
691    /// 装配失败:`svctl.start` 被拒/超时,或 `{gw}.configure` 被拒。可重试。
692    AssembleFailed,
693}
694
695impl AccountStage {
696    /// 界面文案(状态列 / 徽标)。
697    #[must_use]
698    pub fn label(self) -> &'static str {
699        match self {
700            AccountStage::NotAssembled => "未装配",
701            AccountStage::Assembling => "装配中…",
702            // 规划 §3.5 缓解:不让「已装配」被读成「在线」。
703            // (文案长度有实测约束:账户面板是 Grid,状态列文案过长会被挤成竖排换行。)
704            AccountStage::Assembled => "已装配·登录态未知",
705            AccountStage::AssembleFailed => "装配失败",
706        }
707    }
708}
709
710/// 登录态(0.2.19;事实源 = ek-ctp 0.9.0 `{gw}.status`,规划 P3 / 裁定 3)。
711///
712/// 与 [`AccountStage`] 是**两件事**:stage 说「实例在不在」,这一档说「会话活着没有」。
713/// 状态列把两者拼起来(`AccountRow::status_label`)。
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
715pub enum LoginState {
716    /// 还没读到读数,或读到了但没有任何登录证据。
717    ///
718    /// 网关的「句柄已建」(**`td_handle_up`**)**不等于在线** —— 掉线时它仍是真
719    /// (ek-ctp DESIGN D-STATUS ①),所以没有 `logged_in` 就只有这一档,不得显示「在线」。
720    #[default]
721    Unknown,
722    /// 网关回报 `td_logged_in`(纯行情账户看 `md_logged_in`)—— 在线的唯一硬证据。
723    Online,
724    /// 最近一次登录失败(网关在成功后会清零这一位,所以它显形 = 现在就是登不上)。
725    LoginFailed,
726    /// 柜台断过线且当前未登录(`td_disconnects > 0`;计数跨重连保留)。
727    FrontDown,
728    /// 登录着但查询连续被拒 / 资金回报超时(规划 Q10 两判据,阈值在界面)。
729    QueryStarved,
730    /// 用户主动『断开』(规划裁定 1):会话已停,实例与订阅还在,『连接』可恢复。
731    Stopped,
732}
733
734impl LoginState {
735    /// 状态列短文案(账户面板是 Grid,长文案会被挤成竖排 —— s39 快照实锤过)。
736    #[must_use]
737    pub fn label(self) -> &'static str {
738        match self {
739            LoginState::Unknown => "登录态未知",
740            LoginState::Online => "在线",
741            LoginState::LoginFailed => "登录失败",
742            LoginState::FrontDown => "掉线",
743            LoginState::QueryStarved => "查询断供",
744            LoginState::Stopped => "已断开",
745        }
746    }
747
748    /// 这一档要不要在状态列上着色(红/橙)。
749    #[must_use]
750    pub fn is_alarm(self) -> bool {
751        matches!(self, LoginState::LoginFailed | LoginState::FrontDown)
752    }
753}
754
755/// 「掉线 N 次」这类细节:N = 0 回 `None` —— 没有发生过就不该占状态列的位置。
756fn count_note(label: &str, n: u32) -> Option<String> {
757    (n > 0).then(|| format!("{label} {n} 次"))
758}
759
760/// 柜台错误原句 → **短原因**(账户行状态列用)。原句形状照 ek-ctp `map::error_text`
761/// (`map.rs:332-333`)= `{消息},代码:{id},信息:{正文}`。
762///
763/// 取「信息」的正文(去掉 CTP 侧的 `CTP:` 前缀)并把代码括在后面 = `不合法的登录(3)`。
764/// 形状对不上(网关没回带原因、或不是这条格式)就回 `None` —— 状态列宁可只写「登录失败」,
765/// 也不编一个原因出来。
766fn brief_counter_reason(full: &str) -> Option<String> {
767    let tail = full.rfind("信息:").map(|i| &full[i + "信息:".len()..])?;
768    let body = tail.trim().trim_start_matches("CTP:").trim();
769    if body.is_empty() {
770        return None;
771    }
772    let code = full
773        .rfind("代码:")
774        .map(|i| {
775            let rest = &full[i + "代码:".len()..];
776            let end = rest.find(',').unwrap_or(rest.len());
777            rest[..end].trim().to_string()
778        })
779        .filter(|c| !c.is_empty() && c.parse::<i64>().is_ok());
780    Some(match code {
781        Some(c) => format!("{body}({c})"),
782        None => body.to_string(),
783    })
784}
785
786/// 账户行(多账号面;规划 §5.B + 0.2.18 四态)。
787///
788/// 界面是账户清单的**唯一事实源**(不新增"账户注册表"插件):
789/// 网关实例的在场由 `svctl.list` 回填,身份与凭据留在这一行,**每账户一份 form**
790/// (旧「全界面共享一张表单」已退役 → 消 U4 凭据串味 / U6 取消编辑不清空)。
791#[derive(Debug, Clone, PartialEq, Default)]
792pub struct AccountRow {
793    /// 派生注册名(`ctp_<broker>_<账号>`;**寻址唯一用这个**,别名不参与)。
794    pub gateway: String,
795    /// 用户别名(显示用,可空 = 回退账号;唯一性由 `add_account` 校验,规划 Q3)。
796    pub alias: String,
797    pub broker_id: String,
798    pub account: String,
799    /// 是否行情元账号(**全局单源**——用户裁定"单数据源";已装配后锁定,规划 D3)。
800    pub md_source: bool,
801    /// 该账户自己的表单留档(编辑弹窗回填用;密码只存内存,裁定 2:不持久化)。
802    pub form: CtpForm,
803    /// **档案复原标(0.9.0)**:这一行来自启动清点(`%APPDATA%\ek\cfg` 的存档段),
804    /// 不是本次会话手输的。呈现面:行上「档案」徽标 + 编辑弹窗密码位显示
805    /// 「密库引用,不回显」;行为面:「连接」= `UiCmd::Start`(存档直连),
806    /// **不走表单 configure** —— 存档的密码在密库,不在表单里,发空表单只会把
807    /// 空密码受理下去(这就是复原行必须走 svctl.start 的唯一理由)。
808    pub restored: bool,
809    /// **自动连接勾选(0.9.0)**:手点才置位 + 落盘标记;**只存 ui 段**
810    /// (账户行为面归界面档案,不进网关段 —— 2026-10-28 用户裁定「编排归
811    /// station/界面层,网关只当哑网关」的落点)。消费方:守护复原(ek-station
812    /// boot 清点 = 开则起连)与桌面启动清点(不复原 spawn,行列出后手点「连接」)。
813    pub auto_connect: bool,
814    /// **密码密库引用在位**(0.9.0;只读元信息,来自存档段的 `Field::secret`
815    /// 引用行)。呈现面:复原行密码位显示「密库引用 ✓」;它**不是**密码本身,
816    /// 也不参与任何连接判据(实例 on_start 才解密,ek-ctp BD-CFG-16)。
817    /// 手输行恒为 `false`(受理受理面另有实况档位口 `credentials_state`)。
818    pub password_ref: bool,
819    /// 装配四态(规划 §3.3)。
820    pub stage: AccountStage,
821    /// 装配失败原因(`AssembleFailed` 时留显形;成功即清空)。
822    pub fail_note: Option<String>,
823    /// **网关实际收录回带值**(`ConfigureReply.product_classes`,规划 U9)。
824    ///
825    /// 与 `form.accept_*`(用户勾选)并排显示:勾选是意图,这一栏是事实。
826    /// 空 = 尚未装配过或旧版网关未回带。
827    pub effective_classes: Vec<String>,
828    /// **入向异常计数(0.9.34 交易网关表)**:`{gw}.status` 回带的 `td_anomalies.total` /
829    /// `md_anomalies.total`(ek-ctp 0.12.0「入向零丢弃」波的显形面)。
830    ///
831    /// 语义:柜台报文里有**认不出**的交易所码 / 状态码 / 时间戳 / 方向 / 缺委托号的成交 …
832    /// 网关不丢包,一律折算成可读值并把次数记在这里;`0` = 没遇到过异常。
833    /// 与 `login` 正交:异常多不代表掉线,但**非零就要有人看**(数据可能被折算)。
834    pub td_anomaly: u32,
835    /// 行情侧入向异常计数(同上,`md_anomalies.total`)。
836    pub md_anomaly: u32,
837    /// 在场位(由 `svctl.list` 快照回填:该注册名在不在 = 实例已装配)。
838    /// 0.2.19 起登录成败另有事实源(`{gw}.status` → `login`),这一位只管实例。
839    pub connected: bool,
840    /// **登录态(0.2.19)**:`{gw}.status` 读数折出来的,与 `stage`/`connected` 正交。
841    ///
842    /// 默认 `Unknown`:装配成功本身不构成任何登录证据(ek-ctp D-STATUS ①)。
843    pub login: LoginState,
844    /// 状态细节(「掉线 2 次」「查询连续被拒 3 次」「资金回报已 45s 未更新」)。
845    /// `None` = 没有可说的细节(不是「一切正常」——一切正常由 `login` 说)。
846    pub status_note: Option<String>,
847    /// **柜台原因的短标签(0.4.1 十期 BD-UI-63)**:登录失败/掉线时从 `td_last_error` 里
848    /// 抠出「代码:N」与「信息:」的正文,给账户行的状态列用(红字后面直接跟着原因)。
849    ///
850    /// 为什么不直接把整句塞进状态列:柜台原句是「交易服务器登录失败,代码:3,信息:CTP:不
851    /// 合法的登录」这种长句,Grid 状态列被它挤宽就会把别的列顶窄(s39 那口径)。整句留在
852    /// `status_note` 里,窗下方「状态细节」那一行照旧给全。
853    pub login_brief: Option<String>,
854    /// **链状态(0.5.0,用户定「链进行的状态改为事件日志推送」)**:该网关最近一条 `[链] `
855    /// 状态行的正文(去掉前缀)。
856    ///
857    /// 事实源是 ek-ctp 0.9.8 推送的日志(前置已连接 / 授权通过 / 登录成功 / 结算单已确认 /
858    /// 查询合约 第N次 / 合约已到手开始轮询 / 合约未到手本轮已停止 / 会话已复位),不是轮询读数。
859    /// `None` = 这一条会话还没推过状态行(显示「—」)。**它只是显示**:不参与 `apply_status`
860    /// 那套在线/断供判据,也不参与任何下单判据。
861    pub chain_note: Option<String>,
862}
863
864impl AccountRow {
865    /// 派生入口(兼容 1 参 = 在场清单空;多前置判定见 `derive_with`)。
866    pub fn derive(form: &CtpForm) -> Result<Self, String> {
867        Self::derive_with(form, &[])
868    }
869
870    /// 由表单派生账户行(派生名唯一入口 = `appconfig::unique_gw_name`,0.9.10)。
871    ///
872    /// `taken` = 在场清单 `(gw, broker, 账号, td 前置)`:
873    /// - 三处全同 = `Err`(同前置不重复登记);
874    /// - 同 broker+账号 而 td 前置不同 = 自动后缀 `_2/_3…`(用户裁定「一账号多套
875    ///   前置,别名备注切换」,IP 相同端口不同也算两套)。
876    ///
877    /// 返回 `Err(原因)` = broker/账号非法、派生名超 32 字符或重复;
878    /// 界面须红字呈现,**不得静默替换**(规划 §3)。
879    pub fn derive_with(
880        form: &CtpForm,
881        taken: &[(String, String, String, String)],
882    ) -> Result<Self, String> {
883        let broker = form.broker_id.trim();
884        let account = form.td_user.trim();
885        let gateway =
886            crate::appconfig::unique_gw_name(taken, broker, account, form.td_addr.trim())?;
887        Ok(Self {
888            gateway,
889            alias: String::new(),
890            broker_id: broker.to_string(),
891            account: account.to_string(),
892            md_source: false,
893            form: form.clone(),
894            stage: AccountStage::NotAssembled,
895            fail_note: None,
896            effective_classes: Vec::new(),
897            td_anomaly: 0,
898            md_anomaly: 0,
899            connected: false,
900            login: LoginState::Unknown,
901            status_note: None,
902            login_brief: None,
903            chain_note: None,
904            // 本次会话手输/派生的行:非档案、非自动连接(0.9.0;复原行的两标
905            // 在 `restore_saved_accounts_from` 里置位)。
906            restored: false,
907            auto_connect: false,
908            password_ref: false,
909        })
910    }
911
912    /// 状态列文案(装配态 + 登录态拼一起)。
913    ///
914    /// 「已装配」后面必须跟着登录态,不能光写「已装配」——0.2.18 那条「不得读成在线」
915    /// 的口径(规划 U5)现在有了真读数才真正立住:没有证据时写「已装配·登录态未知」。
916    /// 装配段一律走 `AccountStage::label()`,不在这里重抄一遍文案(两处不同式就是坑)。
917    #[must_use]
918    pub fn status_label(&self) -> String {
919        if self.stage == AccountStage::Assembled {
920            match self.login {
921                LoginState::Unknown => "已装配·登录态未知".to_string(),
922                other => format!("已装配·{}", other.label()),
923            }
924        } else {
925            self.stage.label().to_string()
926        }
927    }
928
929    /// 把 `{gw}.status` 读数折成界面登录态 + 细节(0.2.19;规划 P3 / Q10)。
930    ///
931    /// 判据顺序写明(每条都有理由):
932    /// 1. **登录失败先判** —— 网关在登录成功后会清零 `td_login_failed`,所以它显形就是现在登不上;
933    /// 2. **登录着** → 才谈「查询断供」,三条判据:连续被拒达阈 / 距最后一次资金回报超时 /
934    ///    **登录后资金回报从未到手且已超过同一个 stale 阈值**(0.2.21,实连 S15f 逼出来的第三条);
935    ///    都没中 = 在线;
936    /// 3. **没登录** → 用户主动『断开』优先(那是自己按的钮,比柜台掉线计数更贴近用户意图),
937    ///    其次才是柜台掉线(`disconnects > 0`),最后才是「未知」;
938    /// 4. **纯行情账户**(td 前置留档为空)看 MD 半边 —— 看 TD 半边永远是「未知」,那是假信息。
939    ///
940    /// 「从未发生」= `None` 不当 0 用:网关没回报过资金时刻 ≠ 「0 秒前刚回报过」。
941    pub fn apply_status(
942        &mut self,
943        st: &ek_ctp::plugin::GatewayStatus,
944        now_ms: i64,
945        reject_limit: u32,
946        stale_secs: i64,
947    ) {
948        // 入向异常计数(0.9.34):读数口直取,不参与 login 判定 —— 只显形。
949        self.td_anomaly = st.td_anomalies.total;
950        self.md_anomaly = st.md_anomalies.total;
951        let td_side = !self.form.td_addr.trim().is_empty();
952        let (login, note) = if td_side {
953            if st.td_login_failed && !st.td_logged_in {
954                (
955                    LoginState::LoginFailed,
956                    st.td_last_error
957                        .clone()
958                        .or_else(|| Some("登录失败(网关未回带原因)".to_string())),
959                )
960            } else if st.td_logged_in {
961                if st.td_query_reject_streak >= reject_limit {
962                    (
963                        LoginState::QueryStarved,
964                        Some(format!("查询连续被拒 {} 次", st.td_query_reject_streak)),
965                    )
966                } else if let Some(last) = st.td_last_account_ms {
967                    let idle_ms = now_ms.saturating_sub(last);
968                    if idle_ms > stale_secs.saturating_mul(1_000) {
969                        (
970                            LoginState::QueryStarved,
971                            Some(format!("资金回报已 {}s 未更新", idle_ms / 1_000)),
972                        )
973                    } else {
974                        (LoginState::Online, count_note("掉线", st.td_disconnects))
975                    }
976                } else {
977                    // 登录成功但资金一次都没到手。轮转第一拍本就要 2~6 秒(ek-ctp 0.7.0 口径),
978                    // 所以刚登录那几秒只算「尚未到手」不算断供;但**"从未到手"不能永远算在线** ——
979                    // 2026-10-22 实连 S15f 实测:账户 B `logged_in=true`、查询被拒 0 次、
980                    // 资金时刻 `None` 持续 390 秒以上,界面上却一直显示「在线 + 资金回报尚未到手」。
981                    // 超过同一个 stale 阈值就翻「查询断供」:判据原料齐了(登录时刻 + 读数时刻),
982                    // 不需要网关多给任何东西。
983                    let since_login_ms = st.td_last_login_ok_ms.map(|ok| now_ms.saturating_sub(ok));
984                    match since_login_ms {
985                        Some(ms) if ms > stale_secs.saturating_mul(1_000) => (
986                            LoginState::QueryStarved,
987                            Some(format!("资金回报从未到手(登录已 {}s)", ms / 1_000)),
988                        ),
989                        _ => (LoginState::Online, Some("资金回报尚未到手".to_string())),
990                    }
991                }
992            } else if self.login == LoginState::Stopped {
993                let extra = if st.td_disconnects > 0 {
994                    format!("(此前掉线 {} 次)", st.td_disconnects)
995                } else {
996                    String::new()
997                };
998                (
999                    LoginState::Stopped,
1000                    Some(format!("会话已停{extra};『连接』可恢复")),
1001                )
1002            } else if st.td_disconnects > 0 {
1003                (
1004                    LoginState::FrontDown,
1005                    Some(format!("掉线 {} 次", st.td_disconnects)),
1006                )
1007            } else {
1008                (LoginState::Unknown, st.td_last_error.clone())
1009            }
1010        } else if st.md_logged_in {
1011            (
1012                LoginState::Online,
1013                count_note("掉线", st.md_disconnects)
1014                    .or_else(|| Some("纯行情账户(无 TD 会话)".to_string())),
1015            )
1016        } else if self.login == LoginState::Stopped {
1017            (
1018                LoginState::Stopped,
1019                Some("会话已停;『连接』可恢复".to_string()),
1020            )
1021        } else if st.md_disconnects > 0 {
1022            (
1023                LoginState::FrontDown,
1024                Some(format!("掉线 {} 次", st.md_disconnects)),
1025            )
1026        } else {
1027            (LoginState::Unknown, st.md_last_error.clone())
1028        };
1029        // 0.9.1 分项接上(随 ek-ctp 0.9.1):总数与连续串**不分路**(消费侧要的是
1030        // 「查询这条路连续被拒几次」),但用户要知道被拒的是哪一路 —— 合约一条没到手时,
1031        // 下一笔单会被 ContractGate 以 `contract:unknown` 拒掉,而状态列只写「在线」
1032        // 就是假信息。加在句尾而不是另开一行:Grid 列宽会被长文案挤成竖排(s39 实锤)。
1033        let note = if td_side && st.td_instrument_rejects > 0 {
1034            let add = format!("合约查询被拒 {} 次", st.td_instrument_rejects);
1035            match note {
1036                // 已经点名过合约查询的句子不重复加。
1037                Some(base) if base.contains("合约查询被拒") => Some(base),
1038                Some(base) => Some(format!("{base};{add}")),
1039                None => Some(add),
1040            }
1041        } else {
1042            note
1043        };
1044        self.login = login;
1045        self.status_note = note;
1046        // 告警态(登录失败 / 掉线)才配「柜台原因」短标签:在线、未知、已断开都不占状态列。
1047        // 这一位是账户行红字后面那段原因的**唯一来源**(BD-UI-63)。
1048        self.login_brief = if login.is_alarm() {
1049            st.td_last_error
1050                .as_deref()
1051                .or(st.md_last_error.as_deref())
1052                .and_then(brief_counter_reason)
1053        } else {
1054            None
1055        };
1056    }
1057
1058    /// 显示名:别名优先,空则回退账号(寻址仍只用 `gateway`,规划 Q3)。
1059    #[must_use]
1060    pub fn display_name(&self) -> String {
1061        let a = self.alias.trim();
1062        if a.is_empty() {
1063            self.account.clone()
1064        } else {
1065            a.to_string()
1066        }
1067    }
1068}
1069
1070/// 交易监控快照(渲染侧读的那一份;0.5 起它是四本 keyed 账的缓存视图,
1071/// 不再是「eTimer 拍 ask oms 全量查询组帧」的产物)。
1072#[derive(Debug, Clone, PartialEq, Default)]
1073pub struct TradeSnap {
1074    pub orders: Vec<ek_data::order::OrderData>,
1075    pub trades: Vec<ek_data::trade::TradeData>,
1076    pub positions: Vec<ek_data::position::PositionData>,
1077    /// 资金帧(oms.get_all_accounts;CTP configure 登录后有数)。
1078    pub accounts: Vec<ek_data::account::AccountData>,
1079}
1080
1081/// CSV(每行 `dt,open,high,low,close,volume`;dt 为 epoch 秒)→ BarData 批。
1082/// 错行报行号;空 csv = Err;升序不强制(dm.reload 自会逐行入窗)。
1083pub fn parse_reload_csv(form: &ReloadForm) -> Result<(i64, String, Vec<BarData>), String> {
1084    let interval: i64 = form
1085        .interval
1086        .trim()
1087        .parse()
1088        .map_err(|_| format!("回灌 解析: interval 非整数:{:?}", form.interval))?;
1089    let symbol = form.symbol.trim().to_string();
1090    if symbol.is_empty() {
1091        return Err("回灌解析: symbol 空".to_string());
1092    }
1093    if form.csv.trim().is_empty() {
1094        return Err("回灌解析: csv 空".to_string());
1095    }
1096    let mut bars = Vec::new();
1097    for (i, raw) in form.csv.lines().enumerate() {
1098        let line = raw.trim();
1099        if line.is_empty() || line.starts_with('#') {
1100            continue;
1101        }
1102        let parts: Vec<&str> = line.split(',').map(str::trim).collect();
1103        if parts.len() != 6 {
1104            return Err(format!("回灌解析: 第 {} 列数 ≠ 6", i + 1));
1105        }
1106        let num = |s: &str| -> Result<f64, String> {
1107            s.parse::<f64>()
1108                .map_err(|_| format!("回灌解析: 第 {} 列数值坏:{:?}", i + 1, s))
1109        };
1110        let dt_s: i64 = parts[0]
1111            .parse()
1112            .map_err(|_| format!("回灌解析: 第 {} 列 dt 坏:{:?}", i + 1, parts[0]))?;
1113        bars.push(BarData {
1114            symbol: symbol.clone(),
1115            datetime: dt_s * 1_000_000_000,
1116            interval: Some(
1117                ek_dm::wire::interval_of_seconds(interval)
1118                    .unwrap_or(ek_data::constant::Interval::MINUTE),
1119            ),
1120            open_price: num(parts[1])?,
1121            high_price: num(parts[2])?,
1122            low_price: num(parts[3])?,
1123            close_price: num(parts[4])?,
1124            volume: num(parts[5])?,
1125            // ── CSV 只有六列(ts,o,h,l,c,v),其余字段显式给值,不靠 `..Default` ──
1126            // 回灌的 K 线来自文件、不属于任何网关,所以网关名留空。
1127            gateway_name: String::new(),
1128            // 也同样不知道是哪个交易所,给 UNKNOWN 而不是猜一个(猜错比留空危险)。
1129            exchange: ek_data::constant::Exchange::UNKNOWN,
1130            // 成交额/持仓量/涨跌幅 **CSV 里没有**。写 0 有歧义(0 可能是真 0,
1131            // 也可能是没数据);根治要改成 Option<f64>,属于 ek-data 破坏性变更,
1132            // 单独决策后再动。此处先显式写出来,至少不再是"靠默认悄悄变 0"。
1133            turnover: 0.0,
1134            open_interest: 0.0,
1135            pct_change: 0.0,
1136        });
1137    }
1138    if bars.is_empty() {
1139        return Err("回灌解析: 无有效行".to_string());
1140    }
1141    Ok((interval, symbol, bars))
1142}
1143
1144// ================= v4 布局面(带/组 tab / tick 库 / 下单表单;docs/ek-ui-layout-v2-plan.md) =================
1145
1146/// 左上组 tab(Python [委托|未成交];审计修正:行情是带不是 tab,K线/运维/CTP
1147/// 是弹窗不是 tab,故不设全局 10 值 UiTab——那是 v2 残影)。
1148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1149pub enum TopLeftTab {
1150    #[default]
1151    Orders,
1152    Active,
1153}
1154
1155impl TopLeftTab {
1156    pub fn label(self) -> &'static str {
1157        match self {
1158            Self::Orders => "委托",
1159            Self::Active => "未成交",
1160        }
1161    }
1162}
1163
1164/// 右下组 tab(Python [资金|日志])。
1165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1166pub enum BottomRightTab {
1167    #[default]
1168    Account,
1169    Logs,
1170}
1171
1172impl BottomRightTab {
1173    pub fn label(self) -> &'static str {
1174        match self {
1175            Self::Account => "资金",
1176            Self::Logs => "日志",
1177        }
1178    }
1179}
1180
1181/// 数据区两刀的份额(0.4.1 BD-UI-54)。Python `right_splitter` = vertical 50:50,
1182/// 我们的左右刀与之正交;两值都夹在 `crate::splitters::clamp_frac` 界内。
1183#[derive(Debug, Clone, Copy, PartialEq)]
1184pub struct DataSplit {
1185    /// 左右刀:左列占比(委托|未成交 在左)。
1186    pub col: f32,
1187    /// 上下刀:上行占比(委托/成交 在上)。
1188    pub row: f32,
1189}
1190
1191impl Default for DataSplit {
1192    fn default() -> Self {
1193        Self {
1194            col: crate::splitters::DATA_SPLIT_DEFAULT,
1195            row: crate::splitters::DATA_SPLIT_DEFAULT,
1196        }
1197    }
1198}
1199
1200/// 行情带六所固定 tab 序(0.4.1 BD-UI-54:六所各一格 = 用户裁定「每个交易所
1201/// 都要一个」;原 `crate::dock::MARKET_ORDER` 随 dock 拆除,常量原样搬到这里)。
1202pub const MARKET_ORDER: [ek_data::constant::Exchange; 6] = [
1203    ek_data::constant::Exchange::SHFE,
1204    ek_data::constant::Exchange::DCE,
1205    ek_data::constant::Exchange::CZCE,
1206    ek_data::constant::Exchange::CFFEX,
1207    ek_data::constant::Exchange::INE,
1208    ek_data::constant::Exchange::GFEX,
1209];
1210
1211// 0.2.30(BD-UI-46,裁定 8):主题面整块删除 —— 只保留一套主题(当时 = Qt深色
1212// qdarkstyle 3.2.3 复刻;0.4.1 四期 BD-UI-57 换成 shadcn Nova 深色,「只有一套」这条
1213// 裁定不变,换的是值),视觉规格搬进 `crate::tokens`。原来的 `Theme` 五变体枚举、
1214// `label()`、`next()` 循环、`UiShared::theme()/set_theme()/cycle_theme()`、菜单循环钮
1215// 全部退役。这是一次**有意的公共面收缩**:`ek_ui::state::Theme` 不再存在。外部没有任何
1216// crate 依赖 ek-ui,所以收缩不触发联动;但它不可逆,记在 CHANGELOG 0.2.30。
1217
1218/// tick 库(v2 行情 tab 唯一数据源):symbol → 最新 tick,逐 symbol 覆盖,
1219/// 有界 = 订阅面(ek-ctp 只发已订阅合约;无 bar 回落,用户裁定)。
1220#[derive(Debug, Default)]
1221pub struct TickStore {
1222    ticks: BTreeMap<String, ek_data::tick::TickData>,
1223}
1224
1225impl TickStore {
1226    /// 逐合约覆盖(审计修正:键 = en_symbol「交易所.合约」,防跨所同码相撞;
1227    /// 交易所分组取 tick 自带 exchange)。
1228    pub fn put(&mut self, t: &ek_data::tick::TickData) {
1229        self.ticks
1230            .insert(format!("{}.{}", t.exchange.as_str(), t.symbol), t.clone());
1231    }
1232
1233    /// 只读视图(交易所 → 合约排序,tab 分组顺序稳定)。
1234    pub fn rows(&self) -> Vec<ek_data::tick::TickData> {
1235        let mut v: Vec<ek_data::tick::TickData> = self.ticks.values().cloned().collect();
1236        v.sort_by(|a, b| {
1237            (a.exchange.as_str().to_string(), a.symbol.clone())
1238                .cmp(&(b.exchange.as_str().to_string(), b.symbol.clone()))
1239        });
1240        v
1241    }
1242
1243    /// 按交易所分组(tab 键只列有 tick 的所,不预置全枚举)。
1244    pub fn groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
1245        let mut out: Vec<(String, Vec<ek_data::tick::TickData>)> = Vec::new();
1246        for t in self.rows() {
1247            let ex = t.exchange.as_str().to_string();
1248            match out.last_mut() {
1249                Some((e, v)) if *e == ex => v.push(t),
1250                _ => out.push((ex, vec![t])),
1251            }
1252        }
1253        out
1254    }
1255
1256    pub fn len(&self) -> usize {
1257        self.ticks.len()
1258    }
1259
1260    pub fn is_empty(&self) -> bool {
1261        self.ticks.is_empty()
1262    }
1263}
1264
1265/// 下单面板表单(v2 常驻右侧;Python TradingWidget 字段面等价;操作面非配置面)。
1266#[derive(Debug, Clone, PartialEq)]
1267pub struct OrderForm {
1268    /// 交易所代码(下拉;from_str 抬成枚举)
1269    pub exchange: String,
1270    /// 合约代码(纯 symbol,不带前缀)
1271    pub symbol: String,
1272    pub direction: ek_data::constant::Direction,
1273    pub offset: ek_data::constant::Offset,
1274    pub order_type: ek_data::constant::OrderType,
1275    /// 输入面保持字符串(同 Python QLineEdit),委托时解析
1276    pub price: String,
1277    pub volume: String,
1278    /// 网关(下拉源 = trade_snap gateway 去重集 ∪ CTP 表单现值)
1279    pub gateway: String,
1280    /// 价格随行情勾选(勾选时价格框随选中合约最新 tick)
1281    pub follow_price: bool,
1282    /// 「自动开平」勾选(0.1.1 BD-UI-30;默认开 = LockMode::Auto 走拆单器,
1283    /// 关 = LockMode::None 原样旁路——守卫不可绕,拆单可逐请求旁路)
1284    pub auto_split: bool,
1285}
1286
1287impl Default for OrderForm {
1288    fn default() -> Self {
1289        Self {
1290            exchange: "SHFE".to_string(),
1291            symbol: String::new(),
1292            direction: ek_data::constant::Direction::LONG,
1293            offset: ek_data::constant::Offset::OPEN,
1294            order_type: ek_data::constant::OrderType::LIMIT,
1295            price: String::new(),
1296            volume: "1".to_string(),
1297            gateway: String::new(),
1298            follow_price: true,
1299            auto_split: true,
1300        }
1301    }
1302}
1303
1304impl OrderForm {
1305    /// 表单 → OrderRequest(与票据解析同一出口;错误红字就地,不进总线)。
1306    pub fn to_request(&self) -> Result<ek_data::order_request::OrderRequest, String> {
1307        use std::str::FromStr;
1308        let symbol = self.symbol.trim();
1309        if symbol.is_empty() {
1310            return Err("下单: 代码为空".to_string());
1311        }
1312        if symbol.contains('.') {
1313            return Err("下单: 代码不带交易所前缀(交易所走下拉)".to_string());
1314        }
1315        let exchange = ek_data::constant::Exchange::from_str(self.exchange.trim())
1316            .map_err(|e| format!("下单: {e}"))?;
1317        let price: f64 = self
1318            .price
1319            .trim()
1320            .parse()
1321            .map_err(|_| format!("下单: 价格无效 {:?}", self.price))?;
1322        let volume: f64 = self
1323            .volume
1324            .trim()
1325            .parse()
1326            .map_err(|_| format!("下单: 数量无效 {:?}", self.volume))?;
1327        if self.order_type != ek_data::constant::OrderType::MARKET && price <= 0.0 {
1328            return Err("下单: 限价单价格须为正".to_string());
1329        }
1330        if volume <= 0.0 {
1331            return Err("下单: 数量须为正".to_string());
1332        }
1333        Ok(ek_data::order_request::OrderRequest {
1334            symbol: symbol.to_string(),
1335            exchange,
1336            direction: self.direction,
1337            order_type: self.order_type,
1338            volume,
1339            price,
1340            offset: self.offset,
1341            ..ek_data::order_request::OrderRequest::default()
1342        })
1343    }
1344}
1345
1346/// 合约库(v4 查询合约窗数据源):en_symbol → ContractData。
1347///
1348/// 数据面 = ek-ctp 登录后自动 reqQryInstrument 回流(`eContract.` 裸键逐条 +
1349/// `eContractFinish` 完成线,driver.rs:779)——**无 ask 口,UI 只收不催**
1350/// (用户裁定:订阅不在网关配置定死,查询窗勾选订阅 / 下单代码回车)。
1351#[derive(Debug, Default)]
1352pub struct ContractStore {
1353    contracts: BTreeMap<String, ek_data::contract::ContractData>,
1354}
1355
1356impl ContractStore {
1357    pub fn put(&mut self, c: &ek_data::contract::ContractData) {
1358        self.contracts
1359            .insert(format!("{}.{}", c.exchange.as_str(), c.symbol), c.clone());
1360    }
1361
1362    /// Python ContractManager 查询口径:代码/交易所/名称模糊,留空 = 全部。
1363    pub fn query(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
1364        let p = pattern.trim();
1365        self.contracts
1366            .values()
1367            .filter(|c| {
1368                p.is_empty()
1369                    || c.symbol.contains(p)
1370                    || c.exchange.as_str().eq_ignore_ascii_case(p)
1371                    || c.name.contains(p)
1372            })
1373            .cloned()
1374            .collect()
1375    }
1376
1377    pub fn len(&self) -> usize {
1378        self.contracts.len()
1379    }
1380
1381    pub fn is_empty(&self) -> bool {
1382        self.contracts.is_empty()
1383    }
1384}
1385
1386/// 插件面板与宿主观察线程共用的观测状态。
1387#[derive(Debug, Default)]
1388pub struct UiShared {
1389    /// 0.9.37:当前是否「配置相」(起跑前)。相内**不画**「下发指令」的钮 ——
1390    /// 消费指令的 ui-cmd 线程只在 ui 插件起跑后存在,点了没反应会让人以为按钮坏了。
1391    setup_phase: std::sync::atomic::AtomicBool,
1392    /// 档案根读不动的显形条(0.9.39;空表必须能自证原因)。
1393    store_err: Mutex<Option<String>>,
1394    /// 0.9.45:当前帧被真渲染过的模块窗 id(有头判据/取证用;每帧窗口循环开头清)。
1395    window_visible: Mutex<HashSet<String>>,
1396    logs: Mutex<LogRing>,
1397    snap: Mutex<Arc<BusSnap>>,
1398    market: MarketStore,
1399    ctp_form: Mutex<CtpForm>,
1400    /// 交易四本账(0.5 M1:事件驱动后的唯一事实源;键 = 基线 `data_key`)。
1401    /// 值带行序 `u64` —— 基线 `insert_new_row` 是 `insertRow(0)`(新事件在前)。
1402    trade_orders: Mutex<BTreeMap<String, (u64, ek_data::order::OrderData)>>,
1403    trade_trades: Mutex<VecDeque<(u64, ek_data::trade::TradeData)>>,
1404    /// 成交去重集(键 `en_tradeid` = `{网关}.{成交号}`)。
1405    ///
1406    /// 口径来自基线**引擎层** `BaseTradeEngine.trades_filter_dict`(`engine.py:1072-1080`,
1407    /// 注释写明它是为「账户重连时柜台重推同一笔成交」准备的),与 `ek-oms` 的
1408    /// `trades_filter`(`oms.rs:203 / 260-264`)同键同口径。成交表本身没有 `data_key`
1409    /// (基线 `TradeMonitor` :1267 只插不改),所以去重只能靠这张集,不能靠行键。
1410    /// 它跟着账本走:触顶被挤掉的行同时出集(那笔再推就还能上屏),摘网关时同网关一起清。
1411    trade_ids: Mutex<HashSet<String>>,
1412    trade_positions: Mutex<BTreeMap<String, (u64, ek_data::position::PositionData)>>,
1413    trade_funds: Mutex<BTreeMap<String, (u64, ek_data::account::AccountData)>>,
1414    /// 行序计数器(每次入账递增;快照按它降序排 = 新事件在前)。
1415    trade_seq: AtomicU64,
1416    /// 成交账本触顶丢弃计数(0.5 偏离③;>0 说明更早的成交已被丢掉)。
1417    trade_dropped: AtomicU64,
1418    /// 四本账 → 快照的重建标记(入账置真,`trade_snap()` 消费)。
1419    trade_dirty: AtomicBool,
1420    /// 渲染侧读的交易快照 = 四本账的缓存视图(脏时按帧重建一次)。
1421    trade_snap: Mutex<Arc<TradeSnap>>,
1422    tick_store: Mutex<TickStore>,
1423    order_form: Mutex<OrderForm>,
1424    /// 数据区 2×2 两刀的份额(0.4.1 BD-UI-54:拆 dock 后归薄 QSplitter;
1425    /// col = 左右刀的左占比,row = 上下刀的上占比,缺省 = Python right_splitter 50:50)。
1426    data_split: Mutex<DataSplit>,
1427    /// 行情带当前 tab(按所;六所固定 tab 条,谁在前台就是谁)。
1428    selected_exchange: Mutex<String>,
1429    /// 委托|未成交 组的当前 tab。
1430    topleft_tab: Mutex<TopLeftTab>,
1431    /// 资金|日志 组的当前 tab。
1432    bottomright_tab: Mutex<BottomRightTab>,
1433    ops_window: AtomicBool,
1434    /// 账户面板(多账号面;取代旧单账号 CTP 配置窗的入口 —— 旧窗已退役,
1435    /// 旧 `ctp_window` 位随之删除,不留死状态)。
1436    ///
1437    /// 0.9.34:开合搬到 [`crate::mount::Registry`](窗口 = 模块窗),本字段退役。
1438    contracts_window: AtomicBool,
1439    contract_store: Mutex<ContractStore>,
1440    contract_query: Mutex<String>,
1441    contract_selected: Mutex<Vec<String>>,
1442    kline_windows: Mutex<Vec<String>>,
1443    /// K线历史补数队列(I2 第一刀:开窗入队一次;命令线程消费走 tq `{gw}.klines`)。
1444    kline_fetches: Mutex<Vec<KlineFetch>>,
1445    /// 已补数集(同 (symbol,秒) 只补一次;开新窗/重开不重复拉)。
1446    kline_fetched: Mutex<HashSet<(String, i64)>>,
1447    rev: AtomicU64,
1448    /// 关窗/测试结束信号(窗口面置位,宿主观察线程消费)。
1449    stop: AtomicBool,
1450    /// svctl 操作指令队列(窗口面入,ui 插件 eTimer 出)。
1451    cmds: Mutex<VecDeque<UiCmd>>,
1452    /// 待二次确认的移除目标(单槽;再次同名点 = 发令)。
1453    pending_confirm: Mutex<Option<String>>,
1454    /// 账户清单(多账号面;界面唯一事实源)。
1455    accounts: Mutex<Vec<AccountRow>>,
1456    /// 账户配置弹窗开合(0.2.18 Commit B:『添加』与『编辑』同一弹窗两种模式)。
1457    account_edit_window: AtomicBool,
1458    /// 弹窗模式:`Some(派生名)` = 编辑该账户;**`None` = 添加新账户**(草稿位仍是 `ctp_form`)。
1459    editing_account: Mutex<Option<String>>,
1460    /// 弹窗里的别名草稿(『保存』时才写回账户行)。
1461    edit_alias: Mutex<String>,
1462    /// 弹窗内的拦截原因(校验不过 / 装配被拒):弹窗**不关**,原因上屏(规划 U3 / Q26)。
1463    edit_error: Mutex<Option<String>>,
1464    /// 行情元账号的注册名(空 = 未选;连接后锁定,规划 D3)。
1465    md_source: Mutex<String>,
1466    /// 订阅集(裸代码;断开重连后按它重放订阅,规划 §5.C)。
1467    subscriptions: Mutex<Vec<String>>,
1468    /// 表格行选中(BD-UI-31):表 id → 选中行键(每表一行;键由渲染侧构造,
1469    /// 纯界面态,不发指令;悬停跳色是渲染侧即时判断,不入状态)。
1470    row_sel: Mutex<BTreeMap<String, String>>,
1471    /// 0.3.1(Wave D,BD-UI-49)表格排序键:`表 id -> Some((列序, 升序))`,`None` = 不排序。
1472    /// 排序键住在界面状态,不进数据模型(W2 的判据就是这条 -- Python `widget.py:396-419`
1473    /// 那个「更新途中重排」的病,根因是排序作用在正在被喂的模型上)。
1474    table_sorts: Mutex<BTreeMap<String, Option<(usize, bool)>>>,
1475    /// 「列边界可拖」的开关(0.3.1 右键菜单「调整列宽」)已在 0.4.1 五期(BD-UI-58)退役:
1476    /// 可拖 + 列线是全部表的固定形制,不再是可开关的东西 → 字段与访问器一并删。
1477    /// 状态轮询游标(0.5 M3 摊平:每拍从游标起环形取 `STATUS_ASKS_PER_TICK` 个)。
1478    /// 0.7.0 第3刀:周期轮询退役(状态改吃 `eStatus.` 推送),这里只服务
1479    /// 「网关上线后的一次性播种」(每网关运行期至多一次 ask)。
1480    status_cursor: AtomicUsize,
1481    /// 一次性状态播种队列(0.7.0 第3刀):网关上线 → 问一次 `{gw}.status` 拿初始快照,
1482    /// 之后常态全靠 `eStatus.` 推送(界面零轮询)。
1483    status_seed: Mutex<Vec<String>>,
1484    /// `svctl.list` 轮询节拍计数(每 `LIST_POLL_TICKS` 拍一次)。
1485    list_tick: AtomicU32,
1486    /// 管制面快照的「现在就问」标记(0.5 执行核查补的洞):插件上/下线与 svctl 起停
1487    /// 之后必须立刻重问一次,否则在场位与运维窗那一行最多要等 4 拍才动 —— 0.4 是每拍
1488    /// 一次,这一档降下来就把「点了移除、行 4 秒不动」变成了常态。
1489    list_due: AtomicBool,
1490    /// 「重播种一次四张表」标记(0.5 执行核查补的第三处):网关或 oms 上线之后重问一次
1491    /// 四口。摘除一个网关账户会作废它名下的委托 / 成交行,而 oms 那边那些行还在 ——
1492    /// 重新添加时不重问一次,界面就永久少了一段历史。消费方是命令线程,不是 eTimer 拍。
1493    seed_due: AtomicBool,
1494    /// `oms.pipeline_status` 待问标记(0.5 M3:改事件触发 —— 启动 / oms 上线 / 手动刷新钮)。
1495    pipeline_due: AtomicBool,
1496    /// 配置托底段目录待问标记(0.6.0 环节8;启用 = 运维窗「刷新段落目录」钮)。
1497    cfg_due: AtomicBool,
1498    /// 配置托底段目录读数(0.6.0;`None` = 没问过 → 运维窗按句提示,不是空表;
1499    /// `Some(vec)`:零值面,`NsBrief` 只有 key/形态标志,BD-CFG-17/18)。
1500    cfg_briefs: Mutex<Option<Vec<ek_cfg::wire::NsBrief>>>,
1501    /// 清段二次确认臂(0.6.0;防误触,与 svctl 移除的 ConfirmStep 同形但独立)。
1502    cfg_delete_arm: Mutex<Option<String>>,
1503    /// **当前界面档(0.8.0 配色双档,BD-UI-61)**:`0 = Dark`(缺省)、`1 = Light`。
1504    /// 缺省 = 深色(裁定 B:没点过切换钮时现行为零变化)。AtomicU8 而不是 `Mutex<Theme>`:
1505    /// 渲染线程每帧读、命令线程偶发读,`Acquire/Release` 就够,不给每帧加锁。
1506    theme: AtomicU8,
1507    /// 主题落盘请求(0.8.0;只有**用户手点**切换钮才置位,照参考站「只有点击才存」)。
1508    /// 消费方是**命令线程**(写 ek-cfg 存储根 `ns="ui" key="theme"`)—— 渲染线程零文件 IO。
1509    theme_persist_due: AtomicBool,
1510    /// 自动连接落盘请求(0.9.0):**手点**「自动连接」勾选才置位;消费方 = 命令线程
1511    /// 把全部行的勾选态写进 `ns="ui"`(键 `auto_connect.<网关名>`,移除行清键)。
1512    /// 与主题同一支纪律:渲染线程零文件 IO,写档只在命令线程 eTimer 拍上。
1513    auto_connect_persist_due: AtomicBool,
1514    /// 别名落盘请求(0.9.10,用户裁定 2026-10-28「仿真/24小时 标签要能选」):
1515    /// 保存/改别名/摘行时置位;消费方 = 命令线程写 `ns="ui"` 键 `alias.<网关名>`
1516    /// (空别名 = 清键;摘户行清键)。与自动连接同一支纪律:渲染线程零文件 IO。
1517    alias_persist_due: AtomicBool,
1518    /// 档案抹除待办(0.9.10,用户实锤「删了的账户下次启动又显示」):摘行 = 内存清册
1519    /// 摘除 + 档案段 `ctp.{gw}` 也要抹(启动清点扫 `ctp.*` 段,档案不抹 = 账户复活)。
1520    /// 渲染线程只入队(零文件 IO),消费方 = 命令线程 `purge_account_archive`。
1521    archive_purge: Mutex<Vec<String>>,
1522    /// 推送线载荷类型异常的计数(0.5 M1:线名 → 次数)。不静默丢。
1523    bad_payload: Mutex<BTreeMap<String, u64>>,
1524    /// oms 装配显形的最新读数(`pipeline_status()` 的字符串行)。
1525    /// 空 = 没问到 / oms 不在场 → 运维窗整节不画(不是「画一个空表」)。
1526    pipeline_lines: Mutex<Vec<String>>,
1527    /// 「查询断供」阈值覆盖 `Some((连续被拒次数, 资金超时秒))`;`None` = 用缺省常量。
1528    /// 阈值住在界面不在网关(ek-ctp D-STATUS),覆盖位是给测试与后续配置面留的口子。
1529    status_thresholds: Mutex<Option<(u32, i64)>>,
1530    /// 0.3.2(Wave E,BD-UI-50)攒着待灌进 toast 队列的告警(界面层产生,渲染帧收口)。
1531    pending_alerts: Mutex<Vec<(crate::toast::AlertLevel, String)>>,
1532    /// 常驻 toast 队列(住在界面状态,不是 render 的局部变量 —— 否则 8 秒自动关与
1533    /// 同屏上限都没有意义)。`None` = 还没按我们的口径建过。
1534    toasts: crate::toast::ToastsCell,
1535    /// 已经弹过 toast 的告警键(去重:同一账户同一状态只弹一次,照 Python AlertEngine 的去重)。
1536    alerted: Mutex<std::collections::BTreeSet<String>>,
1537    /// 0.3.1(Wave D,BD-UI-49)退出确认窗是否开着(照 Python `closeEvent`:关窗先问一句)。
1538    exit_confirm_open: AtomicBool,
1539    /// 用户已在确认窗里点过「确认退出」—— 之后不再拦关闭(否则确认完也关不掉)。
1540    exit_confirmed: AtomicBool,
1541    /// 装配锁(0.9.5 阶段机):启动全成功后 = 装配面**只可看不可动**。
1542    /// 纯界面层旗(用户裁定「底层不写死」):svctl/插件本体不读它,只有界面认它。
1543    assembly_locked: AtomicBool,
1544    /// 引擎弱引用(0.9.34「一模块一菜单」波):模块窗要按实例名取插件句柄
1545    /// (`get_typed`) 才能渲染,但 `UiShared` 也被插件持有 —— 存 `Weak` 断环,
1546    /// 主循环每帧 `upgrade`,引擎不在场(None)= 窗口画「未就绪」。
1547    engine: Mutex<std::sync::Weak<ek_core::main_engine::MainEngine>>,
1548    /// 模块挂载表(0.9.34):一模块一菜单一窗;`svctl.start/remove` 后 `remount_modules`。
1549    modules: Mutex<crate::mount::Registry>,
1550    // ---------------- 策略交易(0.9.47) ----------------
1551    /// 策略行表:`实例名 → 行`。**单源是 ek-strategy 的推送** ——
1552    /// 界面不查、不推、不缓存第二份事实(渲染只读这里)。
1553    strategies: Mutex<BTreeMap<String, ek_strategy::contract::StrategyRow>>,
1554    /// 策略类元数据(「添加策略」弹窗的数据源;UI 上线时插件推一次全量)。
1555    strategy_metas: Mutex<Vec<ek_strategy::strategy::StrategyMeta>>,
1556    /// 编辑弹窗回填的参数(命令线程问 `strategy.get_settings` 后落到这里)。
1557    strategy_settings: Mutex<BTreeMap<String, Vec<(String, String)>>>,
1558    /// 弹窗预填用的最新价缓存(`symbol → price`)。
1559    strategy_prices: Mutex<BTreeMap<String, f64>>,
1560    /// 策略命令回执单槽(`add`/`edit` 用;渲染线程取走即清)。
1561    strategy_feedback: Mutex<std::collections::VecDeque<StratFeedback>>,
1562}
1563
1564impl UiShared {
1565    // ---- 引擎 / 模块挂载(0.9.34) ----
1566
1567    /// 记下引擎弱引用(runtime 装配完成后调一次)。
1568    pub fn install_engine(&self, engine: &std::sync::Arc<ek_core::main_engine::MainEngine>) {
1569        *self.engine.lock().unwrap() = std::sync::Arc::downgrade(engine);
1570    }
1571
1572    /// 取引擎(主循环每帧;引擎已散 = None)。
1573    #[must_use]
1574    pub fn engine_arc(&self) -> Option<std::sync::Arc<ek_core::main_engine::MainEngine>> {
1575        self.engine.lock().unwrap().upgrade()
1576    }
1577
1578    /// 模块挂载表(渲染/菜单用)。
1579    pub fn modules(&self) -> std::sync::MutexGuard<'_, crate::mount::Registry> {
1580        self.modules.lock().unwrap()
1581    }
1582
1583    // ---- 策略交易(0.9.47) ----
1584
1585    /// 吃一条策略推送并落进行表(`bump` 让下一帧重画)。
1586    ///
1587    /// 三条语义与插件侧严格对齐:
1588    /// - `Upsert` = 整行替换(不是字段合并);
1589    /// - `Removed` = 摘掉一行;
1590    /// - `Snapshot` = **整体替换**(UI 上线/重连后的重播,不能与旧行并存)。
1591    pub fn apply_strategy_event(&self, ev: &ek_strategy::contract::StrategyEvent) {
1592        use ek_strategy::contract::StrategyEvent as Ev;
1593        {
1594            let mut map = self.strategies.lock().unwrap();
1595            match ev {
1596                Ev::Upsert(row) => {
1597                    map.insert(row.name.clone(), (**row).clone());
1598                }
1599                Ev::Removed { name } => {
1600                    map.remove(name);
1601                }
1602                Ev::Snapshot(rows) => {
1603                    map.clear();
1604                    for r in rows {
1605                        map.insert(r.name.clone(), r.clone());
1606                    }
1607                }
1608            }
1609        }
1610        self.bump();
1611    }
1612
1613    /// 策略类元数据(添加弹窗)。
1614    pub fn set_strategy_metas(&self, metas: Vec<ek_strategy::strategy::StrategyMeta>) {
1615        *self.strategy_metas.lock().unwrap() = metas;
1616        self.bump();
1617    }
1618
1619    /// 策略类元数据(只读克隆;弹窗每帧要它)。
1620    #[must_use]
1621    pub fn strategy_metas(&self) -> Vec<ek_strategy::strategy::StrategyMeta> {
1622        self.strategy_metas.lock().unwrap().clone()
1623    }
1624
1625    /// 全部行(按实例名有序,表序稳定)。
1626    #[must_use]
1627    pub fn strategy_rows(&self) -> Vec<ek_strategy::contract::StrategyRow> {
1628        self.strategies.lock().unwrap().values().cloned().collect()
1629    }
1630
1631    /// 单行。
1632    #[must_use]
1633    pub fn strategy_row(&self, name: &str) -> Option<ek_strategy::contract::StrategyRow> {
1634        self.strategies.lock().unwrap().get(name).cloned()
1635    }
1636
1637    /// 行数(`0` = 空态)。
1638    #[must_use]
1639    pub fn strategy_count(&self) -> usize {
1640        self.strategies.lock().unwrap().len()
1641    }
1642
1643    /// 落一份参数回填(编辑弹窗)。
1644    pub fn set_strategy_settings(&self, name: &str, params: Vec<(String, String)>) {
1645        self.strategy_settings
1646            .lock()
1647            .unwrap()
1648            .insert(name.to_string(), params);
1649        self.bump();
1650    }
1651
1652    /// 取参数回填(**取走即清**;弹窗只吃一次)。
1653    pub fn take_strategy_settings(&self, name: &str) -> Option<Vec<(String, String)>> {
1654        self.strategy_settings.lock().unwrap().remove(name)
1655    }
1656
1657    /// 落一个最新价(弹窗预填)。
1658    pub fn set_strategy_price(&self, symbol: &str, price: f64) {
1659        self.strategy_prices
1660            .lock()
1661            .unwrap()
1662            .insert(symbol.to_string(), price);
1663        self.bump();
1664    }
1665
1666    /// 取最新价。
1667    #[must_use]
1668    pub fn strategy_price(&self, symbol: &str) -> Option<f64> {
1669        self.strategy_prices.lock().unwrap().get(symbol).copied()
1670    }
1671
1672    /// 队列上限:回执是"给用户看结果"的,不承载业务事实;爆了丢最老的。
1673    ///
1674    /// 2026-11-03 由单槽改队列:命令线程可能连发几条(启动/暂停/停止),
1675    /// 单槽会让前一条被后一条顶掉 —— 丢掉恰好是失败那条时,用户看到的就是"点了没反应"。
1676    pub const STRAT_FEEDBACK_CAP: usize = 32;
1677
1678    /// 落一条命令回执(命令线程调)。
1679    pub fn set_strategy_feedback(&self, fb: StratFeedback) {
1680        {
1681            let mut q = self.strategy_feedback.lock().unwrap();
1682            if q.len() >= Self::STRAT_FEEDBACK_CAP {
1683                q.pop_front();
1684            }
1685            q.push_back(fb);
1686        }
1687        self.bump();
1688    }
1689
1690    /// 把回执放回**队首**(渲染线程"这条不是给我的"用:顺序不能乱)。
1691    pub fn push_strategy_feedback_front(&self, fb: StratFeedback) {
1692        self.strategy_feedback.lock().unwrap().push_front(fb);
1693        self.bump();
1694    }
1695
1696    /// 取走一条命令回执(渲染线程;取走即清,只消费一次)。
1697    pub fn take_strategy_feedback(&self) -> Option<StratFeedback> {
1698        self.strategy_feedback.lock().unwrap().pop_front()
1699    }
1700
1701    /// 档案根读不动的**显形条**(0.9.39;`Some` = 网关窗顶部挂橙条,空表不再无声)。
1702    pub fn set_store_err(&self, msg: Option<String>) {
1703        *self.store_err.lock().unwrap() = msg;
1704        self.bump();
1705    }
1706
1707    /// 读显形条(`None` = 档案清点正常)。
1708    #[must_use]
1709    pub fn store_err(&self) -> Option<String> {
1710        self.store_err.lock().unwrap().clone()
1711    }
1712
1713    // ---- 0.9.45 窗可见性取证(有头 H12/H13 判据) ----
1714
1715    /// 帧开头:清窗口可见集(`Registry::windows` 每帧调用)。
1716    pub fn begin_window_frame(&self) {
1717        self.window_visible.lock().unwrap().clear();
1718    }
1719    /// 某窗本帧被真渲染过(不是 set_open 的记账,是**真的走完渲染**)。
1720    pub fn note_window_visible(&self, id: &str) {
1721        self.window_visible.lock().unwrap().insert(id.to_string());
1722    }
1723    /// 判据问口:某窗最近一帧在不在屏。
1724    #[must_use]
1725    pub fn window_was_visible(&self, id: &str) -> bool {
1726        self.window_visible.lock().unwrap().contains(id)
1727    }
1728    /// 记「当前在配置相」(0.9.37;`StageApp` 构造时开,起跑时关)。
1729    pub fn set_setup_phase(&self, on: bool) {
1730        self.setup_phase
1731            .store(on, std::sync::atomic::Ordering::Release);
1732        self.bump();
1733    }
1734
1735    /// 是否还在配置相(起跑前)。配置相里**不发**任何下发指令。
1736    #[must_use]
1737    pub fn in_setup(&self) -> bool {
1738        self.setup_phase.load(std::sync::atomic::Ordering::Acquire)
1739    }
1740
1741    /// 重建挂载表(装配完成 / 起停插件后调;已有窗的开合与窗内态保留)。
1742    ///
1743    /// 0.9.40(用户实测「界面点击死卡」):**引擎扫描在锁外做完**。旧写法是持着
1744    /// 挂载表锁去扫引擎,而引擎在插件起停时会短暂持锁(装配 2~4 秒)→ 主界面
1745    /// 点菜单/开窗就跟着卡住。现在先扫(锁外)、再进锁写表(纯内存,微秒级)。
1746    pub fn remount_modules(&self) {
1747        let groups = crate::mount::scan_groups(self);
1748        let mut reg = self.modules.lock().unwrap();
1749        reg.sync_groups(&groups);
1750    }
1751
1752    /// 空状态(快照空、日志空、rev 0)。
1753    pub fn new() -> Self {
1754        Self {
1755            // `always` 模块(交易网关)先建好 —— 没引擎、没装配也要能管账户档案。
1756            window_visible: Mutex::new(HashSet::new()),
1757            modules: Mutex::new(crate::mount::Registry::with_always()),
1758            ..Self::default()
1759        }
1760    }
1761
1762    // ---- v2 布局面访问器(tick 库 / 表单 / 选中 / tab) ----
1763
1764    /// 落一条 tick(eTick. 裸键上屏;逐 symbol 覆盖)。
1765    pub fn update_tick(&self, t: &ek_data::tick::TickData) {
1766        self.tick_store.lock().unwrap().put(t);
1767        self.bump();
1768    }
1769
1770    /// tick 只读视图(排序后)。
1771    pub fn tick_rows(&self) -> Vec<ek_data::tick::TickData> {
1772        self.tick_store.lock().unwrap().rows()
1773    }
1774
1775    /// tick 按交易所分组(行情 tab 二级 tab 键表)。
1776    pub fn tick_groups(&self) -> Vec<(String, Vec<ek_data::tick::TickData>)> {
1777        self.tick_store.lock().unwrap().groups()
1778    }
1779
1780    /// tick 库格数。
1781    pub fn tick_count(&self) -> usize {
1782        self.tick_store.lock().unwrap().len()
1783    }
1784
1785    /// 下单表单只读快照。
1786    pub fn order_form(&self) -> OrderForm {
1787        self.order_form.lock().unwrap().clone()
1788    }
1789
1790    /// 下单表单写(渲染侧每控件改动即写)。
1791    pub fn set_order_form(&self, f: OrderForm) {
1792        *self.order_form.lock().unwrap() = f;
1793        self.bump();
1794    }
1795
1796    /// 选中交易所(行情带当前 tab;不在六所内的取值由本口夹回首所 —— 空/脏值
1797    /// 都落到 SHFE,调用方永远拿到一个可用的 tab 名;行情行单击只带入下单面板,
1798    /// tab 归 tab 条管)。
1799    pub fn selected_exchange(&self) -> String {
1800        let s = self.selected_exchange.lock().unwrap().clone();
1801        if MARKET_ORDER.iter().any(|e| e.as_str() == s) {
1802            s
1803        } else {
1804            MARKET_ORDER[0].as_str().to_string()
1805        }
1806    }
1807
1808    /// 选中交易所写。
1809    pub fn set_selected_exchange(&self, s: impl Into<String>) {
1810        *self.selected_exchange.lock().unwrap() = s.into();
1811        self.bump();
1812    }
1813
1814    /// 左上组当前 tab。
1815    pub fn topleft_tab(&self) -> TopLeftTab {
1816        *self.topleft_tab.lock().unwrap()
1817    }
1818
1819    /// 左上组 tab 写。
1820    pub fn set_topleft_tab(&self, t: TopLeftTab) {
1821        *self.topleft_tab.lock().unwrap() = t;
1822        self.bump();
1823    }
1824
1825    /// 右下组当前 tab。
1826    pub fn bottomright_tab(&self) -> BottomRightTab {
1827        *self.bottomright_tab.lock().unwrap()
1828    }
1829
1830    /// 右下组 tab 写。
1831    pub fn set_bottomright_tab(&self, t: BottomRightTab) {
1832        *self.bottomright_tab.lock().unwrap() = t;
1833        self.bump();
1834    }
1835
1836    /// 数据区分刀份额读(渲染期)。
1837    pub fn data_split(&self) -> DataSplit {
1838        *self.data_split.lock().unwrap()
1839    }
1840
1841    /// 数据区分刀份额写(分隔条拖拽回调;写入前就地夹取到可用区,QSplitter
1842    /// minimumSize 语义 —— 调用方传多少都不出界)。
1843    pub fn set_data_split(&self, s: DataSplit) {
1844        let s = DataSplit {
1845            col: crate::splitters::clamp_frac(s.col),
1846            row: crate::splitters::clamp_frac(s.row),
1847        };
1848        let mut g = self.data_split.lock().unwrap();
1849        if g.col != s.col || g.row != s.row {
1850            *g = s;
1851            self.bump();
1852        }
1853    }
1854
1855    /// 「恢复布局」:数据区分刀回 Python right_splitter 口径(两刀各半),
1856    /// 三个 tab 态回缺省。dock 时代的「整树替换」不再存在 —— 布局骨架本来就是
1857    /// Python 等价的原生面板,没有树可换。
1858    pub fn reset_layout(&self) {
1859        *self.data_split.lock().unwrap() = DataSplit::default();
1860        *self.selected_exchange.lock().unwrap() = String::new();
1861        *self.topleft_tab.lock().unwrap() = TopLeftTab::default();
1862        *self.bottomright_tab.lock().unwrap() = BottomRightTab::default();
1863        self.bump();
1864    }
1865
1866    // ---- v4 弹窗面(egui::Window 开关;kittest 可断言) ----
1867
1868    /// 运维窗开合。
1869    pub fn ops_window_open(&self) -> bool {
1870        self.ops_window.load(Ordering::Acquire)
1871    }
1872
1873    /// 运维窗开合写。
1874    /// 某张表当前的排序键(`None` = 按数据源原序)。
1875    pub fn table_sort(&self, table: &str) -> Option<(usize, bool)> {
1876        self.table_sorts
1877            .lock()
1878            .unwrap()
1879            .get(table)
1880            .copied()
1881            .flatten()
1882    }
1883
1884    /// 表头点击:同列再点 = 翻向;异列 = 新列升序(与 Python QTableView 单击表头同口径)。
1885    pub fn toggle_table_sort(&self, table: &str, col: usize) {
1886        let mut m = self.table_sorts.lock().unwrap();
1887        let asc = match m.get(table).copied().flatten() {
1888            Some((c, a)) if c == col => !a,
1889            _ => true,
1890        };
1891        m.insert(table.to_string(), Some((col, asc)));
1892    }
1893
1894    pub fn set_ops_window_open(&self, v: bool) {
1895        self.ops_window.store(v, Ordering::Release);
1896        self.bump();
1897    }
1898
1899    // ---- 多账号面(规划 §5.B / §5.C) ----
1900
1901    /// 账户面板开合(**0.9.34 起 = 「交易网关」模块窗的开合**,单一事实源在挂载表)。
1902    ///
1903    /// 保留旧名是因为 `render`/测试/示例都以它为入口语义(「把账户面打开」);
1904    /// 实现改走 [`crate::mount::Registry`],不再有独立的 `AtomicBool`。
1905    pub fn accounts_window_open(&self) -> bool {
1906        self.modules().is_open("ctp")
1907    }
1908
1909    /// 账户面板开合写(→ `交易网关` 模块窗)。
1910    pub fn set_accounts_window_open(&self, v: bool) {
1911        self.modules().set_open("ctp", v);
1912        self.bump();
1913    }
1914
1915    /// 账户配置弹窗开合(0.2.18 Commit B)。
1916    pub fn account_edit_open(&self) -> bool {
1917        self.account_edit_window.load(Ordering::Acquire)
1918    }
1919
1920    /// 当前弹窗模式:`Some(派生名)` = 编辑;`None` = 添加。
1921    pub fn editing_account(&self) -> Option<String> {
1922        self.editing_account.lock().unwrap().clone()
1923    }
1924
1925    /// 打开配置弹窗并载入草稿:`Some(派生名)` = 编辑模式(草稿 = 该账户留档);
1926    /// `None` = 添加模式(草稿 = `draft_from_existing()`,凭据位空)。
1927    /// 账户不在清册 = `false`(不开一个编辑不存在账户的窗)。
1928    pub fn begin_account_edit(&self, gateway: Option<&str>) -> bool {
1929        match gateway {
1930            Some(gw) => {
1931                let Some(row) = self.account(gw) else {
1932                    return false;
1933                };
1934                *self.editing_account.lock().unwrap() = Some(gw.to_string());
1935                *self.edit_alias.lock().unwrap() = row.alias.clone();
1936                self.set_ctp_form(row.form.clone());
1937            }
1938            None => {
1939                *self.editing_account.lock().unwrap() = None;
1940                *self.edit_alias.lock().unwrap() = String::new();
1941                let draft = self.draft_from_existing();
1942                self.set_ctp_form(draft);
1943            }
1944        }
1945        *self.edit_error.lock().unwrap() = None;
1946        self.account_edit_window.store(true, Ordering::Release);
1947        self.bump();
1948        true
1949    }
1950
1951    /// 关闭配置弹窗 = **放弃未保存**(规划 Q24:绝不静默写入清册留档)。
1952    pub fn end_account_edit(&self) {
1953        *self.editing_account.lock().unwrap() = None;
1954        *self.edit_alias.lock().unwrap() = String::new();
1955        *self.edit_error.lock().unwrap() = None;
1956        self.account_edit_window.store(false, Ordering::Release);
1957        let draft = self.draft_from_existing();
1958        self.set_ctp_form(draft);
1959        self.bump();
1960    }
1961
1962    /// 摘除一行账户时的连带口径:若这行正被配置弹窗**编辑**,立即关弹窗放弃。
1963    ///
1964    /// 不这么做的话:弹窗是非 modal 的,用户可以在弹窗开着时点同一行的『移除』——
1965    /// 编辑目标消失后弹窗会静默退化成「添加模式」(只读句消失、字段全部可编辑),
1966    /// 此时点『保存』会拿旧凭据建出一个**新账户**。这是执行核查抓到的坑。
1967    pub fn end_account_edit_if_editing(&self, gateway: &str) {
1968        if self.editing_account().as_deref() == Some(gateway) {
1969            self.end_account_edit();
1970        }
1971    }
1972
1973    /// 弹窗别名草稿读。
1974    pub fn edit_alias(&self) -> String {
1975        self.edit_alias.lock().unwrap().clone()
1976    }
1977
1978    /// 弹窗别名草稿写。
1979    pub fn set_edit_alias(&self, v: impl Into<String>) {
1980        *self.edit_alias.lock().unwrap() = v.into();
1981        self.bump();
1982    }
1983
1984    /// 弹窗拦截原因读(`None` = 无)。
1985    pub fn edit_error(&self) -> Option<String> {
1986        self.edit_error.lock().unwrap().clone()
1987    }
1988
1989    /// 弹窗拦截原因写(`None` = 清)。
1990    pub fn set_edit_error(&self, e: Option<String>) {
1991        *self.edit_error.lock().unwrap() = e;
1992        self.bump();
1993    }
1994
1995    /// 弹窗草稿与该账户留档是否不一致(dirty;添加模式恒为"有内容待保存")。
1996    pub fn account_edit_dirty(&self) -> bool {
1997        let editing = self.editing_account();
1998        let draft = self.ctp_form();
1999        match editing {
2000            Some(gw) => self.account(&gw).map(|a| a.form != draft).unwrap_or(false),
2001            None => draft != CtpForm::default() && !draft.td_user.trim().is_empty(),
2002        }
2003    }
2004
2005    /// 账户清单只读视图(注册序)。
2006    pub fn accounts(&self) -> Vec<AccountRow> {
2007        self.accounts.lock().unwrap().clone()
2008    }
2009
2010    /// 单行账户查询。
2011    pub fn account(&self, gateway: &str) -> Option<AccountRow> {
2012        self.accounts
2013            .lock()
2014            .unwrap()
2015            .iter()
2016            .find(|a| a.gateway == gateway)
2017            .cloned()
2018    }
2019
2020    /// 新增一行账户(**只写清册,不装配** —— 规划裁定「添加 ≠ 连接」)。
2021    ///
2022    /// `Err(原因)` 三种:派生名已存在(同 broker+账号重复添加)、别名重名(规划 Q3)、
2023    /// 达账户软上限 `MAX_ACCOUNTS`。宿主侧还有第二道 `Duplicate` 守卫;界面侧先拦是为了
2024    /// 不建出一个"注定被拒"的请求。
2025    pub fn add_account(&self, row: AccountRow) -> Result<(), String> {
2026        let mut acc = self.accounts.lock().unwrap();
2027        if acc.iter().any(|a| a.gateway == row.gateway) {
2028            return Err(format!("账户 {} 已存在(同 broker + 账号)", row.gateway));
2029        }
2030        let alias = row.alias.trim();
2031        if !alias.is_empty()
2032            && acc
2033                .iter()
2034                .any(|a| a.alias.trim().eq_ignore_ascii_case(alias))
2035        {
2036            return Err(format!("别名 {alias:?} 已被其他账户占用(别名须唯一)"));
2037        }
2038        if acc.len() >= MAX_ACCOUNTS {
2039            return Err(format!(
2040                "账户已达软上限 {MAX_ACCOUNTS} 个(先移除一个再添加)"
2041            ));
2042        }
2043        let has_alias = !row.alias.trim().is_empty();
2044        acc.push(row);
2045        drop(acc);
2046        if has_alias {
2047            // 带别名入册 → 立落盘标记(键 alias.<网关名> 要进 ui 段;空别名无键可写,不动标记)。
2048            self.alias_persist_due.store(true, Ordering::Release);
2049        }
2050        self.bump();
2051        Ok(())
2052    }
2053
2054    /// 是否还能添加账户。
2055    #[must_use]
2056    pub fn can_add_account(&self) -> bool {
2057        self.accounts.lock().unwrap().len() < MAX_ACCOUNTS
2058    }
2059
2060    /// 改一行账户的别名(按 gateway 主键;不存在 = false)。
2061    ///
2062    /// 别名只用于显示与选择,**不参与寻址**(寻址恒用派生注册名,规划 Q3)。
2063    /// 别名须**唯一**(与 `add_account` 同一守卫):空别名 = 不设别名(合法);
2064    /// 与他人重名 = false(调用方须显形,不得静默改成重名别名)。
2065    pub fn set_account_alias(&self, gateway: &str, alias: &str) -> bool {
2066        let alias = alias.trim().to_string();
2067        let mut acc = self.accounts.lock().unwrap();
2068        if !alias.is_empty()
2069            && acc
2070                .iter()
2071                .any(|a| a.gateway != gateway && a.alias.eq_ignore_ascii_case(&alias))
2072        {
2073            return false;
2074        }
2075        match acc.iter_mut().find(|a| a.gateway == gateway) {
2076            Some(a) => {
2077                a.alias = alias;
2078                drop(acc);
2079                self.alias_persist_due.store(true, Ordering::Release);
2080                self.bump();
2081                true
2082            }
2083            None => false,
2084        }
2085    }
2086
2087    /// `UiCmd::Connect` 已入列 → 四态置「装配中」并清掉上一次的失败原因。
2088    ///
2089    /// 登录态一并退回 `Unknown`(0.2.19):新一轮装配开始时,上一轮的「在线/掉线」
2090    /// 不再是这一轮的事实 —— 留着会让用户在重连过程中看到「在线」。
2091    pub fn note_connect_sent(&self, gateway: &str) {
2092        let mut acc = self.accounts.lock().unwrap();
2093        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2094            a.stage = AccountStage::Assembling;
2095            a.fail_note = None;
2096            a.login = LoginState::Unknown;
2097            a.status_note = None;
2098            a.login_brief = None;
2099            // 链状态同理作废(0.5.0):上一轮会话的「查询合约中」不是这一轮的事实。
2100            a.chain_note = None;
2101            drop(acc);
2102            self.bump();
2103        }
2104    }
2105
2106    /// 装配结果回写:`Ok(())` → 「实例已装配」;`Err(原因)` → 「装配失败」+ 原因留显形。
2107    ///
2108    /// 失败**不回滚清册行**(规划 U3 定盘:行留着,给『重试』;旧口径"必须先移除才能重试"作废)。
2109    pub fn note_connect_result(&self, gateway: &str, result: Result<(), String>) {
2110        let mut acc = self.accounts.lock().unwrap();
2111        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2112            match result {
2113                Ok(()) => {
2114                    a.stage = AccountStage::Assembled;
2115                    a.fail_note = None;
2116                }
2117                Err(e) => {
2118                    a.stage = AccountStage::AssembleFailed;
2119                    a.fail_note = Some(e);
2120                }
2121            }
2122            drop(acc);
2123            self.bump();
2124        }
2125    }
2126
2127    /// 记录网关实际收录的产品类(`ConfigureReply.product_classes` 回带,规划 U9)。
2128    ///
2129    /// 只存不改用户勾选:勾选是意图,这一栏是事实,两者并排显示。
2130    pub fn note_effective_product_classes(&self, gateway: &str, classes: &[String]) {
2131        let mut acc = self.accounts.lock().unwrap();
2132        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2133            a.effective_classes = classes.to_vec();
2134            drop(acc);
2135            self.bump();
2136        }
2137    }
2138
2139    /// 链状态行落账(0.5.0,用户定):`note` = `[链] ` 前缀之后的正文,账户表「阶段」列显示它。
2140    ///
2141    /// 只认 ek-ctp 推的状态行(调用点在 `plugin.rs` 的 eLog 分支按前缀筛),普通日志不动这一栏。
2142    /// 没有对应账户行 = 什么都不做(网关还没进清册,状态行先到是正常时序)。
2143    pub fn note_chain(&self, gateway: &str, note: &str) {
2144        let mut acc = self.accounts.lock().unwrap();
2145        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2146            if a.chain_note.as_deref() == Some(note) {
2147                return; // 同一条状态重复推(例如重连时同一阶段再来一次)不 bump、不刷帧
2148            }
2149            a.chain_note = Some(note.to_string());
2150            drop(acc);
2151            self.bump();
2152        }
2153    }
2154
2155    // ---- 0.2.19 状态面(`{gw}.status` 轮询结果落账;规划 P3 / 裁定 3) ----
2156
2157    /// 当前生效的「查询断供」阈值:`(连续被拒次数, 资金回报超时秒)`。
2158    #[must_use]
2159    pub fn status_thresholds(&self) -> (u32, i64) {
2160        self.status_thresholds
2161            .lock()
2162            .unwrap()
2163            .unwrap_or((QUERY_REJECT_STREAK_LIMIT, ACCOUNT_STALE_SECS))
2164    }
2165
2166    /// 覆盖阈值(测试夹具把两判据收紧到「1 次 / 0 秒」,才好在没有真柜台的用例里显形)。
2167    pub fn set_status_thresholds(&self, reject_limit: u32, stale_secs: i64) {
2168        *self.status_thresholds.lock().unwrap() = Some((reject_limit, stale_secs));
2169        self.bump();
2170    }
2171
2172    /// 管制面快照的轮询节拍:eTimer 每拍调一次。
2173    ///
2174    /// 三个条件任一为真就问:① 被标记(插件上/下线、svctl 起停);② 第一拍(界面刚
2175    /// 起来不该空着等 4 秒);③ 每 `LIST_POLL_TICKS` 拍一次。
2176    pub fn list_poll_due(&self) -> bool {
2177        let n = self.list_tick.fetch_add(1, Ordering::Relaxed) + 1;
2178        self.list_due.swap(false, Ordering::AcqRel) || n == 1 || n.is_multiple_of(LIST_POLL_TICKS)
2179    }
2180
2181    /// 置「下一拍就重问管制面」(插件上下线、svctl 起停回执之后)。
2182    pub fn request_list_refresh(&self) {
2183        self.list_due.store(true, Ordering::Release);
2184    }
2185
2186    /// 置「重播种一次四张表」(网关或 oms 上线之后)。消费方 = 命令线程。
2187    pub fn request_trade_reseed(&self) {
2188        self.seed_due.store(true, Ordering::Release);
2189    }
2190
2191    /// 取走「重播种」标记(消费性,不是一档状态)。
2192    pub fn take_trade_reseed(&self) -> bool {
2193        self.seed_due.swap(false, Ordering::AcqRel)
2194    }
2195
2196    /// 这一拍该问哪几个账户的 `{gw}.status`(0.5 M3 摊平)。
2197    ///
2198    /// 入参 = 当前「已装配且在场」的网关名;返回不超过 `STATUS_ASKS_PER_TICK` 个,
2199    /// 从游标起环形取 —— 8 个账户 4 拍走完一轮,单拍 ask 上限恒定。
2200    pub fn status_batch(&self, present: &[String]) -> Vec<String> {
2201        // 先按名去重:账户清册允许两行共用同一个网关(第二行 `svctl.start` 回「已在场」
2202        // 而行仍在清册里),而 `{gw}.status` 是按网关寻址的同一个服务口 —— 不去重就是
2203        // 同一拍里白问两次,而且把环形节律打乱(两个格子问同一个网关,别的网关没人问)。
2204        let mut uniq: Vec<String> = Vec::with_capacity(present.len());
2205        for gw in present {
2206            if !uniq.iter().any(|u| u == gw) {
2207                uniq.push(gw.clone());
2208            }
2209        }
2210        let n = uniq.len();
2211        if n == 0 {
2212            return Vec::new();
2213        }
2214        let start = self.status_cursor.fetch_add(1, Ordering::Relaxed) % n;
2215        let take = STATUS_ASKS_PER_TICK.min(n);
2216        let mut out = Vec::with_capacity(take);
2217        for i in 0..take {
2218            out.push(uniq[(start + i) % n].clone());
2219        }
2220        out
2221    }
2222
2223    /// 记「该给这个网关播种一次状态」(0.7.0 第3刀;按名去重:同一网关只排一次)。
2224    pub fn request_status_seed(&self, gateway: &str) {
2225        let mut q = self.status_seed.lock().unwrap();
2226        if !q.iter().any(|g| g == gateway) {
2227            q.push(gateway.to_string());
2228        }
2229    }
2230
2231    /// 取走全部待播种网关(排空;消费方是 eTimer 那一拍的 `seed_status`)。
2232    pub fn take_status_seeds(&self) -> Vec<String> {
2233        let mut q = self.status_seed.lock().unwrap();
2234        std::mem::take(&mut *q)
2235    }
2236
2237    /// 状态变化事件入账(0.7.0 第3刀;`eStatus.` 一次到货三件事)。
2238    ///
2239    /// 1. **文案逐字进日志环**:文本由生产者(ek-ctp `describe()`)生成,界面不拼句子;
2240    /// 2. **状态列回写**:整份快照直接喂 `note_gateway_status`(在线/掉线/断供判据
2241    ///    吃的是同一份事实,不再回查);
2242    /// 3. **去重告警**:失败类六款(断开/认证失败/登录失败/查询停止/查询被拒/订阅被拒)
2243    ///    各弹一次;去重键带**会话标记**(`td_disconnects` / `md_disconnects`),
2244    ///    重连后的同一款算新事件(旧会话的账不替新会话挡告警)。成功类不弹(降噪:
2245    ///    文案与状态列已经说话)。
2246    pub fn note_status_event(&self, ev: &ek_ctp::GatewayStatusEvent) {
2247        let gw = ev.gateway.as_str();
2248        let text = ev.describe();
2249        self.log_line(format!("[INFO][{gw}] {text}"));
2250        let note = text
2251            .strip_prefix(CHAIN_STATE_PREFIX)
2252            .map(|s| s.trim().to_string())
2253            .unwrap_or_else(|| text.clone());
2254        self.note_chain(gw, &note);
2255        self.note_gateway_status(gw, &ev.status);
2256        let alarm_kind = matches!(
2257            ev.change,
2258            ek_ctp::GatewayChange::TdDisconnected
2259                | ek_ctp::GatewayChange::TdAuthFailed
2260                | ek_ctp::GatewayChange::TdLoginFailed
2261                | ek_ctp::GatewayChange::ContractQueryStopped
2262                | ek_ctp::GatewayChange::QueryRejected
2263                | ek_ctp::GatewayChange::MdDisconnected
2264                | ek_ctp::GatewayChange::MdLoginFailed
2265                | ek_ctp::GatewayChange::SubscribeRejected
2266        );
2267        if alarm_kind {
2268            // 会话标记:TD 侧款看 td 断开数,MD 侧款看 md 断开数(重连 → 数变 → 新事件)
2269            let session = if ev.change.kind().starts_with("md-") {
2270                ev.status.md_disconnects
2271            } else {
2272                ev.status.td_disconnects
2273            };
2274            let key = format!("estatus:{gw}:{}:{session}", ev.change.kind());
2275            if self.alert_once(key) {
2276                self.push_alert(
2277                    crate::toast::AlertLevel::Warning,
2278                    format!("账户 {gw}:{}", note.trim()),
2279                );
2280            }
2281        }
2282    }
2283
2284    /// 置「该问一次 oms 装配」(0.5 M3:事件触发而不是节拍)。
2285    pub fn request_pipeline_refresh(&self) {
2286        self.pipeline_due.store(true, Ordering::Release);
2287    }
2288    /// 消费「该问一次 oms 装配」标记(取一次即清)。
2289    pub fn take_pipeline_due(&self) -> bool {
2290        self.pipeline_due.swap(false, Ordering::AcqRel)
2291    }
2292
2293    /// 置「该问一次段落目录」(0.6.0 环节8;运维窗钮)。
2294    pub fn request_cfg_refresh(&self) {
2295        self.cfg_due.store(true, Ordering::Release);
2296    }
2297
2298    /// 消费「该问一次段落目录」标记(取一次即清)。
2299    pub fn take_cfg_due(&self) -> bool {
2300        self.cfg_due.swap(false, Ordering::AcqRel)
2301    }
2302
2303    /// 当前界面档(0.8.0 配色双档;缺省 = 深色)。
2304    pub fn theme(&self) -> crate::tokens::Theme {
2305        crate::tokens::Theme::from_u8(self.theme.load(Ordering::Acquire))
2306    }
2307
2308    /// 装档(0.8.0)。**不置落盘标记** —— 启动读盘、夹具显式设档走这一支,
2309    /// 用户手点才走 `toggle_theme`(只有手点才落盘,照参考站口径)。
2310    pub fn set_theme(&self, theme: crate::tokens::Theme) {
2311        self.theme.store(theme.as_u8(), Ordering::Release);
2312        self.bump();
2313    }
2314
2315    /// 用户点切换钮(0.8.0):切到另一档 + 立落盘标记,返回新档。
2316    pub fn toggle_theme(&self) -> crate::tokens::Theme {
2317        let next = self.theme().flipped();
2318        self.theme.store(next.as_u8(), Ordering::Release);
2319        self.theme_persist_due.store(true, Ordering::Release);
2320        self.bump();
2321        next
2322    }
2323
2324    /// 消费「该把当前档落盘」标记(命令线程用;渲染线程零文件 IO)。
2325    pub fn take_theme_persist_due(&self) -> bool {
2326        self.theme_persist_due.swap(false, Ordering::AcqRel)
2327    }
2328
2329    /// 用户手点「自动连接」勾选(0.9.0):更新行标 + 立落盘标记(手点才写 ui 段)。
2330    ///
2331    /// 守卫口径与既有账户面一致:行不在清册 = `Err`(不静默)。
2332    pub fn set_auto_connect(&self, gateway: &str, on: bool) -> Result<(), String> {
2333        {
2334            let mut acc = self.accounts.lock().unwrap();
2335            match acc.iter_mut().find(|a| a.gateway == gateway) {
2336                Some(a) => {
2337                    if a.auto_connect != on {
2338                        a.auto_connect = on;
2339                        self.auto_connect_persist_due.store(true, Ordering::Release);
2340                        self.bump();
2341                    }
2342                }
2343                None => return Err(format!("账户 {gateway} 不在清册")),
2344            }
2345        }
2346        Ok(())
2347    }
2348
2349    /// 消费「该把自动连接勾选态落盘 ui 段」标记(命令线程用;手点才真写文件)。
2350    pub fn take_auto_connect_persist_due(&self) -> bool {
2351        self.auto_connect_persist_due.swap(false, Ordering::AcqRel)
2352    }
2353
2354    /// 消费「该把别名表落盘」标记(命令线程用;渲染线程零文件 IO)。
2355    pub fn take_alias_persist_due(&self) -> bool {
2356        self.alias_persist_due.swap(false, Ordering::AcqRel)
2357    }
2358
2359    /// 取走并清空档案抹除待办(命令线程落盘拍消费;见 `archive_purge` 字段注释)。
2360    pub fn take_archive_purges(&self) -> Vec<String> {
2361        let mut q = self.archive_purge.lock().unwrap();
2362        std::mem::take(&mut *q)
2363    }
2364
2365    /// **启动清点 → 账户清单复原**(0.9.0)。
2366    ///
2367    /// 清点范围 = 该存档根里的 `ctp.*` 段(名单与连接参数的源);
2368    /// 每行是否「自动连接」= `ui` 段里的键 `auto_connect.<网关名>`("1" = 开;
2369    /// 键缺 = **关**,不善意)。密码**绝不进表单**:存档段里的密码是密库引用,
2370    /// `Field::secret` 键在这里只被读成「引用在位」的元信息(密码显示位用),
2371    /// 解密在实例 on_start 的 worker 时机(ek-ctp BD-CFG-16),与本函数无关。
2372    ///
2373    /// 复原行口径:`restored = true`、`auto_connect` 取档案值、
2374    /// `stage = NotAssembled`(实例只在「连接」/守护 auto_connect 时 spawn)。
2375    /// 已在清册的同名行**(手输先于复活的撞名)**跳过并显形 —— 清册行为唯一事实源,
2376    /// 清点不覆盖人手建的行。
2377    ///
2378    /// 返回:本次真正入库的复原行名(测试/首启说明用)。
2379    pub fn restore_saved_accounts_from(&self, store: &ek_cfg::Store) -> Vec<String> {
2380        // ui 段先读一次(缺 = 冷启动,全行 auto_connect = 关;别名键同理缺省无)。
2381        let mut flags: std::collections::BTreeMap<String, bool> = Default::default();
2382        let mut aliases: std::collections::BTreeMap<String, String> = Default::default();
2383        match store.load(crate::plugin::THEME_NS) {
2384            Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => {
2385                for (k, _f) in map.iter() {
2386                    if let Some(gw) = k.strip_prefix("auto_connect.") {
2387                        flags.insert(gw.to_string(), map.get_plain(k) == Some("1"));
2388                    }
2389                    if let Some(gw) = k.strip_prefix("alias.")
2390                        && let Some(v) = map.get_plain(k)
2391                    {
2392                        let v = v.trim();
2393                        if !v.is_empty() {
2394                            aliases.insert(gw.to_string(), v.to_string());
2395                        }
2396                    }
2397                }
2398            }
2399            Ok(_) => {}  // 冷启动:ui 段不在,零标志
2400            Err(_) => {} // ui 段存在但读不动(BD-CFG-4 应已兜底):标志照缺省关,显形下面统一出
2401        }
2402        let mut restored: Vec<String> = Vec::new();
2403        let ns_list = match store.list_namespaces() {
2404            Ok(v) => v,
2405            Err(e) => {
2406                // 0.9.39(用户实测「账户登录上去也没有了」):**显形**,不许静默空表。
2407                // 这一句同时进日志、进网关窗顶部(橙条)—— 空表要能自证"是读不出,不是丢了"。
2408                let msg = format!(
2409                    "档案清点失败:{e} —— 账户表会是空的,但**档案没丢**。\
2410                     多半是安全软件拦了本程序写 %APPDATA%\\ek\\cfg(放行后重启即恢复)"
2411                );
2412                self.log_line(format!("[ui] {msg}"));
2413                self.set_store_err(Some(msg));
2414                return restored;
2415            }
2416        };
2417        // 读通了 = 显形条撤掉(上一次失败留下的橙条不许赖着)。
2418        self.set_store_err(None);
2419        for ns in ns_list {
2420            let Some(gw) = ns.strip_prefix("ctp.") else {
2421                continue;
2422            };
2423            if self.account(gw).is_some() {
2424                self.log_line(format!(
2425                    "[ui] 档案 {ns} 与在册同名行撞名,清点不覆盖(清册行为唯一事实源)"
2426                ));
2427                continue;
2428            }
2429            let map = match store.load(&ns) {
2430                Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => map,
2431                Ok(_) => {
2432                    self.log_line(format!("[ui] 档案 {ns} 一段也读不出(跳过)"));
2433                    continue;
2434                }
2435                Err(e) => {
2436                    self.log_line(format!("[ui] 档案 {ns} 读不了:{e:?}(跳过)"));
2437                    continue;
2438                }
2439            };
2440            // 密码佈位:引用在位 = 表单的「密库引用」徽标依据(只读元信息)。
2441            let password_ref = map
2442                .get_secret_key("td_password")
2443                .is_some_and(|k| !k.is_empty());
2444            let mut form = CtpForm {
2445                broker_id: map.get_plain("broker_id").unwrap_or_default().to_owned(),
2446                td_addr: map.get_plain("td_addr").unwrap_or_default().to_owned(),
2447                td_user: map.get_plain("td_user").unwrap_or_default().to_owned(),
2448                td_password: String::new(),
2449                td_auth_code: map.get_plain("td_auth_code").unwrap_or_default().to_owned(),
2450                td_app_id: map.get_plain("td_app_id").unwrap_or_default().to_owned(),
2451                md_addr: map.get_plain("md_addr").unwrap_or_default().to_owned(),
2452                md_user: String::new(),
2453                md_password: String::new(),
2454                symbols: String::new(),
2455                accept_futures: false,
2456                accept_options: false,
2457            };
2458            // 2026-10-28 收录面裁定:期权全线退役;旧档里的 "1,2" 仍按**只期货**复原
2459            // (呈现层宁可收窄,不在复原时悄悄重开期权)。
2460            for c in map
2461                .get_plain("product_classes")
2462                .unwrap_or_default()
2463                .split(',')
2464            {
2465                if c.trim() == "1" {
2466                    form.accept_futures = true;
2467                }
2468            }
2469            let Ok(mut row) = AccountRow::derive(&form) else {
2470                self.log_line(format!(
2471                    "[ui] 档案 {ns} 无法派生(账号/前置非法别名冲突?),跳过 —— 界面手输重配更省心"
2472                ));
2473                continue;
2474            };
2475            // 注册名以**档案名**为准(0.9.10 多前置):档案段带 `_2` 后缀的行,
2476            // 派生基名与档案名不同 —— 复原跟着档案名走,否则同账号两套前置会撞回一行。
2477            row.gateway = gw.to_string();
2478            row.restored = true;
2479            row.auto_connect = flags.get(gw).copied().unwrap_or(false);
2480            row.password_ref = password_ref;
2481            // 别名标签(0.9.10):ui 段 alias.<网关名> 读回;撞名不硬塞(清册唯一性
2482            // 守卫优先),显形一句,行照复原 —— 标签是显示面,不该把行挡在门外。
2483            if let Some(a) = aliases.get(gw) {
2484                let taken = self
2485                    .accounts
2486                    .lock()
2487                    .unwrap()
2488                    .iter()
2489                    .any(|x| x.alias.trim().eq_ignore_ascii_case(a));
2490                if taken {
2491                    self.log_line(format!(
2492                        "[ui] 档案别名 {a:?} 已被在册行占用,{gw} 不带标签复原(账户面板改别名即可)"
2493                    ));
2494                } else {
2495                    row.alias = a.clone();
2496                }
2497            }
2498            restored.push(row.gateway.clone());
2499            self.add_account(row).ok();
2500        }
2501        if !restored.is_empty() {
2502            self.log_line(format!(
2503                "[ui] 账户档案清点复原 {} 行(手点「连接」直连存档;自动连接按行上勾选)",
2504                restored.len()
2505            ));
2506            // **行情源默认(0.9.10 用户裁定)**:默认 = **第一个勾选自动连接的账户**
2507            // (清册顺序);全没勾 = 不设默认(用户自己选);用户已指定(源非空) = 以指定为主。
2508            if self.md_source.lock().unwrap().is_empty() {
2509                let first = self
2510                    .accounts
2511                    .lock()
2512                    .unwrap()
2513                    .iter()
2514                    .find(|a| a.auto_connect)
2515                    .map(|a| a.gateway.clone());
2516                if let Some(gw) = first
2517                    && self.set_md_source(&gw).is_ok()
2518                {
2519                    self.log_line(format!("[账户] 行情源(默认=第一个勾选自动连接的)= {gw}"));
2520                }
2521            }
2522            self.bump();
2523        }
2524        restored
2525    }
2526
2527    /// 复原入口(桌面端;生态缺省根)。根不可用 = 空表 + 显形(不落临时目录)。
2528    pub fn restore_saved_accounts(&self) -> Vec<String> {
2529        match ek_cfg::Store::eco() {
2530            Ok(store) => self.restore_saved_accounts_from(&store),
2531            Err(_) => {
2532                self.log_line(
2533                    "[ui] 档案清点跳过:config_dir() 不可用(BD-CFG-20;配置根异常该显形)".to_string(),
2534                );
2535                Vec::new()
2536            }
2537        }
2538    }
2539
2540    /// 段落目录回写(0.6.0)。
2541    pub fn set_cfg_briefs(&self, b: Vec<ek_cfg::wire::NsBrief>) {
2542        *self.cfg_briefs.lock().unwrap() = Some(b);
2543        self.bump();
2544    }
2545    /// 段落目录只读视图(0.6.0;`None` = 从未问过)。
2546    pub fn cfg_briefs(&self) -> Option<Vec<ek_cfg::wire::NsBrief>> {
2547        self.cfg_briefs.lock().unwrap().clone()
2548    }
2549
2550    /// 清段二次确认臂(0.6.0;`Some(ns)` = 已点过一次,下一帧再点同一行才真发)。
2551    pub fn cfg_delete_armed(&self) -> Option<String> {
2552        self.cfg_delete_arm.lock().unwrap().clone()
2553    }
2554    pub fn arm_cfg_delete(&self, ns: Option<String>) {
2555        *self.cfg_delete_arm.lock().unwrap() = ns;
2556        self.bump();
2557    }
2558
2559    /// `oms.pipeline_status` 读数回写(装配事实,不是业务事实)。
2560    pub fn set_pipeline_lines(&self, lines: Vec<String>) {
2561        *self.pipeline_lines.lock().unwrap() = lines;
2562        self.bump();
2563    }
2564
2565    /// 最新一次的 oms 装配显形读数(空 = 运维窗不画这一节)。
2566    pub fn pipeline_lines(&self) -> Vec<String> {
2567        self.pipeline_lines.lock().unwrap().clone()
2568    }
2569
2570    /// 某条推送线的载荷类型不对(0.5 M1)。
2571    ///
2572    /// 链上这几条线的载荷类型不是编译期保证:`ePosition.` 的载荷「由广发方确定」
2573    /// (ek-oms plugin.rs:329 同样只能 downcast 试)。收不进去就计数,并在**第一次**
2574    /// 显形一条日志 —— 静默丢等于「界面少了一路数据而没人知道」。
2575    pub fn note_bad_payload(&self, line: &str) {
2576        let mut book = self.bad_payload.lock().unwrap();
2577        let n = book.entry(line.to_string()).or_insert(0);
2578        *n += 1;
2579        if *n == 1 {
2580            self.log_line(format!(
2581                "[ui] {line} 载荷类型异常(第一次):这一路的行进不了账本"
2582            ));
2583        }
2584    }
2585
2586    /// 某条推送线累计收不进去多少条(测试判据)。
2587    #[must_use]
2588    pub fn bad_payload_count(&self, line: &str) -> u64 {
2589        self.bad_payload
2590            .lock()
2591            .unwrap()
2592            .get(line)
2593            .copied()
2594            .unwrap_or(0)
2595    }
2596
2597    /// 插件邮箱**水位与触顶**的显形(0.4.5 P7d 首发;0.7.0 背压刀改口径)。
2598    ///
2599    /// 新口径(随 ek-core 0.5.0):邮箱**无界默认不丢** → `spilled` 只在触到
2600    /// 该插件自己的**软顶**时才涨,所以「spilled > 0」不再意味着「丢过几条行情」,
2601    /// 而是**已经触顶**——一件红色大事。默认不丢之后,现场真正要看的是
2602    /// **积压多深**(消费侧滞后 = 刷新慢),所以这里从 0.5 的"溢出告警"扩成
2603    /// 三段读数,全部走快照逐行(数据源 = ek-station 0.4.0 的 `pending/peak/soft_cap`):
2604    /// - 积压越黄阈(软顶 1/16,下限 4096)→ Warning;
2605    /// - 积压越红阈(软顶 1/4,下限 32768)→ Error:再涨就要丢新帧了;
2606    /// - `spilled > 0`(已触顶)→ Error:此刻起每多一条就丢一条**新到**的事件。
2607    ///
2608    /// 去重口径照旧(告警是状态事实,不是渲染帧的副作用):每个插件每一级只弹一次,
2609    /// 持续状态由运维窗那一列**常显**(告警负责"发生",窗口负责"一直在")。
2610    pub fn note_plugin_spill(&self, rows: &[ek_station::wire::PlugRow]) {
2611        for r in rows {
2612            let (warn, crit) = pending_thresholds(r.soft_cap);
2613            if r.pending >= warn && self.alert_once(format!("pend-warn:{}", r.name)) {
2614                self.push_alert(
2615                    crate::toast::AlertLevel::Warning,
2616                    format!(
2617                        "插件 {}:邮箱积压 {} 帧(峰值 {};该插件软顶 {})——它的 worker 消化不过来,刷新会变慢",
2618                        r.name, r.pending, r.peak, r.soft_cap
2619                    ),
2620                );
2621            }
2622            if r.pending >= crit && self.alert_once(format!("pend-crit:{}", r.name)) {
2623                self.push_alert(
2624                    crate::toast::AlertLevel::Error,
2625                    format!(
2626                        "插件 {}:邮箱积压 {} 帧,越过软顶 {} 的四分之一——再涨就开始丢新到的事件",
2627                        r.name, r.pending, r.soft_cap
2628                    ),
2629                );
2630            }
2631            if r.spilled > 0 && self.alert_once(format!("spill:{}", r.name)) {
2632                self.push_alert(
2633                    crate::toast::AlertLevel::Error,
2634                    format!(
2635                        "插件 {}:已触软顶 {} 并丢弃新到事件累计 {} 条(峰值 {})——它的消化速度跟不上生产速度",
2636                        r.name, r.soft_cap, r.spilled, r.peak
2637                    ),
2638                );
2639            }
2640        }
2641    }
2642
2643    /// `{gw}.status` 读数回写 → 该行登录态与细节重算。
2644    ///
2645    /// 0.3.2(Wave E,BD-UI-50)这里同时是**告警的唯一产生口**:轮询一拍问出告警态就灌 toast。
2646    /// 为什么住这儿而不是账户面板的渲染里(Python `AlertEngine` 的对照口径):
2647    /// 告警是「状态口问出来的事实」,不是「某张窗画出来的东西」——账户窗没开也必须知道掉线了。
2648    /// 去重键 = 网关 + 登录态:同一状态只弹一次,状态变了才算新告警(否则每轮轮询一条,
2649    /// 8 秒自动关与同屏上限 3 都没有意义)。
2650    pub fn note_gateway_status(&self, gateway: &str, st: &ek_ctp::plugin::GatewayStatus) {
2651        let (limit, stale) = self.status_thresholds();
2652        let now = ek_ctp::map::now_epoch_ms();
2653        // 先在账户锁里把「这一拍是不是告警态」读出来,出锁再发 toast
2654        // (accounts / alerted / pending_alerts 是三颗独立的锁,不嵌套持有)。
2655        let mut alarm: Option<(LoginState, String)> = None;
2656        let mut found = false;
2657        {
2658            let mut acc = self.accounts.lock().unwrap();
2659            if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2660                found = true;
2661                a.apply_status(st, now, limit, stale);
2662                if a.login.is_alarm() || a.login == LoginState::QueryStarved {
2663                    alarm = Some((
2664                        a.login,
2665                        a.status_note
2666                            .clone()
2667                            .unwrap_or_else(|| a.login.label().to_string()),
2668                    ));
2669                }
2670            }
2671        }
2672        if found {
2673            self.bump();
2674        }
2675        if let Some((login, note)) = alarm
2676            && self.alert_once(format!("{gateway}|{}", login.label()))
2677        {
2678            self.push_alert(
2679                crate::toast::AlertLevel::Error,
2680                format!("账户 {gateway}:{note}"),
2681            );
2682        }
2683    }
2684
2685    /// 状态口问不到(网关不在场 / ask 超时):登录态退回 `Unknown` 并写明原因。
2686    ///
2687    /// **不得保留上一轮的「在线」**:问不到就是没有证据,留着旧读数等于替网关谎称在线。
2688    pub fn note_status_unreachable(&self, gateway: &str, why: &str) {
2689        let mut acc = self.accounts.lock().unwrap();
2690        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2691            a.login = LoginState::Unknown;
2692            a.status_note = Some(format!("状态口问不到:{why}"));
2693            a.login_brief = None;
2694            drop(acc);
2695            self.bump();
2696        }
2697    }
2698
2699    /// 『断开』已发令 → 该行立刻显形「已断开」(不等下一轮轮询;用户按了就该马上看到)。
2700    pub fn mark_account_stopped(&self, gateway: &str) {
2701        let mut acc = self.accounts.lock().unwrap();
2702        if let Some(a) = acc.iter_mut().find(|a| a.gateway == gateway) {
2703            a.login = LoginState::Stopped;
2704            a.status_note = Some("会话已停;『连接』可恢复".to_string());
2705            a.login_brief = None;
2706            drop(acc);
2707            self.bump();
2708        }
2709    }
2710
2711    /// 装配前把表单落到该账户留档,并按**全局单源纪律**裁决 md 位:
2712    /// 非行情源账户 → `md_addr` 就地清空(**留档与下发同一份**,消 U4 的"只清下发不清留档")。
2713    pub fn prepare_connect_form(&self, gateway: &str, form: CtpForm) -> CtpForm {
2714        let source = self.md_source();
2715        let mut f = form;
2716        if source.as_str() != gateway {
2717            f.md_addr.clear();
2718            f.md_user.clear();
2719            f.md_password.clear();
2720        }
2721        self.update_account_form(gateway, f.clone());
2722        f
2723    }
2724
2725    /// 『添加账户』弹窗的草稿位:沿用当前在场账户的**非凭据字段**
2726    /// (broker / AppID / AuthCode / 收录勾选),凭据位(账号、密码、前置)一律清空
2727    /// (规划 Q20 的零风险缓解:少敲几遍,但绝不把上一家的密码带进下一家)。
2728    #[must_use]
2729    pub fn draft_from_existing(&self) -> CtpForm {
2730        let acc = self.accounts.lock().unwrap();
2731        let mut draft = CtpForm::default();
2732        if let Some(a) = acc.last() {
2733            draft.broker_id = a.form.broker_id.clone();
2734            draft.td_auth_code = a.form.td_auth_code.clone();
2735            draft.td_app_id = a.form.td_app_id.clone();
2736            draft.accept_futures = a.form.accept_futures;
2737            draft.accept_options = a.form.accept_options;
2738        }
2739        draft
2740    }
2741
2742    /// 更新一行账户的表单留档(按 gateway 主键;不存在 = false)。
2743    pub fn update_account_form(&self, gateway: &str, form: CtpForm) -> bool {
2744        let mut acc = self.accounts.lock().unwrap();
2745        match acc.iter_mut().find(|a| a.gateway == gateway) {
2746            Some(a) => {
2747                a.form = form;
2748                drop(acc);
2749                self.bump();
2750                true
2751            }
2752            None => false,
2753        }
2754    }
2755
2756    /// 摘除一行账户(界面侧清册;网关实例由 `UiCmd::Remove` 走 svctl 摘)。
2757    pub fn remove_account(&self, gateway: &str) -> bool {
2758        let mut acc = self.accounts.lock().unwrap();
2759        let before = acc.len();
2760        acc.retain(|a| a.gateway != gateway);
2761        let removed = acc.len() != before;
2762        if removed {
2763            // 元账号被摘 → 源位清空(等用户重新指定);订阅集保留(规划 §5.C)。
2764            let mut md = self.md_source.lock().unwrap();
2765            if md.as_str() == gateway {
2766                md.clear();
2767            }
2768            drop(acc);
2769            // 摘行 → 别名键也要跟着清(不留魂;同 auto_connect 的摘户纪律)。
2770            self.alias_persist_due.store(true, Ordering::Release);
2771            // 摘行 → 档案段入抹除待办(不抹 = 下次启动清点复活,用户实锤病根)。
2772            self.archive_purge.lock().unwrap().push(gateway.to_string());
2773            self.bump();
2774        }
2775        removed
2776    }
2777
2778    /// 用 svctl 快照回填账户在场位与四态;回带**本轮新装配**的网关名(订阅重放用)。
2779    ///
2780    /// 四态口径:svctl 带回这个名字 = 实例已装配(清掉失败原因);
2781    /// 名字不在场且原先记着「已装配」= 回到「未装配」(被摘/宿主崩了);
2782    /// 「装配中」与「装配失败」是界面侧记的,**不被在场表覆盖**。
2783    ///
2784    /// 0.2.19 附带一条:实例不在场时**登录态读数一并作废** —— 会话读数属于那个实例,
2785    /// 实例没了还留着「在线/掉线 2 次」,就是在替一个不存在的会话说话。
2786    pub fn sync_accounts_connected(&self, rows: &[PlugRow]) -> Vec<String> {
2787        let mut newly = Vec::new();
2788        {
2789            let mut acc = self.accounts.lock().unwrap();
2790            for a in acc.iter_mut() {
2791                let up = rows.iter().any(|r| r.name == a.gateway);
2792                if up && !a.connected {
2793                    newly.push(a.gateway.clone());
2794                }
2795                a.connected = up;
2796                if up {
2797                    a.stage = AccountStage::Assembled;
2798                    a.fail_note = None;
2799                } else {
2800                    // 实例不在场 = 这一条会话没了:链状态同理作废(0.5.0「阶段」栏)。
2801                    // 留着就是替一个不存在的会话说话(与上面「登录态读数一并作废」同一条理由)。
2802                    a.chain_note = None;
2803                    if a.stage == AccountStage::Assembled {
2804                        a.stage = AccountStage::NotAssembled;
2805                        a.login = LoginState::Unknown;
2806                        a.status_note = None;
2807                        a.login_brief = None;
2808                    }
2809                }
2810            }
2811        }
2812        if !newly.is_empty() {
2813            self.bump();
2814        }
2815        newly
2816    }
2817
2818    /// 行情元账号注册名(空 = 未选)。
2819    pub fn md_source(&self) -> String {
2820        self.md_source.lock().unwrap().clone()
2821    }
2822
2823    /// 是否已有账户装配 —— 元账号锁定判定(规划 D3:连接后不允许切换)。
2824    pub fn accounts_locked(&self) -> bool {
2825        self.accounts.lock().unwrap().iter().any(|a| a.connected)
2826    }
2827
2828    /// 指定行情元账号(仅配置阶段;任何账户已连接 = 拒,界面须提示"先断开")。
2829    pub fn set_md_source(&self, gateway: &str) -> Result<(), String> {
2830        {
2831            let acc = self.accounts.lock().unwrap();
2832            if acc.iter().any(|a| a.connected) {
2833                return Err("元账号已锁定:有账户在线,切换行情源需先断开该账户".to_string());
2834            }
2835            if !acc.iter().any(|a| a.gateway == gateway) {
2836                return Err(format!("账户 {gateway} 不在清册"));
2837            }
2838        }
2839        let mut acc = self.accounts.lock().unwrap();
2840        for a in acc.iter_mut() {
2841            a.md_source = a.gateway == gateway;
2842        }
2843        drop(acc);
2844        let mut md = self.md_source.lock().unwrap();
2845        *md = gateway.to_string();
2846        self.bump();
2847        Ok(())
2848    }
2849
2850    /// 订阅集只读视图。
2851    pub fn subscriptions(&self) -> Vec<String> {
2852        self.subscriptions.lock().unwrap().clone()
2853    }
2854
2855    /// 登记订阅(去重保序;断开重连后按它重放)。
2856    pub fn note_subscriptions(&self, symbols: &[String]) {
2857        {
2858            let mut subs = self.subscriptions.lock().unwrap();
2859            for s in symbols {
2860                let s = s.trim();
2861                if !s.is_empty() && !subs.iter().any(|x| x == s) {
2862                    subs.push(s.to_string());
2863                }
2864            }
2865        }
2866        self.bump();
2867    }
2868
2869    /// 下单默认账户阶梯(替代旧"空 → 字面量 ctp"兜底,规划 §5.B;
2870    /// **0.2.18 四态后修正,规划 Q4**):
2871    /// 上次手动选择(须仍在清册且已装配)→ 第一个已装配账户;
2872    /// **未装配账户不得进默认** —— 旧阶梯的"退到第一个账户"会把只添加了还没连接的
2873    /// 账户选成下单归属,发出去只会撞 `unknown:gateway`。
2874    /// 一个都没有 = `None`(界面据此禁用下单钮)。
2875    pub fn default_gateway(&self) -> Option<String> {
2876        let chosen = {
2877            let f = self.order_form.lock().unwrap();
2878            f.gateway.trim().to_string()
2879        };
2880        if !chosen.is_empty() && self.account(&chosen).is_some_and(|a| a.connected) {
2881            return Some(chosen);
2882        }
2883        let acc = self.accounts.lock().unwrap();
2884        acc.iter().find(|a| a.connected).map(|a| a.gateway.clone())
2885    }
2886
2887    /// 已开 K线窗合约表(多开,去重)。
2888    pub fn kline_symbols(&self) -> Vec<String> {
2889        self.kline_windows.lock().unwrap().clone()
2890    }
2891
2892    /// 开 K线窗(去重;日志留痕可断言)。
2893    pub fn open_kline(&self, symbol: impl Into<String>) {
2894        let s = symbol.into();
2895        let mut v = self.kline_windows.lock().unwrap();
2896        if !v.contains(&s) {
2897            v.push(s.clone());
2898        }
2899        drop(v);
2900        self.log_line(format!("[kline] 打开 K线窗 {s}"));
2901        // I2 第一刀:开窗即入队一次历史补数(tq 桥;周期跟 dm 缺省 1 分钟档,
2902        // 符号没带交易所前缀就用当前行情 tab 的所补齐成 en_symbol)。
2903        let fq = if s.contains('.') {
2904            s.clone()
2905        } else {
2906            format!("{}.{}", self.selected_exchange(), s)
2907        };
2908        const KLINE_INTERVAL_SECS: i64 = 60;
2909        {
2910            let mut fetched = self.kline_fetched.lock().unwrap();
2911            if fetched.contains(&(fq.clone(), KLINE_INTERVAL_SECS)) {
2912                return;
2913            }
2914            fetched.insert((fq.clone(), KLINE_INTERVAL_SECS));
2915        }
2916        self.kline_fetches.lock().unwrap().push(KlineFetch {
2917            symbol: fq,
2918            interval_secs: KLINE_INTERVAL_SECS,
2919        });
2920        self.log_line(format!("[kline] 入队历史补数 {s}(1min×300,tq 桥)"));
2921    }
2922
2923    /// 补数队列出口(命令线程 catch;非阻塞读,取走即清)。
2924    pub fn take_kline_fetches(&self) -> Vec<KlineFetch> {
2925        std::mem::take(&mut self.kline_fetches.lock().unwrap())
2926    }
2927
2928    /// 命令线程路由位:有补数单待发。
2929    pub fn kline_fetch_pending(&self) -> bool {
2930        !self.kline_fetches.lock().unwrap().is_empty()
2931    }
2932
2933    /// 关 K线窗(窗关闭钮 → 出表)。
2934    pub fn close_kline(&self, symbol: &str) {
2935        self.kline_windows.lock().unwrap().retain(|s| s != symbol);
2936        self.bump();
2937    }
2938
2939    // ---- v4 查询合约窗面(合约库 / 查询串 / 勾选集) ----
2940
2941    /// 查询合约窗开合。
2942    pub fn contracts_window_open(&self) -> bool {
2943        self.contracts_window.load(Ordering::Acquire)
2944    }
2945
2946    /// 查询合约窗开合写。
2947    pub fn set_contracts_window_open(&self, v: bool) {
2948        self.contracts_window.store(v, Ordering::Release);
2949        self.bump();
2950    }
2951
2952    /// 落一条合约(`eContract.` 裸键上屏),并**回补已有行的名称**。
2953    ///
2954    /// 时序决定了这一步必须有:登录即上账的委托/成交回报**先于合约到手**(柜台在
2955    /// 登录后立刻回放当日回报,而合约查询要走完查询链),网关发它们时合约表还空着,
2956    /// 名字补不上(0.11.0 第5刀现场实测:委托 2/27、成交 1/14)。合约到货之后
2957    /// 把同名行补齐 —— 只补空名字,**已有名字不覆盖**;补名不改行序、不新增行。
2958    pub fn update_contract(&self, c: &ek_data::contract::ContractData) {
2959        self.contract_store.lock().unwrap().put(c);
2960        if !c.name.is_empty() {
2961            self.backfill_names(&c.symbol, &c.name);
2962        }
2963        self.bump();
2964    }
2965
2966    /// 名称回补(0.11.0 第5刀 R6):按代码补委托 / 成交 / 持仓三本账里的空名行。
2967    ///
2968    /// 名称是**交易所层面的合约属性**(与网关无关),所以跨网关按代码补;
2969    /// 补的是显示事实,不参与任何判据(在线/断供/归属守卫都不看这一栏)。
2970    fn backfill_names(&self, symbol: &str, name: &str) {
2971        let mut touched = false;
2972        {
2973            let mut rows = self.trade_orders.lock().unwrap();
2974            for (_, (_, o)) in rows.iter_mut() {
2975                if o.symbol == symbol && o.name.is_empty() {
2976                    o.name = name.to_string();
2977                    touched = true;
2978                }
2979            }
2980        }
2981        {
2982            let mut rows = self.trade_trades.lock().unwrap();
2983            for (_, t) in rows.iter_mut() {
2984                if t.symbol == symbol && t.name.is_empty() {
2985                    t.name = name.to_string();
2986                    touched = true;
2987                }
2988            }
2989        }
2990        {
2991            let mut rows = self.trade_positions.lock().unwrap();
2992            for (_, (_, p)) in rows.iter_mut() {
2993                if p.symbol == symbol && p.name.is_empty() {
2994                    p.name = name.to_string();
2995                    touched = true;
2996                }
2997            }
2998        }
2999        if touched {
3000            // 快照缓存要跟着作废(否则屏幕上还是旧名字)
3001            self.trade_dirty.store(true, Ordering::Release);
3002        }
3003    }
3004
3005    /// 合约查询(Python ContractManager 口径:留空 = 全部)。
3006    pub fn contracts(&self, pattern: &str) -> Vec<ek_data::contract::ContractData> {
3007        self.contract_store.lock().unwrap().query(pattern)
3008    }
3009
3010    /// 合约库格数。
3011    pub fn contract_count(&self) -> usize {
3012        self.contract_store.lock().unwrap().len()
3013    }
3014
3015    /// 按代码查合约名(0.11.0 第5刀 R6 的补名口;查不到 = `None`,不编名字)。
3016    fn contract_name_of(&self, symbol: &str) -> Option<String> {
3017        self.contract_store
3018            .lock()
3019            .unwrap()
3020            .query(symbol)
3021            .into_iter()
3022            .find(|c| c.symbol == symbol && !c.name.is_empty())
3023            .map(|c| c.name)
3024    }
3025
3026    /// 查询串读写。
3027    pub fn contract_query(&self) -> String {
3028        self.contract_query.lock().unwrap().clone()
3029    }
3030
3031    /// 查询串写。
3032    pub fn set_contract_query(&self, q: impl Into<String>) {
3033        *self.contract_query.lock().unwrap() = q.into();
3034        self.bump();
3035    }
3036
3037    /// 勾选翻转(en_symbol)。
3038    pub fn toggle_contract_selection(&self, en_symbol: &str) {
3039        let mut v = self.contract_selected.lock().unwrap();
3040        match v.iter().position(|s| s == en_symbol) {
3041            Some(i) => {
3042                v.remove(i);
3043            }
3044            None => v.push(en_symbol.to_string()),
3045        }
3046        self.bump();
3047    }
3048
3049    /// 当前勾选集。
3050    pub fn selected_contracts(&self) -> Vec<String> {
3051        self.contract_selected.lock().unwrap().clone()
3052    }
3053
3054    /// 撤单拒因翻译(0.11.0 第6刀 R7):机器码 → 人话,**原文照留**。
3055    ///
3056    /// oms 的拒因是结构化机器码(判据/日志吃它,不能改),屏幕上要给一句人话。
3057    /// 这一条住在状态层(不是渲染层),与告警同一口径:事实的产生口说什么,
3058    /// 屏幕上就说什么,渲染不参与翻译。
3059    #[must_use]
3060    pub fn cancel_reject_note(msg: &str) -> String {
3061        let head = msg.split(' ').next().unwrap_or(msg);
3062        let note = if head.starts_with("unknown:order") {
3063            "这台网关的账本里没有这笔委托(登录前的旧单,或不属于这个账户)"
3064        } else if head.starts_with("mismatch:gateway") {
3065            "该委托不属于这个账户(归属守卫挡住,不会撤到别人头上)"
3066        } else if head.starts_with("terminal:status") {
3067            "这笔委托已经到终态(已成交/已撤/已拒),撤不了了"
3068        } else if head.starts_with("limit:cancel-guard") {
3069            "撤单尝试次数已到上限(每单有上限,防连点打爆柜台)"
3070        } else {
3071            ""
3072        };
3073        if note.is_empty() {
3074            // 认不出的机器码:照原文,不编故事
3075            format!("撤单被拒:{msg}")
3076        } else {
3077            format!("撤单被拒:{note}(机器码:{msg})")
3078        }
3079    }
3080
3081    /// 追加一行日志(总线面/宿主观察线程写)。
3082    pub fn log_line(&self, line: impl Into<String>) {
3083        self.logs.lock().unwrap().push(line);
3084        self.bump();
3085    }
3086
3087    /// 清空日志(渲染"清空日志"按钮 / 测试)。
3088    pub fn clear_logs(&self) {
3089        self.logs.lock().unwrap().clear();
3090        self.bump();
3091    }
3092
3093    /// 日志只读快照 + 溢出计数。
3094    pub fn logs_snapshot(&self) -> (Vec<String>, u64) {
3095        let r = self.logs.lock().unwrap();
3096        (r.snapshot(), r.spilled())
3097    }
3098
3099    /// 带时间戳的日志快照(BD-UI-41 渲染侧:日志表格的「时间」列)。
3100    pub fn logs_entries(&self) -> (Vec<LogEntry>, u64) {
3101        let r = self.logs.lock().unwrap();
3102        (r.entries(), r.spilled())
3103    }
3104
3105    /// 日志当前长度(kittest 断言用)。
3106    pub fn logs_len(&self) -> usize {
3107        self.logs.lock().unwrap().len()
3108    }
3109
3110    /// 落一次服务快照(eTimer 节流回调)。
3111    pub fn set_snapshot(&self, snap: BusSnap) {
3112        *self.snap.lock().unwrap() = Arc::new(snap);
3113        self.bump();
3114    }
3115
3116    /// 当前快照(Arc 克隆,零拷贝走锁)。
3117    pub fn snapshot(&self) -> Arc<BusSnap> {
3118        Arc::clone(&self.snap.lock().unwrap())
3119    }
3120
3121    /// revision:任何写入 +1(插件面 ePlug 事件也用;测试判定"有事件进来过")。
3122    pub fn rev(&self) -> u64 {
3123        self.rev.load(Ordering::Acquire)
3124    }
3125
3126    /// 手动推一次 revision(总线面 ePlugUp/Down 记账用)。
3127    pub fn bump(&self) {
3128        self.rev.fetch_add(1, Ordering::AcqRel);
3129    }
3130
3131    /// 置关窗信号(eframe App 关窗钩子调用)。
3132    pub fn request_stop(&self) {
3133        self.stop.store(true, Ordering::Release);
3134    }
3135
3136    /// 观察线程轮询用。
3137    /// 0.3.2(Wave E,BD-UI-50)告警入口:界面层调它,渲染帧灌进 toast 队列。
3138    pub fn push_alert(&self, level: crate::toast::AlertLevel, text: impl Into<String>) {
3139        self.pending_alerts
3140            .lock()
3141            .unwrap_or_else(std::sync::PoisonError::into_inner)
3142            .push((level, text.into()));
3143    }
3144
3145    /// 取走待灌的告警(render 每帧一次)。
3146    pub(crate) fn take_alerts(&self) -> Vec<(crate::toast::AlertLevel, String)> {
3147        let mut q = self
3148            .pending_alerts
3149            .lock()
3150            .unwrap_or_else(std::sync::PoisonError::into_inner);
3151        std::mem::take(&mut *q)
3152    }
3153
3154    /// 常驻 toast 队列。
3155    pub(crate) fn toasts(&self) -> std::sync::MutexGuard<'_, Option<egui_notify::Toasts>> {
3156        self.toasts.lock()
3157    }
3158
3159    /// 去重:这条告警第一次出现返回 true(同一键不再重复弹)。
3160    pub fn alert_once(&self, key: impl Into<String>) -> bool {
3161        let key = key.into();
3162        self.alerted
3163            .lock()
3164            .unwrap_or_else(std::sync::PoisonError::into_inner)
3165            .insert(key)
3166    }
3167
3168    /// 待灌进 toast 的告警条数(状态层判据用;灌进队列由渲染帧收口)。
3169    #[must_use]
3170    pub fn pending_alert_count(&self) -> usize {
3171        self.pending_alerts
3172            .lock()
3173            .unwrap_or_else(std::sync::PoisonError::into_inner)
3174            .len()
3175    }
3176
3177    /// 已弹过的告警键数(去重账本;判据用它核「同一状态不重复弹」)。
3178    #[must_use]
3179    pub fn alerted_count(&self) -> usize {
3180        self.alerted
3181            .lock()
3182            .unwrap_or_else(std::sync::PoisonError::into_inner)
3183            .len()
3184    }
3185
3186    /// 退出确认窗:开 / 关 / 是否已确认放行(0.3.1 BD-UI-49)。
3187    pub fn exit_confirm_open(&self) -> bool {
3188        self.exit_confirm_open
3189            .load(std::sync::atomic::Ordering::Relaxed)
3190    }
3191
3192    pub fn open_exit_confirm(&self) {
3193        self.exit_confirm_open
3194            .store(true, std::sync::atomic::Ordering::Relaxed);
3195    }
3196
3197    pub fn close_exit_confirm(&self) {
3198        self.exit_confirm_open
3199            .store(false, std::sync::atomic::Ordering::Relaxed);
3200    }
3201
3202    pub fn exit_confirmed(&self) -> bool {
3203        self.exit_confirmed
3204            .load(std::sync::atomic::Ordering::Relaxed)
3205    }
3206
3207    pub fn confirm_exit(&self) {
3208        self.exit_confirmed
3209            .store(true, std::sync::atomic::Ordering::Relaxed);
3210    }
3211
3212    pub fn is_stop(&self) -> bool {
3213        self.stop.load(Ordering::Acquire)
3214    }
3215
3216    /// 装配锁是否在位(0.9.5 阶段机;只被界面读)。
3217    #[must_use]
3218    pub fn assembly_locked(&self) -> bool {
3219        self.assembly_locked.load(Ordering::Relaxed)
3220    }
3221
3222    /// 置装配锁(0.9.5;启动全成功 → true,失败 → 保持 false 留修路)。
3223    pub fn set_assembly_locked(&self, on: bool) {
3224        self.assembly_locked.store(on, Ordering::Relaxed);
3225    }
3226
3227    // ---- svctl 操作面(S2) ----
3228
3229    /// 入一条操作指令(窗口面离场动作唯一通道;左位不直调)。
3230    pub fn push_cmd(&self, cmd: UiCmd) {
3231        self.cmds.lock().unwrap().push_back(cmd);
3232        self.bump();
3233    }
3234
3235    /// 取走全部指令(0.5 M5:命令线程辞行)。
3236    pub fn drain_cmds(&self) -> Vec<UiCmd> {
3237        self.cmds.lock().unwrap().drain(..).collect()
3238    }
3239
3240    /// 队列里是否有待执行指令(命令线程的「有没有活」判据,不消费)。
3241    #[must_use]
3242    pub fn cmd_pending(&self) -> bool {
3243        !self.cmds.lock().unwrap().is_empty()
3244    }
3245
3246    /// 当前待二次确认目标(Some = 二次确认中)。
3247    pub fn pending_confirm(&self) -> Option<String> {
3248        self.pending_confirm.lock().unwrap().clone()
3249    }
3250
3251    /// 确认步进:同名二点 = 发令(Fired),异名 = 迁移(Switched),首点 = 进入待确认。
3252    pub fn confirm_step(&self, name: &str) -> ConfirmStep {
3253        let mut g = self.pending_confirm.lock().unwrap();
3254        match g.take() {
3255            Some(p) if p == name => ConfirmStep::Fired,
3256            Some(_) => {
3257                *g = Some(name.to_string());
3258                ConfirmStep::Switched
3259            }
3260            None => {
3261                *g = Some(name.to_string());
3262                ConfirmStep::Armed
3263            }
3264        }
3265    }
3266
3267    /// 清除待确认(取消钮)。
3268    pub fn confirm_cancel(&self) {
3269        *self.pending_confirm.lock().unwrap() = None;
3270        self.bump();
3271    }
3272
3273    // ---- S4 行情面 ----
3274
3275    /// 行情库直读(读写两用:worker 写,渲染读)。
3276    pub fn market(&self) -> &MarketStore {
3277        &self.market
3278    }
3279
3280    // ---- S5 交易面 ----
3281
3282    /// 全量播种四本账(**测试夹具的入口**;0.5 起生产侧不再走这一条 —— 启动一次四问
3283    /// 走 `note_*` 合并式播种,见 `plugin.rs::reconcile_trade`)。
3284    ///
3285    /// 与 `note_*` 的分工:这一条是「把已有的一份账交给界面」(先清后灌),`note_*` 是
3286    /// 「一批新事件进来」。行序按入参**反序**赋 seq,正是为了让重建后的 `trade_snap()`
3287    /// 与喂进来的顺序一致 —— 播种不是「一批新事件」,只有新事件才走「在前」。
3288    pub fn seed_trade_snap(&self, ts: TradeSnap) {
3289        // 0.11.0 第5刀 R6:播种面同样补名(oms 账本里那些"发的时候合约表还空着"的
3290        // 行,进界面这一刻合约早就在手了)
3291        let named = |symbol: &str, name: String| -> String {
3292            if name.is_empty() {
3293                self.contract_name_of(symbol).unwrap_or(name)
3294            } else {
3295                name
3296            }
3297        };
3298        {
3299            let mut book = self.trade_orders.lock().unwrap();
3300            book.clear();
3301            for mut o in ts.orders.into_iter().rev() {
3302                o.name = named(&o.symbol, o.name);
3303                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3304                book.insert(o.en_orderid(), (seq, o));
3305            }
3306        }
3307        {
3308            let mut book = self.trade_trades.lock().unwrap();
3309            book.clear();
3310            let mut ids = self.trade_ids.lock().unwrap();
3311            ids.clear();
3312            for mut t in ts.trades.into_iter().rev() {
3313                t.name = named(&t.symbol, t.name);
3314                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3315                ids.insert(t.en_tradeid());
3316                book.push_front((seq, t));
3317            }
3318        }
3319        {
3320            let mut book = self.trade_positions.lock().unwrap();
3321            book.clear();
3322            for mut p in ts.positions.into_iter().rev() {
3323                p.name = named(&p.symbol, p.name);
3324                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3325                book.insert(p.en_positionid(), (seq, p));
3326            }
3327        }
3328        {
3329            let mut book = self.trade_funds.lock().unwrap();
3330            book.clear();
3331            for a in ts.accounts.into_iter().rev() {
3332                let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3333                book.insert(a.en_accountid(), (seq, a));
3334            }
3335        }
3336        self.trade_dirty.store(true, Ordering::Release);
3337        self.bump();
3338    }
3339
3340    /// 一笔委托入账(基线 OrderMonitor:有 `en_orderid` 就更新可变列,否则新行)。
3341    ///
3342    /// 可变列口径照基线 `widget.py:1341-1357` 的 `update` 标记:随回报走的是
3343    /// `name / traded / traded_price / status / status_msg / datetime`;`symbol /
3344    /// exchange / type / direction / offset / price / volume / orderid / reference /
3345    /// gateway_name` 插入后不再被后续回报改写 —— CTP 的后续回报里这些字段可能给空,
3346    /// 整行覆盖会把「总量」写成 0(`BaseCell.set_content` 的 `if self.is_update` 挡的就是这个)。
3347    pub fn note_order(&self, o: &ek_data::order::OrderData) {
3348        let key = o.en_orderid();
3349        {
3350            let mut book = self.trade_orders.lock().unwrap();
3351            match book.get_mut(&key) {
3352                Some((_, cur)) => {
3353                    cur.name.clone_from(&o.name);
3354                    cur.traded = o.traded;
3355                    cur.traded_price = o.traded_price;
3356                    cur.status = o.status;
3357                    cur.status_msg.clone_from(&o.status_msg);
3358                    cur.datetime = o.datetime;
3359                }
3360                None => {
3361                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3362                    book.insert(key, (seq, o.clone()));
3363                }
3364            }
3365        }
3366        self.trade_dirty.store(true, Ordering::Release);
3367        self.bump();
3368    }
3369
3370    /// 一笔成交入账(0.5 裁定:按 `en_tradeid` 去重,新成交在前)。
3371    ///
3372    /// 基线的两层口径在这里合成一条:表格层 `TradeMonitor.data_key = ""`(:1267,只插不改、
3373    /// 不按键更新),引擎层 `trades_filter_dict`(`engine.py:1072-1080`,同一笔成交只算一次,
3374    /// 注释写明防的是账户重连时柜台重推)。我们的成交表就是用户看到的那本账,而它现在有两个
3375    /// 事件源(网关推送 + 启动 / 上线播种),所以引擎层那一条必须落到这里,否则同一笔会出两行
3376    /// —— 实连演示里量出来了:摘账户再重加之后,元账号 3 行成交变 4 行、纯 TD 账号 3 行变 6 行。
3377    pub fn note_trade(&self, t: &ek_data::trade::TradeData) {
3378        let id = t.en_tradeid();
3379        if self.trade_ids.lock().unwrap().contains(&id) {
3380            return;
3381        }
3382        let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3383        let dropped = {
3384            let mut book = self.trade_trades.lock().unwrap();
3385            book.push_front((seq, t.clone()));
3386            self.trade_ids.lock().unwrap().insert(id);
3387            let over = book.len().saturating_sub(TRADE_CAP);
3388            for _ in 0..over {
3389                // 挤掉最旧一行同时把它出集:那笔之后再推,还能上屏(去重集不许比账本大)。
3390                if let Some((_, old)) = book.pop_back() {
3391                    self.trade_ids.lock().unwrap().remove(&old.en_tradeid());
3392                }
3393            }
3394            over
3395        };
3396        if dropped > 0 {
3397            let total = self
3398                .trade_dropped
3399                .fetch_add(dropped as u64, Ordering::AcqRel)
3400                + dropped as u64;
3401            // 只在第一次触顶弹一次:触顶之后每笔都丢,弹下去就是刷屏。
3402            if total == dropped as u64 {
3403                self.push_alert(
3404                    crate::toast::AlertLevel::Warning,
3405                    format!("成交表已达 {TRADE_CAP} 行上限:更早的成交不再显示"),
3406                );
3407            }
3408        }
3409        self.trade_dirty.store(true, Ordering::Release);
3410        self.bump();
3411    }
3412
3413    /// 一笔持仓入账(基线 PositionMonitor:键 `en_positionid`,可变列照 :1480-1497)。
3414    ///
3415    /// 「平仓后残留」由渲染侧处理(基线 :1514-1517 的 `volume == 0 → hideRow`):
3416    /// 账本留着这一行、把 volume 写成 0,不删行。
3417    pub fn note_position(&self, p: &ek_data::position::PositionData) {
3418        let key = p.en_positionid();
3419        {
3420            let mut book = self.trade_positions.lock().unwrap();
3421            match book.get_mut(&key) {
3422                Some((_, cur)) => {
3423                    cur.volume = p.volume;
3424                    cur.yd_volume = p.yd_volume;
3425                    cur.can_use_volume = p.can_use_volume;
3426                    cur.frozen = p.frozen;
3427                    cur.on_road_volume = p.on_road_volume;
3428                    cur.market_value = p.market_value;
3429                    cur.avg_price = p.avg_price;
3430                    cur.price = p.price;
3431                    cur.last_price = p.last_price;
3432                    cur.pnl = p.pnl;
3433                    cur.profit_rate = p.profit_rate;
3434                    cur.name.clone_from(&p.name);
3435                }
3436                None => {
3437                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3438                    book.insert(key, (seq, p.clone()));
3439                }
3440            }
3441        }
3442        self.trade_dirty.store(true, Ordering::Release);
3443        self.bump();
3444    }
3445
3446    /// 一笔资金入账(基线 AccountMonitor:键 `en_accountid`,可变列 balance / frozen)。
3447    pub fn note_funds(&self, a: &ek_data::account::AccountData) {
3448        let key = a.en_accountid();
3449        {
3450            let mut book = self.trade_funds.lock().unwrap();
3451            match book.get_mut(&key) {
3452                Some((_, cur)) => {
3453                    cur.balance = a.balance;
3454                    cur.frozen = a.frozen;
3455                }
3456                None => {
3457                    let seq = self.trade_seq.fetch_add(1, Ordering::Relaxed) + 1;
3458                    book.insert(key, (seq, a.clone()));
3459                }
3460            }
3461        }
3462        self.trade_dirty.store(true, Ordering::Release);
3463        self.bump();
3464    }
3465
3466    /// 某网关的行全部作废(0.5 M1:摘除 / 下线后不留旧委托旧仓)。返回清掉的行数。
3467    pub fn clear_gateway_rows(&self, gateway: &str) -> usize {
3468        let mut removed = 0usize;
3469        {
3470            let mut book = self.trade_orders.lock().unwrap();
3471            let before = book.len();
3472            book.retain(|_, (_, o)| o.gateway_name != gateway);
3473            removed += before - book.len();
3474        }
3475        {
3476            let mut book = self.trade_trades.lock().unwrap();
3477            let before = book.len();
3478            book.retain(|(_, t)| t.gateway_name != gateway);
3479            removed += before - book.len();
3480            // 去重集跟着账本清:这个网关之后再推同一笔成交,还能上屏。
3481            // 前缀带点(`en_tradeid` = `{网关}.{成交号}`)→ 名字互为前缀的两个网关不会误清。
3482            let prefix = format!("{gateway}.");
3483            self.trade_ids
3484                .lock()
3485                .unwrap()
3486                .retain(|id| !id.starts_with(&prefix));
3487        }
3488        {
3489            let mut book = self.trade_positions.lock().unwrap();
3490            let before = book.len();
3491            book.retain(|_, (_, p)| p.gateway_name != gateway);
3492            removed += before - book.len();
3493        }
3494        {
3495            let mut book = self.trade_funds.lock().unwrap();
3496            let before = book.len();
3497            book.retain(|_, (_, a)| a.gateway_name != gateway);
3498            removed += before - book.len();
3499        }
3500        if removed > 0 {
3501            self.trade_dirty.store(true, Ordering::Release);
3502            self.bump();
3503        }
3504        removed
3505    }
3506
3507    /// 成交账本触顶丢弃计数(0.5 偏离③;0 = 从没丢过)。
3508    #[must_use]
3509    pub fn trade_dropped(&self) -> u64 {
3510        self.trade_dropped.load(Ordering::Acquire)
3511    }
3512
3513    /// 交易监控快照读(渲染):四本账的缓存视图,脏时按帧重建一次。
3514    ///
3515    /// 脏标记由入账置真、这里消费 —— 回报潮里一百条事件也只重建一次(每帧一次),
3516    /// 不是每条事件一次。锁序:先缓存后账本;入账侧只持账本锁,不反向。
3517    pub fn trade_snap(&self) -> Arc<TradeSnap> {
3518        let mut cache = self.trade_snap.lock().unwrap();
3519        if self.trade_dirty.swap(false, Ordering::AcqRel) {
3520            *cache = Arc::new(self.build_trade_snap());
3521        }
3522        Arc::clone(&cache)
3523    }
3524
3525    /// 四本账 → 快照。行序 = seq 降序(基线 `insertRow(0)` 的新事件在前),
3526    /// 同 seq 用键升序兜底,保证可断言。
3527    fn build_trade_snap(&self) -> TradeSnap {
3528        fn by_seq<T>(rows: &BTreeMap<String, (u64, T)>) -> Vec<T>
3529        where
3530            T: Clone,
3531        {
3532            let mut v: Vec<(u64, &String, &T)> =
3533                rows.iter().map(|(k, (s, v))| (*s, k, v)).collect();
3534            v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
3535            v.into_iter().map(|(_, _, v)| v.clone()).collect()
3536        }
3537        TradeSnap {
3538            orders: by_seq(&self.trade_orders.lock().unwrap()),
3539            trades: self
3540                .trade_trades
3541                .lock()
3542                .unwrap()
3543                .iter()
3544                .map(|(_, t)| t.clone())
3545                .collect(),
3546            positions: by_seq(&self.trade_positions.lock().unwrap()),
3547            accounts: by_seq(&self.trade_funds.lock().unwrap()),
3548        }
3549    }
3550
3551    // ---- S6 CTP 配置面 ----
3552
3553    /// CTP 配置表单读(渲染克隆面)。
3554    pub fn ctp_form(&self) -> CtpForm {
3555        self.ctp_form.lock().unwrap().clone()
3556    }
3557
3558    /// CTP 配置表单写。
3559    pub fn set_ctp_form(&self, f: CtpForm) {
3560        *self.ctp_form.lock().unwrap() = f;
3561        self.bump();
3562    }
3563
3564    // ---- BD-UI-31 表格行选中面 ----
3565
3566    /// 该行是否为所属表的选中行(渲染判断;无键 = 未选)。
3567    pub fn row_selected(&self, table: &str, key: &str) -> bool {
3568        self.row_sel
3569            .lock()
3570            .unwrap()
3571            .get(table)
3572            .is_some_and(|k| k == key)
3573    }
3574
3575    /// 设所属表的选中行(每表一行;同键重复点 = 保持)。
3576    pub fn select_row(&self, table: &str, key: &str) {
3577        self.row_sel
3578            .lock()
3579            .unwrap()
3580            .insert(table.to_string(), key.to_string());
3581    }
3582}