1use std::sync::Arc;
16use std::time::Duration;
17
18use ek_core::main_engine::{MainEngine, PluginState};
19use ek_core::plugin::Plugin;
20use ek_dm::plugin::DmConfig;
21use ek_station::{PlugKind, StationConfig, default_kinds};
22
23use crate::plugin::{UI_NAME, UiPlugin};
24use crate::state::UiShared;
25
26pub fn ui_kinds(shared: &Arc<UiShared>, dm_rt: bool) -> Vec<PlugKind> {
34 let mut kinds: Vec<PlugKind> = default_kinds()
35 .into_iter()
36 .filter(|k| k.name != "dm")
37 .collect();
38 kinds.push(PlugKind {
39 name: "dm",
40 doc: "数据管理器(KlineManager;real_time 更新门开,行情板/K线页取 eBarUpdate./eBar.)",
41 build: Arc::new(move || -> Arc<dyn Plugin> {
42 Arc::new(ek_dm::plugin::DmPlugin::new(DmConfig {
43 real_time: dm_rt,
44 ..DmConfig::minute("dm")
45 }))
46 }),
47 build_named: None,
48 name_validator: None,
49 });
50 kinds.push(PlugKind {
51 name: UI_NAME,
52 doc: "常驻观测界面(eLog + eTimer 1s 快照;关窗即 stop)",
53 build: {
54 let shared = Arc::clone(shared);
55 Arc::new(move || -> Arc<dyn Plugin> { Arc::new(UiPlugin::new(Arc::clone(&shared))) })
56 },
57 build_named: None,
58 name_validator: None,
59 });
60 kinds.push(PlugKind {
65 name: "tq",
66 doc: "天勤桥(行情/K线接入;凭据走密库引用,口令永不进档案)",
67 build: Arc::new(|| -> Arc<dyn Plugin> {
68 let bridge_cfg = ek_tq::bridge::BridgeConfig {
73 program: ek_tq::pyenv::python_program(),
74 script: ek_tq::pyenv::real_script(),
75 handshake_ms: ek_tq::consts::BRIDGE_HANDSHAKE_MS,
76 ..Default::default()
77 };
78 Arc::new(ek_tq::plugin::TqPlugin::new(
79 ek_tq::plugin::TqPluginSpec::new(bridge_cfg),
80 ))
81 }),
82 build_named: None,
83 name_validator: None,
84 });
85 kinds
86}
87
88pub fn spawn_host_watcher(
92 engine: &Arc<MainEngine>,
93 shared: &Arc<UiShared>,
94) -> std::thread::JoinHandle<()> {
95 let engine = Arc::clone(engine);
96 let shared = Arc::clone(shared);
97 std::thread::spawn(move || {
98 while !shared.is_stop() {
99 std::thread::sleep(Duration::from_millis(120));
100 }
101 shared.log_line("[host] 窗口关闭,close_all …");
102 engine.close_all(Duration::from_secs(3));
103 shared.log_line("[host] 已收场");
104 })
105}
106
107fn bundle_op(shared: &Arc<UiShared>, engine: &MainEngine, dir: &str, export: bool) -> String {
109 let host = crate::faces::CfgBridge::new(shared);
110 let dest = std::path::Path::new(dir);
111 if dir.trim().is_empty() {
112 return "目录为空(先填路径)".to_string();
113 }
114 if export {
115 let Some(root) = host.cfg.as_ref().map(|c| c.root().to_path_buf()) else {
116 return "导出失败:档案托底根不可用".to_string();
117 };
118 match crate::pluginwin::export_bundle(&root, dest) {
119 Ok((c, s)) => {
120 let msg = format!("导出完成:{c} 段 → {}(跳过非段文件 {s})", dest.display());
121 shared.log_line(format!("[ui] {msg}"));
122 msg
123 }
124 Err(e) => format!("导出失败:{e}"),
125 }
126 } else {
127 let lines = crate::pluginwin::import_bundle(&host, dest, &|ns, sec| {
128 crate::faces::apply_section(engine, ns, sec)
129 });
130 let msg = lines.join(" | ");
131 shared.log_line(format!("[ui] 档案包导入:{msg}"));
132 msg
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Stage {
139 Setup,
141 Running,
143}
144
145pub struct StageApp {
148 shared: Arc<UiShared>,
149 engine: Arc<MainEngine>,
151 kinds: Vec<PlugKind>,
153 sel: Vec<bool>,
155 store: Option<ek_cfg::Store>,
157 stage: Stage,
159 notice: Option<String>,
161 list_dir: String,
163 fs: crate::faces::FaceStates,
165 wins_open: std::collections::HashMap<String, bool>,
167 pluginwin_prev: bool,
169 bundle_dir: String,
171 bundle_notice: Option<String>,
172 ready_marker: Option<std::path::PathBuf>,
174 watcher: Option<std::thread::JoinHandle<()>>,
176}
177
178impl StageApp {
179 #[must_use]
181 pub fn new_setup(
182 engine: Arc<MainEngine>,
183 shared: Arc<UiShared>,
184 kinds: Vec<PlugKind>,
185 store: Option<ek_cfg::Store>,
186 ready_marker: Option<std::path::PathBuf>,
187 ) -> Self {
188 let sel = kinds.iter().map(|_| true).collect::<Vec<bool>>();
189 Self {
190 shared,
191 engine,
192 kinds,
193 sel,
194 store,
195 stage: Stage::Setup,
196 notice: None,
197 list_dir: String::new(),
198 fs: crate::faces::FaceStates::new(),
199 wins_open: std::collections::HashMap::new(),
200 pluginwin_prev: false,
201 bundle_dir: String::new(),
202 bundle_notice: None,
203 ready_marker,
204 watcher: None,
205 }
206 }
207
208 #[must_use]
210 pub fn stage(&self) -> Stage {
211 self.stage
212 }
213
214 #[must_use]
216 pub fn picked(&self) -> Vec<String> {
217 self.kinds
218 .iter()
219 .zip(self.sel.iter())
220 .filter(|(_, ok)| **ok)
221 .map(|(k, _)| k.name.to_string())
222 .collect()
223 }
224
225 #[must_use]
227 pub fn assembled(&self) -> bool {
228 !self.engine.plugin_names().is_empty()
229 }
230
231 pub fn set_selected(&mut self, name: &str, on: bool) {
233 if let Some(i) = self.kinds.iter().position(|k| k.name == name) {
234 self.sel[i] = on && name != UI_NAME;
235 }
236 }
237
238 pub fn do_assemble(&mut self) -> String {
242 let kinds: Vec<PlugKind> = self
243 .kinds
244 .iter()
245 .enumerate()
246 .filter(|(i, _)| self.sel[*i])
247 .map(|(_, k)| k.clone())
248 .collect();
249 let names: Vec<String> = kinds.iter().map(|k| k.name.to_string()).collect();
250 let cfg = StationConfig {
251 plugins: names.clone(),
252 };
253 let r = match &self.store {
254 Some(s) => ek_station::boot_with_cfg(&self.engine, &kinds, &cfg, Some(s)),
255 None => ek_station::boot(&self.engine, &kinds, &cfg),
256 };
257 match r {
258 Ok(_) => {
259 self.shared
260 .log_line(format!("[ui] 装配:{} 壳(未启动)", names.join(",")));
261 format!(
262 "已装配 {} 壳(未启动;参数面可改):{}",
263 names.len(),
264 names.join(",")
265 )
266 }
267 Err(e) => {
268 let msg = format!("装配失败:{e:?}");
269 self.shared.log_line(format!("[ui] {msg}"));
270 msg
271 }
272 }
273 }
274
275 pub fn do_start(&mut self) -> String {
278 if let Err(e) = self.engine.start() {
279 self.stage = Stage::Running;
281 self.shared.set_assembly_locked(false);
282 self.ensure_watcher();
283 let msg = format!("启动失败:{e:}(运维面可修,装配不锁定)");
284 self.shared.log_line(format!("[ui] {msg}"));
285 return msg;
286 }
287 let not_ready: Vec<String> = self
288 .engine
289 .plugin_names()
290 .into_iter()
291 .filter(|n| !matches!(self.engine.get_state(n), Some(PluginState::Running)))
292 .collect();
293 self.stage = Stage::Running;
294 self.shared.set_assembly_locked(not_ready.is_empty());
295 self.ensure_watcher();
296 self.write_ready_marker();
297 let shared_restore = Arc::clone(&self.shared);
299 std::thread::spawn(move || {
300 let rows = shared_restore.restore_saved_accounts();
301 let _ = rows;
302 });
303 let msg = if not_ready.is_empty() {
304 "已启动:装配面已锁定(只可看不可动)".to_string()
305 } else {
306 format!(
307 "已启动,{} 壳未就绪:{} — 装配**不锁定**(运维面留修路)",
308 not_ready.len(),
309 not_ready.join(",")
310 )
311 };
312 self.shared.log_line(format!("[ui] {msg}"));
313 msg
314 }
315
316 pub fn export_plugin_list(&mut self, dir: &str) -> String {
322 let factory_names: Vec<String> = default_kinds()
323 .into_iter()
324 .map(|k| k.name.to_string())
325 .collect();
326 let picked = self.picked();
327 let exportable: Vec<String> = picked
328 .iter()
329 .filter(|n| factory_names.iter().any(|f| f == *n))
330 .cloned()
331 .collect();
332 let skipped: Vec<String> = picked
333 .iter()
334 .filter(|n| !factory_names.iter().any(|f| f == *n))
335 .cloned()
336 .collect();
337 if exportable.is_empty() {
338 return "无可导出(勾选里没有无界面可用的组件)".to_string();
339 }
340 if dir.trim().is_empty() {
341 return "目录为空(先填路径)".to_string();
342 }
343 let path = std::path::Path::new(dir).join("ek-plugins.txt");
344 let header = if skipped.is_empty() {
345 "# ek-station 启动清单(ek-ui 阶段机导出,一行一名)".to_string()
346 } else {
347 format!(
348 "# ek-station 启动清单(ek-ui 阶段机导出,一行一名;跳过界面自带工厂:{})",
349 skipped.join(",")
350 )
351 };
352 let body = format!("{}\n{}\n", header, exportable.join("\n"));
353 match std::fs::File::create(&path).and_then(|mut f| {
354 use std::io::Write;
355 f.write_all(body.as_bytes())
356 }) {
357 Ok(()) => {
358 self.shared
359 .log_line(format!("[ui] 启动清单已导出:{}", path.display()));
360 if skipped.is_empty() {
361 format!("清单已导出:{}({} 项)", path.display(), exportable.len())
362 } else {
363 format!(
364 "清单已导出:{}({} 项;跳过界面自带工厂 {})",
365 path.display(),
366 exportable.len(),
367 skipped.join(",")
368 )
369 }
370 }
371 Err(e) => format!("导出失败:{e}"),
372 }
373 }
374
375 fn ensure_watcher(&mut self) {
376 if self.watcher.is_none() {
377 self.watcher = Some(spawn_host_watcher(&self.engine, &self.shared));
378 }
379 }
380
381 fn write_ready_marker(&self) {
382 let Some(marker) = &self.ready_marker else {
383 return;
384 };
385 use std::io::Write;
386 if let Ok(mut f) = std::fs::File::create(marker) {
387 let _ = f.write_all(crate::launch::READY_TEXT.as_bytes());
388 }
389 }
390
391 pub fn render_setup_panel(&mut self, ui: &mut egui::Ui) {
393 ui.heading("组件配置(启动前)");
394 ui.label("勾选要装载的组件 → 装配 → 参数面可改 → 启动运行;启动后装配锁定(只可看不可动)。");
395 ui.separator();
396 egui::ScrollArea::vertical()
397 .max_height(210.0)
398 .show(ui, |ui| {
399 for (i, k) in self.kinds.iter().enumerate() {
400 ui.horizontal(|ui| {
401 if k.name == UI_NAME {
402 let mut t = true;
404 ui.add_enabled(false, egui::Checkbox::new(&mut t, k.name));
405 self.sel[i] = true;
406 } else {
407 ui.checkbox(&mut self.sel[i], k.name);
408 }
409 let _ = ui.label(egui::RichText::new(k.doc.to_string()).small().weak());
410 });
411 }
412 });
413 ui.separator();
414 let picked = self.picked();
415 ui.label(if picked.is_empty() {
416 "已选: (无)".to_string()
417 } else {
418 format!("已选: {}", picked.join(", "))
419 });
420 ui.horizontal(|ui| {
421 let assembled = self.assembled();
422 if ui
423 .add_enabled(
424 !assembled && !picked.is_empty(),
425 egui::Button::new("装配(按勾选)"),
426 )
427 .clicked()
428 {
429 let msg = self.do_assemble();
430 self.notice = Some(msg);
431 }
432 if ui
433 .add_enabled(assembled, egui::Button::new("启动运行"))
434 .clicked()
435 {
436 let msg = self.do_start();
437 self.notice = Some(msg);
438 }
439 if ui.button("参数面(改参数)").clicked() {
440 self.shared
441 .set_pluginwin_open(!self.shared.pluginwin_open());
442 }
443 if ui.button("档案包(导入参数)").clicked() {
444 self.shared.set_bundle_open(!self.shared.bundle_open());
445 }
446 });
447 ui.horizontal(|ui| {
448 ui.add(
449 egui::TextEdit::singleline(&mut self.list_dir)
450 .desired_width(300.0)
451 .hint_text("启动清单导出目录,如 C:\\Users\\<你>\\Documents"),
452 );
453 if ui.button("导出启动清单(--from 可用)").clicked() {
454 let dir = self.list_dir.clone();
455 let msg = self.export_plugin_list(&dir);
456 self.notice = Some(msg);
457 }
458 });
459 if self.assembled() && self.stage == Stage::Setup {
460 ui.label(
461 egui::RichText::new("已装配(未启动):参数面窗可改,启动时按档案回灌。")
462 .color(crate::tokens::info(ui.ctx()))
463 .small(),
464 );
465 }
466 if let Some(n) = &self.notice {
467 ui.label(egui::RichText::new(n.as_str()).monospace().small());
468 }
469 }
470}
471
472impl eframe::App for StageApp {
473 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
474 let host = crate::faces::CfgBridge::new(&self.shared);
475 let mounted = crate::faces::mounted(&self.engine);
476 let pw = self.shared.pluginwin_open();
478 if pw && !self.pluginwin_prev {
479 self.wins_open.clear();
480 }
481 self.pluginwin_prev = pw;
482 if pw {
483 for (name, title) in mounted.iter() {
484 let mut open = *self.wins_open.get(name).unwrap_or(&true);
485 egui::Window::new(title.as_str())
486 .id(egui::Id::new(format!("pluginwin-{name}")))
487 .open(&mut open)
488 .default_width(460.0)
489 .show(ctx, |ui| {
490 crate::faces::render_face(ui, &self.engine, name, &host, &mut self.fs);
491 });
492 self.wins_open.insert(name.clone(), open);
493 }
494 }
495 if self.shared.bundle_open() {
498 let mut open = true;
499 let setup = self.stage == Stage::Setup;
500 egui::Window::new("档案包")
501 .id(egui::Id::new("bundlewin"))
502 .open(&mut open)
503 .default_width(430.0)
504 .show(ctx, |ui| {
505 ui.label("目录(导出目标 / 导入来源,同一目录可两用):");
506 ui.add(
507 egui::TextEdit::singleline(&mut self.bundle_dir)
508 .desired_width(400.0)
509 .hint_text("C:\\Users\\<你>\\Documents\\ek-bundle"),
510 );
511 ui.horizontal(|ui| {
512 if ui
513 .add_enabled(
514 !setup,
515 egui::Button::new("导出(段文件整批复制)"),
516 )
517 .clicked()
518 {
519 self.bundle_notice =
520 Some(bundle_op(&self.shared, &self.engine, &self.bundle_dir, true));
521 }
522 if ui.button("导入(逐段收编+受理)").clicked() {
523 self.bundle_notice = Some(bundle_op(
524 &self.shared,
525 &self.engine,
526 &self.bundle_dir,
527 false,
528 ));
529 }
530 });
531 if let Some(n) = &self.bundle_notice {
532 ui.label(egui::RichText::new(n.as_str()).monospace().small());
533 }
534 ui.label(
535 egui::RichText::new("密码不落包(在 Windows 凭据管理器);换机后口令走 ekapp add-account(stdin)")
536 .small()
537 .weak(),
538 );
539 });
540 self.shared.set_bundle_open(open);
541 }
542 if self.stage == Stage::Setup {
545 egui::CentralPanel::default().show(ctx, |ui| {
546 self.render_setup_panel(ui);
547 });
548 } else {
549 crate::render_body(ctx, &self.shared);
550 }
551 ctx.request_repaint_after(Duration::from_secs(1));
552 }
553}
554
555#[derive(Debug, Clone)]
558pub struct RunOpts {
559 pub ready_marker: Option<std::path::PathBuf>,
561 pub dm_rt: bool,
563 pub auto_boot: bool,
566}
567
568impl Default for RunOpts {
569 fn default() -> Self {
573 Self {
574 ready_marker: None,
575 dm_rt: true,
576 auto_boot: true,
577 }
578 }
579}
580
581impl RunOpts {
582 pub fn plain() -> Self {
584 Self::default()
585 }
586}
587
588pub fn run_station_ui(cfg: &StationConfig, opts: &RunOpts) -> Result<(), String> {
591 let engine = Arc::new(MainEngine::new(None));
592 let shared = Arc::new(UiShared::new());
593 let store = ek_cfg::Store::eco();
594
595 let store_ok = store.ok();
600 if opts.auto_boot {
601 let boot = match &store_ok {
602 Some(s) => {
603 ek_station::boot_with_cfg(&engine, &ui_kinds(&shared, opts.dm_rt), cfg, Some(s))
604 }
605 None => ek_station::boot(&engine, &ui_kinds(&shared, opts.dm_rt), cfg),
606 };
607 boot.map_err(|e| format!("boot: {e:?}"))?;
608 if let Err(e) = engine.start() {
613 engine.close_all(Duration::from_secs(3));
614 return Err(format!("start: {e:?}"));
615 }
616 shared.set_assembly_locked(true);
617 }
618
619 let mut app = StageApp::new_setup(
620 Arc::clone(&engine),
621 Arc::clone(&shared),
622 ui_kinds(&shared, opts.dm_rt),
623 store_ok,
624 opts.ready_marker.clone(),
625 );
626 if opts.auto_boot {
627 app = StageApp {
628 stage: Stage::Running,
629 ..app
630 };
631 app.write_ready_marker();
632 }
633
634 let watcher = spawn_host_watcher(&engine, &shared);
635 let _ = &watcher;
637
638 let opts = eframe::NativeOptions {
639 viewport: egui::ViewportBuilder::default()
640 .with_inner_size([1280.0, 860.0])
641 .with_title("ek-station"),
642 ..Default::default()
643 };
644 let result = eframe::run_native(
646 "ek-station",
647 opts,
648 Box::new(|cc| {
649 crate::fonts::apply_cjk_fonts(&cc.egui_ctx); Ok(Box::new(app) as Box<dyn eframe::App>)
651 }),
652 );
653 shared.request_stop();
654 let _ = watcher.join();
655 result.map_err(|e| format!("ui: {e:?}"))
656}
657
658pub fn run_desktop() -> Result<(), String> {
662 let engine = Arc::new(MainEngine::new(None));
663 let shared = Arc::new(UiShared::new());
664 let store = ek_cfg::Store::eco().ok();
665 let app = StageApp::new_setup(
666 Arc::clone(&engine),
667 Arc::clone(&shared),
668 ui_kinds(&shared, true),
669 store,
670 None,
671 );
672 let watcher = spawn_host_watcher(&engine, &shared);
673 let opts = eframe::NativeOptions {
674 viewport: egui::ViewportBuilder::default()
675 .with_inner_size([1280.0, 860.0])
676 .with_title("ek-station 桌面"),
677 ..Default::default()
678 };
679 let result = eframe::run_native(
680 "ek-station 桌面",
681 opts,
682 Box::new(|cc| {
683 crate::fonts::apply_cjk_fonts(&cc.egui_ctx);
684 Ok(Box::new(app) as Box<dyn eframe::App>)
685 }),
686 );
687 shared.request_stop();
688 let _ = watcher.join();
689 result.map_err(|e| format!("ui: {e:?}"))
690}