Skip to main content

ek_ui/
plugin.rs

1//! `UiPlugin` —— 总线面:`eLog` 入环形日志、`ePlugUp/Down` 记事件 + rev、
2//! `eBar`./`eBarUpdate.` 记行情库(K线窗数据面)、`eTick.` 记 tick 库(v4 行情带
3//! 唯一源,S7 上屏地面)、`eContract.`/`eContractFinish` 记合约表,
4//! **0.5 M1 起 `eOrder.`/`eTrade.`/`ePosition.`/`eAccount.` 四条推送线入交易账本**
5//! (基线 BaseMonitor 同形:后台只 put,界面自己 process_event)。
6//! `eTimer`(1s 宿主自带)那一拍只做三件事:管制面快照(`svctl.list`,每
7//! `LIST_POLL_TICKS` 拍)、**待播种队列**触发的状态播种(`seed_status`:网关刚上线/
8//! 刚重连时一次性问 `{gw}.status`,常态**零轮询** —— 0.7.0 第3刀退役了 0.5 时代的
9//! 摊平状态轮询,登录态读数吃 `eStatus.` 推送)、被标记时才问一次的装配显形
10//! (`oms.pipeline_status`)。**这一拍不再问交易数据,
11//! 也不再辞行操作指令** —— 指令由命令线程执行(0.5 M5,点击即发)。
12//!
13//! 纪律(D1):UI 不周期取数;唯一例外是观测面 `svctl.list`(基线里对应物是同进程
14//! 直读,不是推送)。纪律(D2):后台只 put、不驱动界面 —— 入账 worker 里没有任何 ask。唯一管制面是 ask 契约(svctl / dm.reload /
15//! oms 查询与 submit_order·cancel_order / ctp 配置订阅,全部与
16//! headless 同源;`PluginContext` 只在 worker 可用)。0.1.1 BD-UI-30:
17//! 下单/撤单直发网关的旁路口删除——风控与守卫不可绕过。0.2.10 BD-UI-38:
18//! sim 纸面网关退场(流控查询口随之撤)。
19
20use std::sync::OnceLock;
21use std::time::Duration;
22
23use ek_core::context::PluginContext;
24use ek_core::plugin::Plugin;
25use ek_core::service::{PlugNotice, ServiceDesc};
26use ek_core::{EVENT_PLUG_DOWN, EVENT_PLUG_UP};
27use ek_ctp::plugin::{
28    ConfigureReply, ConfigureReq, DisconnectReply, DisconnectReq, GatewayStatus, StatusReq,
29    SubscribeReply as CtpSubscribeReply, SubscribeReq as CtpSubscribeReq,
30};
31use ek_data::account::AccountData;
32use ek_data::event::{EVENT_BAR, EVENT_CONTRACT, EVENT_LOG, EVENT_TICK};
33use ek_data::log::LogData;
34use ek_data::order::OrderData;
35use ek_data::position::PositionData;
36use ek_data::trade::TradeData;
37use ek_dm::plugin::{EVENT_BAR_UPDATE, ReloadReq};
38use ek_event::EVENT_TIMER;
39use ek_oms::plugin::{
40    GetAllAccountsReq, GetAllOrdersReq, GetAllPositionsReq, GetAllTradesReq, OmsCancelOrderReq,
41    OmsCancelReply, PipelineStatusReq, SubmitOrderReq,
42};
43use ek_oms::{SubmitReply, SubmitStatus};
44use ek_station::wire::{
45    ListReq, PlugRow, RemoveReq, RemoveRes, SVC_LIST, SVC_REMOVE, SVC_START, StartReq, StartRes,
46};
47
48// 配置托底三口的 ask 面(0.6.0 环节8);id 以 ek-cfg `cfg_services()` 单源
49// (逐角取,不本地复定义字面 —— 服务名将来若改,编译/运行端对上就行)。
50// 类型面单源同在 ek-cfg:cfg.save 的 SaveReq/SaveRes ui 现时没有消费方
51// (重存段走 cfg.list 读档发 SaveReq 的是上半执面外的 cmd 线;布局待偿),
52// 0.6.3 clippy 清债撤这两个未用别名 —— 上游契约仍在,用到时再加回。
53//   cfg.list   = CfgListReq/CfgListRes(目录;零值面)
54//   cfg.delete = CfgDeleteReq/CfgDeleteRes(抹键/清段)
55use ek_cfg::wire::{
56    DeleteReq as CfgDeleteReq, DeleteRes as CfgDeleteRes, ListReq as CfgListReq,
57    ListRes as CfgListRes,
58};
59
60/// `cfg.list` / `cfg.delete` 服务 id(单源自 ek-cfg `wire::cfg_services()` 的
61/// 表位;此处取第 2/3 行)。加载期一次,不重复算。
62fn cfg_svc_ids() -> (String, String, String) {
63    let descs = ek_cfg::wire::cfg_services();
64    let id = |i: usize| -> String {
65        descs
66            .get(i)
67            .map_or_else(|| format!("cfg.svc[{i}]"), |d| d.id.clone())
68    };
69    (id(0), id(1), id(2))
70}
71
72/// 配置托底存储根(与 station 工厂 `ek_cfg::CfgPlugin::eco_or_local` 同口径:
73/// eco 成功 = 生态配置目录;失败 = 仓内 `./ekcfg-local`)。命令线程直接落库
74/// 的「重存段」與目录行共根的保证点。
75fn ui_cfg_store() -> ek_cfg::Store {
76    match ek_cfg::Store::eco() {
77        Ok(s) => s,
78        Err(e) => {
79            eprintln!("ek-cfg:运维修复根 eco 失败({e}),回落仓内 ./ekcfg-local(显形口径)");
80            ek_cfg::Store::with_root(std::path::PathBuf::from("ekcfg-local"))
81        }
82    }
83}
84
85/// **主题档的存储键位(0.8.0 配色双档,BD-UI-61)**:`ns="ui"` / `key="theme"`。
86///
87/// 为什么走存储根直用 `Store` 而不是总线:`cfg.list` 回包按契约**只有键名 + 形态标志,
88/// 没有值**(BD-CFG-18 安全视图),契约里没有「读值」的口 → 走总线必然破契约。
89/// 直用 `Store` 是 0.6.0 起的既有口径(BD-CFG-10 soft-centralized,`CfgResave` 同源同根)。
90/// 只有**用户手点过**切换钮才落盘(照参考站「只有点击才存 localStorage」);
91/// 运维窗「清段 ui」= 主题回缺省深色,与既有运维语义自洽。
92pub const THEME_NS: &str = "ui";
93/// 段里的键名。
94pub const THEME_KEY: &str = "theme";
95
96/// 读档(启动时一次,命令线程;选单窗在插件起来之前也用它)。
97/// 读不到(段不存在 / 键不存在 / 脏值 / 读失败)= **缺省深色**(裁定 B:现行为零变化)。
98#[must_use]
99pub fn load_theme() -> crate::tokens::Theme {
100    match ui_cfg_store().load(THEME_NS) {
101        Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => map
102            .get_plain(THEME_KEY)
103            .map(crate::tokens::Theme::from_str)
104            .unwrap_or_default(),
105        Ok(ek_cfg::LoadOutcome::Fresh) => crate::tokens::Theme::default(),
106        Err(e) => {
107            eprintln!("ek-cfg:读主题档失败({e}),按缺省深色起界面");
108            crate::tokens::Theme::default()
109        }
110    }
111}
112
113/// 落盘当前档(命令线程 / eTimer 拍;一次小文件写,只在用户手点后发生)。
114///
115/// 0.9.0 起 `ns="ui"` 一段住了两户(主题 + 自动连接勾选):载入-改-存,
116/// **不许**再拿全新 map 把别人的键抹掉(BD-CFG-5 单写者 + 邻里键保全)。
117fn save_theme(theme: crate::tokens::Theme) -> Result<(), String> {
118    let store = ui_cfg_store();
119    let mut map = match store.load(THEME_NS).map_err(|e| e.to_string())? {
120        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
121        ek_cfg::LoadOutcome::Fresh => ek_core::SectionMap::new(1),
122    };
123    map = map.set(THEME_KEY, ek_core::Field::plain(theme.as_str()));
124    store.store(THEME_NS, &map).map_err(|e| e.to_string())
125}
126
127/// 落盘「自动连接」勾选(0.9.0;与 save_theme 同 `ns="ui"` 一段,载入-改-存)。
128///
129/// - 主题键(以及段里其余键)一个不动 —— 两拨消费方共用同一档案段,谁也不许把
130///   别人的键抹掉(BD-CFG-5 单写者纪律:ui 插件是这个 ns 的唯一写手);
131/// - `auto_connect.*` 的旧键**先全清再插**:摘除/改名后的行不留魂
132///   (判据:摘除行重存档案后,ui 段里没有它的键);
133/// - `SectionMap::set` 吃 self 返回新值,循环里累计。
134pub fn save_auto_connect_flags_into(
135    store: &ek_cfg::Store,
136    flags: &[(String, bool)],
137) -> Result<(), String> {
138    let mut map = match store.load(THEME_NS).map_err(|e| e.to_string())? {
139        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
140        ek_cfg::LoadOutcome::Fresh => ek_core::SectionMap::new(1),
141    };
142    let stale: Vec<String> = map
143        .iter()
144        .filter(|(k, _)| k.starts_with("auto_connect."))
145        .map(|(k, _)| (*k).to_string())
146        .collect();
147    for k in stale {
148        map.remove(&k);
149    }
150    for (gw, on) in flags {
151        map = map.set(
152            format!("auto_connect.{gw}"),
153            ek_core::Field::plain(if *on { "1" } else { "0" }),
154        );
155    }
156    store.store(THEME_NS, &map).map_err(|e| e.to_string())
157}
158
159use crate::state::{BusSnap, UiShared};
160
161/// 注册名 = 工厂名 = `"ui"`(BD-U5:两者一致)。
162pub const UI_NAME: &str = "ui";
163
164/// 界面邮箱**软顶**(0.5 M6 首发;0.7.0 背压刀改语义,数值照旧)。
165///
166/// 0.4 口径:界面是链上唯一覆写容量的插件,10 万格把"30 条/秒的行情 34 秒就丢"
167/// 变成"55 分钟"。0.5.0 起邮箱**无界** → 这个数不再是容量(到顶必丢),而是**软顶**
168/// (到顶才丢的那一格):默认一条都不丢,只有积压堆到 10 万帧才说明界面 worker
169/// 已经被彻底甩开,那时保护进程比保住行情更重要 → 开始丢新帧并把 `spilled` 涨起来。
170/// 数值取 10 万的理由不变:一帧 tick ≈ 30 条/秒 ≈ 55 分钟的量,内存上限也就是这个量级。
171/// 越阈显形(黄 6250 / 红 25000,`state::pending_thresholds`)在运维窗与 toast 双路可见。
172pub const UI_MAILBOX_SOFT_CAP: u64 = 100_000;
173
174/// 交易四表的推送线(基线 `event.py:13-18`;ek-ctp 0.4.0 起发裸键,ek-core 精确匹配)。
175/// 名字单源取 ek-oms::wire —— 同一条线在链上只有一份拼写。
176const W_ORDER: &str = ek_oms::wire::EVENT_ORDER;
177const W_TRADE: &str = ek_oms::wire::EVENT_TRADE;
178const W_POSITION: &str = ek_oms::wire::EVENT_POSITION;
179const W_ACCOUNT: &str = ek_oms::wire::EVENT_ACCOUNT;
180
181/// 常驻观测插件(窗口面在 [`crate::runtime`] 持 [`UiShared`] 渲染)。
182pub struct UiPlugin {
183    shared: std::sync::Arc<UiShared>,
184    ctx: OnceLock<PluginContext>,
185    /// 命令线程的停线位(0.5 M5;on_stop 置真,线程自己退)。
186    cmd_stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
187}
188
189impl UiPlugin {
190    /// 与窗口面共用的共享状态句柄。
191    pub fn new(shared: std::sync::Arc<UiShared>) -> Self {
192        Self {
193            shared,
194            ctx: OnceLock::new(),
195            cmd_stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
196        }
197    }
198
199    /// eTimer 节流回调(0.7.0:管制面快照 + 标记触发的状态播种 + 标记触发的装配显形;
200    /// 0.5 时代的「摊平状态轮询」已被第3刀退役,常态零轮询 = 判据 C13)。
201    fn refresh(&self) {
202        let Some(ctx) = self.ctx.get() else {
203            return; // 未 init 就吃到 eTimer:忽略,等下轮
204        };
205        // 0.5 M3/M5:这一拍只做三件事,而且都不问交易数据。
206        // 指令辞行搬到命令线程(`spawn_cmd_thread`);四张表吃 eOrder. / eTrade. /
207        // ePosition. / eAccount. 推送入账(基线 BaseMonitor 同形)—— 这一拍里再出现
208        // 任何「取数类 ask」就是回到 0.4 的病。
209        if self.shared.list_poll_due() {
210            self.poll_list(ctx);
211        }
212        self.seed_status(ctx);
213        if self.shared.take_pipeline_due() {
214            self.poll_pipeline(ctx);
215        }
216        if self.shared.take_cfg_due() {
217            // 配置托底段目录(0.6.0 环节8):观测面读数,轻 IO 一次(与 poll_pipeline
218            // 同档的「观测面例外」,不进周期节拍 —— 只在钮触/回执后续问触发)。
219            self.poll_cfg(ctx);
220        }
221        if self.shared.take_theme_persist_due() {
222            // 0.8.0 配色双档:用户手点切换钮后的那次落盘(渲染线程零文件 IO,写在这拍)。
223            let theme = self.shared.theme();
224            match save_theme(theme) {
225                Ok(()) => self
226                    .shared
227                    .log_line(format!("[ui] 主题已存(ui.theme={})", theme.as_str())),
228                Err(e) => self
229                    .shared
230                    .log_line(format!("[ui] 主题落盘失败:{e}(本次切换只在本次运行内生效)")),
231            }
232        }
233        if self.shared.take_auto_connect_persist_due() {
234            // 0.9.0 自动连接勾选:同一支纪律(手点才写,渲染线程零文件 IO)。
235            // ui 段**载入-改-存**:主题键必须保住;摘了户口的行要清键(不留魂)。
236            let flags: Vec<(String, bool)> = self
237                .shared
238                .accounts()
239                .iter()
240                .map(|a| (a.gateway.clone(), a.auto_connect))
241                .collect();
242            match save_auto_connect_flags_into(&ui_cfg_store(), &flags) {
243                Ok(()) => {
244                    let on: Vec<&str> = flags
245                        .iter()
246                        .filter(|(_, on)| *on)
247                        .map(|(gw, _)| gw.as_str())
248                        .collect();
249                    if on.is_empty() {
250                        self.shared
251                            .log_line("[ui] 自动连接勾选已存(ui 段;当前全部关)".to_string());
252                    } else {
253                        self.shared.log_line(format!(
254                            "[ui] 自动连接勾选已存(ui 段;开:{};守护/桌面启动清点按它起连)",
255                            on.join(", ")
256                        ));
257                    }
258                }
259                Err(e) => self.shared.log_line(format!(
260                    "[ui] 自动连接勾选落盘失败:{e}(本轮勾选只在本次运行内生效)"
261                )),
262            }
263        }
264    }
265
266    /// 配置托底段目录(0.6.0;`cfg.list` 零值面,BD-CFG-17/18)。
267    fn poll_cfg(&self, ctx: &PluginContext) {
268        let (_, svc_list, _) = cfg_svc_ids();
269        match ctx.ask_service::<CfgListReq, CfgListRes>(
270            svc_list.as_str(),
271            CfgListReq,
272            Duration::from_secs(2),
273        ) {
274            Ok(res) => {
275                self.shared.set_cfg_briefs(res.entries);
276                if !res.msg.is_empty() {
277                    self.shared
278                        .log_line(format!("[cfg] 目录:{msg}", msg = res.msg));
279                }
280            }
281            Err(e) => self
282                .shared
283                .log_line(format!("[cfg] 段落目录失败(cfg 缺席或超时): {e:?}")),
284        }
285    }
286
287    /// 管制面快照(`svctl.list`):谁在场 + 服务目录 + 邮箱溢出计数。
288    ///
289    /// 0.5 M3 降到每 `LIST_POLL_TICKS` 拍一次。它给的不是交易数据而是「宿主长什么样」
290    /// 的观测面 —— 基线里的对应物是 `get_all_gateway_names` 的同进程直读,不是推送,
291    /// 所以它留在白名单里(「界面不周期取数」这条纪律的唯一例外)。
292    fn poll_list(&self, ctx: &PluginContext) {
293        match ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(2)) {
294            Ok(rows) => {
295                let mut services = Vec::with_capacity(rows.len());
296                for r in &rows {
297                    let ids = ctx
298                        .services_of(&r.name)
299                        .map(|v| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>())
300                        .unwrap_or_default();
301                    services.push((r.name.clone(), ids));
302                }
303                // 账户在场位回填 + 「实例没了就作废会话读数」。
304                // 0.5:「新装配」不再由这里检测 —— ePlugUp. 已订阅,即时且不靠轮询。
305                self.shared.sync_accounts_connected(&rows);
306                // 0.4.5(P7d):邮箱溢出的当场显形。运维窗那一列一直有 spilled,
307                // 但 ek-core 的 stderr 诊断每 128 条才打一条 —— 屏上此前没有任何东西。
308                self.shared.note_plugin_spill(&rows);
309                self.shared.set_snapshot(BusSnap { rows, services });
310            }
311            Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e}")),
312        }
313    }
314
315    /// 一次性状态播种(0.7.0 第3刀:周期轮询退役)。
316    ///
317    /// 常态**零轮询**:登录态/断供读数全靠 `eStatus.` 推送到货。这里只处理
318    /// 「网关刚上线(或刚重连)」的待播种队列 —— 每网关运行期至多一次 ask,
319    /// 拿初始快照(推送是变化触发,刚上线还没有变化,界面得有一份起点)。
320    /// 只读口不写日志(ek-ctp D-STATUS ④),播种不冲 eLog 环。
321    fn seed_status(&self, ctx: &PluginContext) {
322        for gw in self.shared.take_status_seeds() {
323            let svc = format!("{gw}.{}", ek_ctp::SVC_STATUS);
324            match ctx.ask_service::<StatusReq, GatewayStatus>(
325                &svc,
326                StatusReq,
327                Duration::from_secs(2),
328            ) {
329                Ok(st) => self.shared.note_gateway_status(&gw, &st),
330                Err(e) => {
331                    // 打 Display(不是 Debug):超时行自带「目标积压 N 帧」(ek-core 0.5.0)。
332                    // **不排回队列重试**:常态零轮询是这一刀的判据(C13),问不到就留在
333                    // 「无证据」态等推送 —— `eStatus.` 一到,读数立刻归位。
334                    self.shared.note_status_unreachable(&gw, &format!("{e}"));
335                }
336            }
337        }
338    }
339
340    /// 装配显形(0.5 M3 改事件触发):`oms.pipeline_status` 只在被标记的那一拍问一次。
341    ///
342    /// 标记来自启动、`ePlugUp.oms` 与运维窗的刷新钮。它此前是每 4 拍一次的轮询 ——
343    /// 装配很少变,而它是 oms worker 上又一次串行 ask;在事件驱动模型里这种轮询是多余的。
344    fn poll_pipeline(&self, ctx: &PluginContext) {
345        match ctx.ask_service::<PipelineStatusReq, Vec<String>>(
346            ek_oms::SVC_PIPELINE_STATUS,
347            PipelineStatusReq,
348            Duration::from_secs(2),
349        ) {
350            Ok(lines) => self.shared.set_pipeline_lines(lines),
351            Err(e) => {
352                // 不每拍刷日志:oms 不在场是合法态(清单里没选它,ask 走 TargetGone
353                // 快错)。只在「从有到无」那一次显形一条,与状态轮询「只读口不冲 eLog 环」
354                // 同口径。读数清空 → 运维窗那一节整节不画。
355                if !self.shared.pipeline_lines().is_empty() {
356                    self.shared
357                        .log_line(format!("[ui] oms.pipeline_status 失手: {e:?}"));
358                }
359                self.shared.set_pipeline_lines(Vec::new());
360            }
361        }
362    }
363
364    /// 启动一次四问(0.5 M4;基线无对应物 —— 插件热插拔是 ek 独有场景)。
365    ///
366    /// 一次性,不是周期:之后四张表只吃推送。走 `note_*` 而不是 `seed_trade_snap`,
367    /// 是为了**合并而不是覆盖** —— 网关可能在 oms 还没消化完之前就已经把委托推给了
368    /// 界面,覆盖式播种会把界面已经看见的那一笔抹掉。
369    ///
370    /// 执行位置 = 命令线程(0.5 执行核查改的):原先它挂在 `on_start` 里,而 `on_start`
371    /// 是宿主启动序列的一部分 —— 四口最坏 4 × 2 秒会把后面插件的启动一起拖住;挂在
372    /// 入账 worker 上又违反「入账 worker 里没有任何 ask」。所以它跟着命令线程走。
373    fn reconcile_trade(shared: &UiShared, ctx: &PluginContext) {
374        let orders = ctx.ask_service::<GetAllOrdersReq, Vec<OrderData>>(
375            "oms.get_all_orders",
376            GetAllOrdersReq,
377            Duration::from_secs(2),
378        );
379        let trades = ctx.ask_service::<GetAllTradesReq, Vec<TradeData>>(
380            "oms.get_all_trades",
381            GetAllTradesReq,
382            Duration::from_secs(2),
383        );
384        let positions = ctx.ask_service::<GetAllPositionsReq, Vec<PositionData>>(
385            "oms.get_all_positions",
386            GetAllPositionsReq,
387            Duration::from_secs(2),
388        );
389        let funds = ctx.ask_service::<GetAllAccountsReq, Vec<AccountData>>(
390            "oms.get_all_accounts",
391            GetAllAccountsReq,
392            Duration::from_secs(2),
393        );
394        let mut n = 0usize;
395        if let Ok(rows) = &orders {
396            for o in rows {
397                shared.note_order(o);
398            }
399            n += rows.len();
400        }
401        if let Ok(rows) = &trades {
402            for t in rows {
403                shared.note_trade(t);
404            }
405            n += rows.len();
406        }
407        if let Ok(rows) = &positions {
408            for p in rows {
409                shared.note_position(p);
410            }
411            n += rows.len();
412        }
413        if let Ok(rows) = &funds {
414            for a in rows {
415                shared.note_funds(a);
416            }
417            n += rows.len();
418        }
419        // 只在真播种到东西时落一条;空清单(刚装配、柜台还没回报)不刷日志。
420        if n > 0 {
421            let bad = usize::from(orders.is_err())
422                + usize::from(trades.is_err())
423                + usize::from(positions.is_err())
424                + usize::from(funds.is_err());
425            shared.log_line(format!(
426                "[ui] 启动播种 {n} 行(委托 {} / 成交 {} / 持仓 {} / 资金 {};失手 {} 口)",
427                orders.as_ref().map_or(0, |v| v.len()),
428                trades.as_ref().map_or(0, |v| v.len()),
429                positions.as_ref().map_or(0, |v| v.len()),
430                funds.as_ref().map_or(0, |v| v.len()),
431                bad
432            ));
433        }
434    }
435
436    /// 命令线程(0.5 M5):点击即发,不等 eTimer 拍,也不占 ui 的入账 worker。
437    ///
438    /// 基线对应物是「UI 主线程直调 `main_engine.send_order`,立即返回」(engine.py:915-923)。
439    /// ek-core 的 ask 是同步的,所以把「等回包」这件事从入账线程挪走:入账线程从此只
440    /// 做「收事件 → 写账本」,每事件 O(1),永不被后端拖住。
441    /// 这一条线程也是「会等的东西」唯一允许待的地方:启动一次四问在这里跑
442    /// (见 `reconcile_trade`),既不占宿主的启动序列,也不占入账 worker。
443    fn spawn_cmd_thread(&self) {
444        let Some(ctx) = self.ctx.get().cloned() else {
445            return; // 未 init:没有管制面可用
446        };
447        let shared = std::sync::Arc::clone(&self.shared);
448        let stop = std::sync::Arc::clone(&self.cmd_stop);
449        if std::thread::Builder::new()
450            .name("ui-cmd".to_string())
451            .spawn(move || {
452                loop {
453                    if stop.load(std::sync::atomic::Ordering::Acquire) {
454                        break;
455                    }
456                    // 用户指令优先于播种:播种最坏 4 × 2 秒,不能把点击排到它后面。
457                    if shared.cmd_pending() {
458                        Self::run_cmds(&shared, &ctx);
459                        continue;
460                    }
461                    // 播种档:启动一次 + 之后每次网关 / oms 上线各一次(标记是消费性的)。
462                    if shared.take_trade_reseed() {
463                        Self::reconcile_trade(&shared, &ctx);
464                        continue;
465                    }
466                    // K线历史补数(I2 第一刀;开窗入队,这里发 tq 桥)。
467                    if shared.kline_fetch_pending() {
468                        Self::run_kline_fetches(&shared, &ctx);
469                        continue;
470                    }
471                    std::thread::sleep(Duration::from_millis(20));
472                }
473            })
474            .is_err()
475        {
476            // 线程起不来 = 指令只会进队列不会执行。这一条必须上屏,不能静默降级。
477            self.shared
478                .log_line("[ui] 命令线程起不来:操作指令会滞留在队列里");
479        }
480    }
481
482    /// K线历史补数执行体(I2 第一刀;命令线程):
483    /// 入队单 → 找 tq 派生网关 → `{gw}.klines`(1min×300,Length 写法)→ 回包并格。
484    /// error/truncated/ask 失手 = 日志显形(不静默),cell 只吃 error=None 的数据。
485    fn run_kline_fetches(shared: &UiShared, ctx: &PluginContext) {
486        for f in shared.take_kline_fetches() {
487            let Some(gw) = ctx
488                .plugins()
489                .iter()
490                .map(|p| p.name.clone())
491                .find(|n| n.starts_with("tq"))
492            else {
493                shared.log_line(format!(
494                    "[kline] {} tq 网关不在线,历史补数丢弃(重开 K线窗再试)",
495                    f.symbol
496                ));
497                continue;
498            };
499            let req = ek_tq::KlinesReq {
500                symbol: f.symbol.clone(),
501                raw: false,
502                interval_secs: f.interval_secs,
503                range: ek_tq::RangeSpec::Length { count: 300, end_ns: None },
504                max_rows: Some(300),
505            };
506            match ctx.ask::<ek_tq::KlinesReq, ek_tq::KlinesReply>(
507                &gw,
508                req,
509                Duration::from_secs(45),
510            ) {
511                Ok(reply) => {
512                    if let Some(err) = &reply.error {
513                        shared.log_line(format!(
514                            "[kline] {}/{}s 历史显形: {}",
515                            f.symbol, f.interval_secs, err.message
516                        ));
517                        continue;
518                    }
519                    let n = reply.rows.len();
520                    shared.market().note_history(&f.symbol, f.interval_secs, &reply.rows);
521                    shared.bump();
522                    shared.log_line(format!(
523                        "[kline] {}/{}s 历史补数 +{n} 根(mode={} truncated={})",
524                        f.symbol,
525                        f.interval_secs,
526                        reply.mode.as_str(),
527                        reply.truncated
528                    ));
529                }
530                Err(e) => shared.log_line(format!(
531                    "[kline] {}/{}s 历史 ask 失手: {e:?}",
532                    f.symbol, f.interval_secs
533                )),
534            }
535        }
536    }
537
538    /// 辞行指令队列(0.5 M5:由命令线程调用,不在 ui worker 上;回执进日志,面板语义可断言)。
539    fn run_cmds(shared: &UiShared, ctx: &PluginContext) {        for cmd in shared.drain_cmds() {
540            match cmd {
541                crate::state::UiCmd::Start { factory, name } => {
542                    match ctx.ask_service::<StartReq, StartRes>(
543                        SVC_START,
544                        StartReq {
545                            factory: factory.clone(),
546                            name: name.clone(),
547                        },
548                        Duration::from_secs(3),
549                    ) {
550                        Ok(StartRes::Started { name }) => {
551                            shared.log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
552                        }
553                        Ok(StartRes::Rejected { reason }) => {
554                            shared.log_line(format!("[ops] start {factory} 被拒: {reason}"));
555                        }
556                        Err(e) => {
557                            shared.log_line(format!("[ops] start {factory} 失败: {e:?}"));
558                        }
559                    }
560                    // 起停回执之后重问一次管制面:运维窗那一行与账户在场位立刻动。
561                    shared.request_list_refresh();
562                }
563                crate::state::UiCmd::Remove { name } => {
564                    // 观测面自身的闸(0.4.3 BD-UI-64,审核 P1;0.5 M5 换了理由,闸不换):
565                    // 0.4 的理由是「这条指令在 ui 自己的 worker 上执行 → 摘 ui = 自己等自己
566                    // (ek-core `close_one` 只能走到 2 秒超时的 detach 分支)」。
567                    // 0.5 执行者换成命令线程,「自己等自己」这一半不成立了,但结论一样:
568                    // 摘掉 ui 之后没有 eTimer 拍再跑快照与状态轮询、入账 worker 也没了,
569                    // 窗口还开着而里面全是上一帧。所以仍然直拒,不发 svctl。
570                    // 管制面 `svctl` 的同一道闸长在 ek-station supervisor 侧(0.2.11)。
571                    if name == UI_NAME {
572                        shared.log_line(
573                            "[ops] 拒绝移除 ui:观测面自身不可自摘(摘掉后没有下一拍执行任何指令)"
574                                .to_string(),
575                        );
576                        continue;
577                    }
578                    match ctx.ask_service::<RemoveReq, RemoveRes>(
579                        SVC_REMOVE,
580                        RemoveReq { name: name.clone() },
581                        Duration::from_secs(3),
582                    ) {
583                        Ok(RemoveRes::Removed { name }) => {
584                            shared.log_line(format!("[ops] svctl.remove {name} 完成"));
585                        }
586                        Ok(RemoveRes::NoSuchName { name }) => {
587                            shared.log_line(format!("[ops] remove {name} 不存在/不可摘"));
588                        }
589                        // 0.4.3:回执枚举增项(ek-station 0.2.11)—— 名字在场但不可摘,
590                        // 或宿主另有错。原先这一档被塞进 NoSuchName 的 name 栏(错误文本
591                        // 当名字打),这一栏现在照请求回填,错因只进 reason。
592                        Ok(RemoveRes::Rejected { name, reason }) => {
593                            shared.log_line(format!("[ops] remove {name} 被拒: {reason}"));
594                        }
595                        Err(e) => {
596                            shared.log_line(format!("[ops] remove {name} 失败: {e:?}"));
597                        }
598                    }
599                    shared.request_list_refresh();
600                }
601                crate::state::UiCmd::Reload {
602                    interval,
603                    symbol,
604                    bars,
605                } => {
606                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
607                    match ctx.ask_service::<ReloadReq, usize>(
608                        "dm.reload",
609                        ReloadReq {
610                            interval,
611                            symbol: symbol.clone(),
612                            bars: bars.clone(),
613                            size: Some(bars.len()),
614                            push: true,
615                        },
616                        Duration::from_secs(5),
617                    ) {
618                        Ok(n) => {
619                            shared.market().note_reload();
620                            shared.log_line(format!(
621                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
622                            ));
623                        }
624                        Err(e) => {
625                            shared.log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
626                        }
627                    }
628                }
629                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
630                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
631                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
632                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
633                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
634                        "oms.submit_order",
635                        SubmitOrderReq {
636                            req: req.clone(),
637                            gateway_name: gateway.clone(),
638                            mode,
639                        },
640                        Duration::from_secs(10),
641                    ) {
642                        Ok(reply) => {
643                            let tag = match reply.status {
644                                SubmitStatus::Sent => "SENT",
645                                SubmitStatus::Partial => "PARTIAL",
646                                SubmitStatus::Rejected => "REJECTED",
647                            };
648                            for leg in &reply.legs {
649                                match (leg.ok, &leg.en_orderid, &leg.reason) {
650                                    (true, Some(id), _) => shared.log_line(format!(
651                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
652                                        leg.req.symbol,
653                                        leg.req.direction.as_str(),
654                                        leg.req.offset.as_str(),
655                                        leg.req.volume,
656                                        reply.parent_id
657                                    )),
658                                    _ => shared.log_line(format!(
659                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
660                                        leg.req.symbol,
661                                        leg.req.direction.as_str(),
662                                        leg.req.offset.as_str(),
663                                        leg.req.volume,
664                                        leg.reason.as_deref().unwrap_or("?"),
665                                        reply.parent_id
666                                    )),
667                                }
668                            }
669                            if reply.legs.is_empty() {
670                                shared.log_line(format!(
671                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
672                                    req.symbol,
673                                    req.offset.as_str(),
674                                    req.volume,
675                                    reply.parent_id
676                                ));
677                            }
678                        }
679                        Err(e) => {
680                            shared.log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
681                        }
682                    }
683                }
684                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
685                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
686                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
687                    // OrderData.en_orderid() 同式。
688                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
689                        "oms.cancel_order",
690                        OmsCancelOrderReq {
691                            en_orderid: format!("{gateway}.{orderid}"),
692                            gateway_name: gateway.clone(),
693                        },
694                        Duration::from_secs(10),
695                    ) {
696                        Ok(OmsCancelReply { ok: true, msg }) => {
697                            shared.log_line(format!("[ops] {msg}"));
698                        }
699                        Ok(OmsCancelReply { ok: false, msg }) => {
700                            // 0.11.0 第6刀 R7:屏幕上给人话,机器码原文照留(取证要吃原文)
701                            shared
702                                .log_line(format!("[ops] {}", UiShared::cancel_reject_note(&msg)));
703                        }
704                        Err(e) => {
705                            shared.log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
706                        }
707                    }
708                }
709                crate::state::UiCmd::CtpConfigure { gateway, form } => {
710                    // 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
711                    // 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
712                    let svc = format!("{gateway}.configure");
713                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
714                        svc.as_str(),
715                        form.to_configure_req(),
716                        Duration::from_secs(5),
717                    ) {
718                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
719                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
720                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
721                        Ok(ConfigureReply { ok: true, msg, .. }) => {
722                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
723                        }
724                        Ok(ConfigureReply { ok: false, msg, .. }) => {
725                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
726                        }
727                        Err(e) => {
728                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
729                        }
730                    }
731                }
732                crate::state::UiCmd::Connect { gateway, form } => {
733                    // **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
734                    // `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
735                    // (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
736                    // 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
737                    shared.note_connect_sent(&gateway);
738                    // 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
739                    // (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
740                    let form = shared.prepare_connect_form(&gateway, form);
741                    let started = match ctx.ask_service::<StartReq, StartRes>(
742                        SVC_START,
743                        StartReq {
744                            factory: "ctp".to_string(),
745                            name: gateway.clone(),
746                        },
747                        Duration::from_secs(3),
748                    ) {
749                        Ok(StartRes::Started { name }) => {
750                            shared.log_line(format!("[ops] svctl.start ctp → {name} 上线"));
751                            true
752                        }
753                        Ok(StartRes::Rejected { reason }) => {
754                            // 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
755                            // 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
756                            if shared.account(&gateway).is_some_and(|a| a.connected) {
757                                shared
758                                    .log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
759                                true
760                            } else {
761                                shared.log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
762                                shared.note_connect_result(&gateway, Err(reason));
763                                continue;
764                            }
765                        }
766                        Err(e) => {
767                            shared.log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
768                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
769                            continue;
770                        }
771                    };
772                    if !started {
773                        continue;
774                    }
775                    let svc = format!("{gateway}.configure");
776                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
777                        svc.as_str(),
778                        form.to_configure_req(),
779                        Duration::from_secs(5),
780                    ) {
781                        // `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
782                        // (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
783                        // 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
784                        Ok(ConfigureReply {
785                            ok: true,
786                            msg,
787                            product_classes,
788                        }) => {
789                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
790                            shared.note_effective_product_classes(&gateway, &product_classes);
791                            shared.note_connect_result(&gateway, Ok(()));
792                        }
793                        Ok(ConfigureReply { ok: false, msg, .. }) => {
794                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
795                            shared.note_connect_result(&gateway, Err(msg));
796                        }
797                        Err(e) => {
798                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
799                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
800                        }
801                    }
802                }
803                crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
804                    // 多账号面:一律路由到行情元账号(渲染侧已按此入列);
805                    // 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
806                    // 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
807                    let svc = format!("{gateway}.subscribe");
808                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
809                        svc.as_str(),
810                        CtpSubscribeReq {
811                            symbols: symbols.clone(),
812                        },
813                        Duration::from_secs(5),
814                    ) {
815                        Ok(CtpSubscribeReply { ok: true, msg }) => {
816                            shared.note_subscriptions(&symbols);
817                            shared.log_line(format!(
818                                "[ops] {svc} {} 受理:{}",
819                                symbols.join(","),
820                                msg
821                            ));
822                        }
823                        Ok(CtpSubscribeReply { ok: false, msg }) => {
824                            shared.log_line(format!(
825                                "[ops] {svc} {} 被拒:{}",
826                                symbols.join(","),
827                                msg
828                            ));
829                        }
830                        Err(e) => {
831                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
832                        }
833                    }
834                }
835                crate::state::UiCmd::Disconnect { gateway } => {
836                    // 0.2.19 规划裁定 1:『断开』= `{gw}.disconnect`(停会话),
837                    // 『移除』= `svctl.remove`(停实例)—— 两条走两条不同的口,不共用。
838                    // 发令即把该行标成「已断开」:用户按了钮就要立刻看到,不等下一轮轮询。
839                    let svc = format!("{gateway}.disconnect");
840                    match ctx.ask_service::<DisconnectReq, DisconnectReply>(
841                        svc.as_str(),
842                        DisconnectReq,
843                        Duration::from_secs(5),
844                    ) {
845                        Ok(DisconnectReply { ok: true, msg }) => {
846                            shared.mark_account_stopped(&gateway);
847                            shared.log_line(format!("[账户] 断开 {gateway}:{msg}(实例仍在场)"));
848                        }
849                        Ok(DisconnectReply { ok: false, msg }) => {
850                            shared.log_line(format!("[账户] 断开 {gateway} 未执行:{msg}"));
851                        }
852                        Err(e) => {
853                            shared.log_line(format!(
854                                "[账户] 断开 {gateway} 失败:{e:?}(网关不在场或超时)",
855                            ));
856                        }
857                    }
858                }
859                crate::state::UiCmd::CfgList => {
860                    // 目录刷新(命令线程执行档;与 worker poll_cfg 同一读数面)。
861                    let (_, svc_list, _) = cfg_svc_ids();
862                    match ctx.ask_service::<CfgListReq, CfgListRes>(
863                        svc_list.as_str(),
864                        CfgListReq,
865                        Duration::from_secs(2),
866                    ) {
867                        Ok(res) => {
868                            shared.set_cfg_briefs(res.entries);
869                            if !res.msg.is_empty() {
870                                shared.log_line(format!("[cfg] 目录:{msg}", msg = res.msg));
871                            }
872                        }
873                        Err(e) => shared.log_line(format!("[cfg] 段落目录失败: {e:?}")),
874                    }
875                }
876                crate::state::UiCmd::CfgDelete { ns } => {
877                    // 清段(BD-CFG-20 运维语义;空 key = 整段;零值面)。
878                    let (svc_save, svc_list, svc_delete) = cfg_svc_ids();
879                    let _ = (svc_save, svc_list);
880                    match ctx.ask_service::<CfgDeleteReq, CfgDeleteRes>(
881                        svc_delete.as_str(),
882                        CfgDeleteReq {
883                            ns: ns.clone(),
884                            key: String::new(),
885                        },
886                        Duration::from_secs(2),
887                    ) {
888                        Ok(res) if res.ok => {
889                            shared.log_line(format!(
890                                "[cfg] 清段 {ns}:键 {} / 秘条 {}",
891                                res.removed_keys, res.removed_secrets
892                            ));
893                            shared.request_cfg_refresh();
894                        }
895                        Ok(res) => shared.log_line(format!("[cfg] 清段 {ns} 未执行:{}", res.msg)),
896                        Err(e) => shared.log_line(format!("[cfg] 清段 {ns} 失败: {e:?}")),
897                    }
898                    shared.arm_cfg_delete(None);
899                }
900                crate::state::UiCmd::CfgResave { ns } => {
901                    // 重存段(0.6.0):明面按现状重读 → serialize → store。**值不变
902                    // (BD-CFG-15:不输出 = 不持久,这里连「不重排空行」都不做之外的
903                    // 改动一律没有)**;rev/格式修复口。兜底根与 cfg 实例同口径
904                    // (BD-CFG-10 soft-centralized:直调用库,不拉起注册)。
905                    let store = ui_cfg_store();
906                    let loaded = store
907                        .load(&ns)
908                        .map_err(|e| e.to_string())
909                        .and_then(|o| match o {
910                            ek_cfg::LoadOutcome::Fresh => Err(format!("{ns} 段不存在,不持久空形")),
911                            map @ ek_cfg::LoadOutcome::Loaded { .. } => Ok(map),
912                        });
913                    match loaded {
914                        Ok(ek_cfg::LoadOutcome::Fresh) => {
915                            unreachable!("Fresh 已被上面的 and_then 挡走");
916                        }
917                        Ok(ek_cfg::LoadOutcome::Loaded {
918                            map,
919                            warnings,
920                            backup_used,
921                        }) => {
922                            if !warnings.is_empty() {
923                                shared.log_line(format!("[cfg] {ns} 重读警告:{warnings:?}"));
924                            }
925                            if backup_used {
926                                shared.log_line(format!("[cfg] {ns} 走了 .bak 回落"));
927                            }
928                            match store.store(&ns, &map) {
929                                Ok(()) => {
930                                    shared.log_line(format!("[cfg] {ns} 已重存(rev {})", map.rev))
931                                }
932                                Err(e) => shared.log_line(format!("[cfg] {ns} 重存失败:{e}")),
933                            }
934                        }
935                        Err(msg) => shared.log_line(format!("[cfg] {msg}")),
936                    }
937                }
938            }
939        }
940    }
941}
942
943impl Plugin for UiPlugin {
944    fn name(&self) -> &str {
945        UI_NAME
946    }
947
948    fn deps(&self) -> &[&str] {
949        &[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
950    }
951
952    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
953        vec![
954            std::borrow::Cow::Borrowed(EVENT_LOG),
955            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
956            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
957            std::borrow::Cow::Borrowed(EVENT_BAR),
958            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
959            std::borrow::Cow::Borrowed(EVENT_TICK),
960            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
961            std::borrow::Cow::Borrowed(ek_ctp::EVENT_CONTRACT_FINISH),
962            // 0.5 M1 交易四表的推送线(基线 BaseMonitor 各自订阅自己的线)。
963            // 不订 eTradeSync.:ek-oms 处理完成交后重推的那一条(plugin.rs:305-308),
964            // 与 ek-ctp 的 eTrade. 是同一笔成交的两份 —— 订两条就是重复入账。
965            std::borrow::Cow::Borrowed(W_ORDER),
966            std::borrow::Cow::Borrowed(W_TRADE),
967            std::borrow::Cow::Borrowed(W_POSITION),
968            std::borrow::Cow::Borrowed(W_ACCOUNT),
969            std::borrow::Cow::Borrowed(EVENT_TIMER),
970            // 0.7.0 第3刀:状态变化推送(裸线,载荷自带网关名)—— 界面零轮询的地基
971            std::borrow::Cow::Borrowed(ek_ctp::EVENT_STATUS_PREFIX),
972        ]
973    }
974
975    fn mailbox_soft_cap(&self) -> u64 {
976        UI_MAILBOX_SOFT_CAP
977    }
978
979    fn version(&self) -> std::borrow::Cow<'static, str> {
980        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
981    }
982
983    fn services(&self) -> Vec<ServiceDesc> {
984        Vec::new() // 纯视图零服务;管制面在 svctl
985    }
986
987    fn on_init(&self, ctx: &PluginContext) {
988        let _ = self.ctx.set(ctx.clone());
989    }
990
991    fn on_start(&self) {
992        self.shared.log_line("[ui] 界面插件上线");
993        // 0.5 M5 + M4:命令线程先起(点击即发);启动一次四问在那条线程里跑 ——
994        // `on_start` 是 `engine.start()` 启动链的一环,四口最坏 4 × 2 秒不许把后面
995        // 插件的启动一起拖住,更不许占入账 worker。
996        self.spawn_cmd_thread();
997        // 启动一次四问的标记(on_start 是 `engine.start()` 启动链的一环,四口最坏
998        // 4 × 2 秒不许把后面插件的启动一起拖住,更不许占入账 worker)。
999        self.shared.request_trade_reseed();
1000        self.shared.request_pipeline_refresh();
1001        // 0.8.0 配色双档:启动读一次档(命令线程,一次小文件读),用户没点过钮 = 缺省深色。
1002        let theme = load_theme();
1003        self.shared.set_theme(theme);
1004        if theme == crate::tokens::Theme::Light {
1005            self.shared.log_line("[ui] 主题:浅色(读盘 ui.theme)");
1006        }
1007    }
1008
1009    fn on_stop(&self) {
1010        self.shared.log_line("[ui] 界面插件下线");
1011        // 命令线程停线:它在 20ms 空转切片自查这个位,不会卡在 sleep 里。
1012        self.cmd_stop
1013            .store(true, std::sync::atomic::Ordering::Release);
1014    }
1015
1016    fn on_mail(&self, event: &ek_event::Event) {
1017        let ty = event.event_type.as_str();
1018        if ty == EVENT_LOG {
1019            if let Some(d) = event.data.downcast_ref::<LogData>() {
1020                let tag = if d.is_warning_or_above() {
1021                    "WARN"
1022                } else {
1023                    "INFO"
1024                };
1025                self.shared
1026                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
1027                // 0.5.0(用户定「链进行的状态改为事件日志推送」):`[链] ` 前缀的行除了进日志窗,
1028                // 还落进账户行的「阶段」栏(ek-ctp 0.9.8 D-QUERY-GATE ⑧)。这一条是纯显示落账,
1029                // 不参与 `apply_status` 那套在线/断供判据。
1030                // 前缀单源:`crate::state::CHAIN_STATE_PREFIX` = ek-ctp 0.9.8 那一份的 re-export
1031                // (state.rs:201;0.6.3 路标兑现,注释同步 —— 无本地字面)。
1032                if let Some(note) = d.msg.strip_prefix(crate::state::CHAIN_STATE_PREFIX) {
1033                    self.shared.note_chain(&d.gateway_name, note);
1034                }
1035            } else {
1036                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
1037            }
1038            return;
1039        }
1040        if ty == ek_ctp::EVENT_STATUS_PREFIX {
1041            // 0.7.0 第3刀:状态变化推送 → 一次到货三件事(文案 / 状态列 / 去重告警)。
1042            if let Some(ev) = event.data.downcast_ref::<ek_ctp::GatewayStatusEvent>() {
1043                self.shared.note_status_event(ev);
1044            } else {
1045                self.shared
1046                    .log_line("[eStatus] 载荷类型异常(非 GatewayStatusEvent)");
1047            }
1048            return;
1049        }
1050        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
1051            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
1052                let verb = if ty == EVENT_PLUG_UP {
1053                    "上线"
1054                } else {
1055                    "下线"
1056                };
1057                self.shared
1058                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
1059                // 在场位与运维窗那一行跟着这条线动(不等 4 拍的快照节拍)。
1060                self.shared.request_list_refresh();
1061                if ty == EVENT_PLUG_UP {
1062                    // 0.5 M3:装配显形改吃这条线 —— 不再靠每拍 list 去发现「谁新上线」。
1063                    if n.name == ek_oms::NAME {
1064                        self.shared.request_pipeline_refresh();
1065                    }
1066                    // 0.5 执行核查补的第三处:网关 / oms 上线之后重播种一次四张表。
1067                    // 摘除一个网关账户会作废它名下的委托与成交行,而 oms 那边那些行还在;
1068                    // 重新添加时不重问一次,界面就永久少一段历史。顺带覆盖「ui 先于 oms
1069                    // 起来、第一次播种问空了」这一序。
1070                    let is_account = self.shared.accounts().iter().any(|a| a.gateway == n.name);
1071                    if n.name == ek_oms::NAME || is_account {
1072                        self.shared.request_trade_reseed();
1073                    }
1074                    // 0.7.0 第3刀:网关上线 → 播种一次初始快照(运行期至多这一发 ask;
1075                    // 之后登录态/断供全靠 `eStatus.` 推送,界面零轮询)。
1076                    if is_account {
1077                        self.shared.request_status_seed(&n.name);
1078                    }
1079                    // 0.5 M3:订阅重放同样改吃这条线(与 `sync_accounts_connected` 的
1080                    // newly 回带是同一件事的两条路,留一条:这条即时、那条要等快照拍)。
1081                    let md = self.shared.md_source();
1082                    let subs = self.shared.subscriptions();
1083                    if !md.is_empty() && n.name == md && !subs.is_empty() {
1084                        self.shared.push_cmd(crate::state::UiCmd::CtpSubscribe {
1085                            gateway: n.name.clone(),
1086                            symbols: subs.clone(),
1087                        });
1088                        self.shared.log_line(format!(
1089                            "[ops] 行情源 {} 上线,重放订阅 {} 项",
1090                            n.name,
1091                            subs.len()
1092                        ));
1093                    }
1094                } else {
1095                    // 0.5 M1:网关下线 = 它名下的委托 / 成交 / 持仓 / 资金行全部作废。
1096                    // 推送模型里没人再告诉界面「这一行已经不存在了」,不清就是替一个
1097                    // 已经不存在的会话说话(与 0.2.19「实例不在场就作废会话读数」同口径)。
1098                    let removed = self.shared.clear_gateway_rows(&n.name);
1099                    if removed > 0 {
1100                        self.shared.log_line(format!(
1101                            "[ui] 插件下线 {}:作废 {} 行交易数据",
1102                            n.name, removed
1103                        ));
1104                    }
1105                    // 0.5 执行核查补的第二处:摘掉 oms 之后管线读数不会自己清空。
1106                    // 0.4 是每 4 拍问一次,4 秒内自己就空了;改事件触发后没人再问,
1107                    // 运维窗那一节会替一个已经不存在的 oms 说话。标记一次:下一拍问一次
1108                    // → TargetGone 快错 → `poll_pipeline` 的 Err 分支清空并落一条日志。
1109                    if n.name == ek_oms::NAME {
1110                        self.shared.request_pipeline_refresh();
1111                    }
1112                }
1113            }
1114            self.shared.bump();
1115            return;
1116        }
1117        if ty == EVENT_BAR {
1118            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
1119                self.shared.market().on_frame(f);
1120            }
1121            self.shared.bump();
1122            return;
1123        }
1124        if ty == EVENT_BAR_UPDATE {
1125            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
1126                self.shared.market().on_update(b);
1127            }
1128            self.shared.bump();
1129            return;
1130        }
1131        if ty == EVENT_TICK {
1132            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
1133                self.shared.update_tick(t);
1134            }
1135            return;
1136        }
1137        if ty == EVENT_CONTRACT {
1138            // 0.11.0 第4刀(ek-ctp R5):合约流分批到货,一帧 ≤2000 行 → 逐行上屏
1139            // (合约窗照旧一段段长出来,只是每一拍长一批);单条形继续受理(兼容)。
1140            if let Some(v) = event
1141                .data
1142                .downcast_ref::<Vec<ek_data::contract::ContractData>>()
1143            {
1144                for c in v {
1145                    self.shared.update_contract(c);
1146                }
1147            } else if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
1148                self.shared.update_contract(c);
1149            }
1150            return;
1151        }
1152        if ty == ek_ctp::EVENT_CONTRACT_FINISH {
1153            let gw = event
1154                .data
1155                .downcast_ref::<String>()
1156                .cloned()
1157                .unwrap_or_else(|| "?".to_string());
1158            self.shared
1159                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
1160            return;
1161        }
1162        // 0.5 M1 交易四表:后台只把事件推过来,界面自己入账(基线 BaseMonitor.process_event)。
1163        // 这里不 ask 任何东西,所以入账 worker 永不被后端拖住。
1164        if ty == W_ORDER {
1165            match event.data.downcast_ref::<OrderData>() {
1166                Some(o) => self.shared.note_order(o),
1167                None => self.shared.note_bad_payload(W_ORDER),
1168            }
1169            return;
1170        }
1171        if ty == W_TRADE {
1172            match event.data.downcast_ref::<TradeData>() {
1173                Some(t) => self.shared.note_trade(t),
1174                None => self.shared.note_bad_payload(W_TRADE),
1175            }
1176            return;
1177        }
1178        if ty == W_POSITION {
1179            match event.data.downcast_ref::<PositionData>() {
1180                Some(p) => self.shared.note_position(p),
1181                None => self.shared.note_bad_payload(W_POSITION),
1182            }
1183            return;
1184        }
1185        if ty == W_ACCOUNT {
1186            match event.data.downcast_ref::<AccountData>() {
1187                Some(a) => self.shared.note_funds(a),
1188                None => self.shared.note_bad_payload(W_ACCOUNT),
1189            }
1190            return;
1191        }
1192        if ty == EVENT_TIMER {
1193            self.refresh();
1194        }
1195    }
1196}