//! 日志页(M95):**单列**版面的第二个用例,也是本项目卖点(崩溃日志诊断)的落地页。 //! //! ## 一屏由三块组成 //! //! ```text //! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐ //! ├ 诊断卡片(只在有结论时出现,**按严重度分组**)──────────────────────┤ //! │ DIAGNOSIS 3 CRITICAL 1 / WARNING 2 │ //! │ integrity.unsatisfied_link(规则 id,等宽) │ //! │ 结论(规则表原文) / 说明 / Suggested fix / Confidence 85% │ //! │ ─ One-line summary ─ (等宽可复制的一行) [Copy summary] │ //! ├ 日志行列表(等宽 + 折行 + **独立滚动**,吃满剩余高度)──────────────┤ //! └──────────────────────────────────────────────────────────────────┘ //! ``` //! //! 数据源只有两个(页面**不读盘**,路径从不进过页面): //! //! | 来源 | 谁造 | 用途 | //! |---|---|---| //! | presenter 快照(`log_lines` / `diagnosis` / `log_sessions`) | `app/view.rs::logs_model` | 产品运行时 | //! | [`demo`] 内嵌的崩溃日志片段 | `pages::demo_model`(`ULL_LOGS_FIXTURE=empty\|live\|crash`) | 出图 | //! //! ## M95 的取舍(逐条) //! //! | # | 取舍 | 为什么 | //! |---|---|---| //! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | //! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 | //! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) | //! | 4 | 严重度按**置信度折算** | `dto::Diagnosis` 没有 `severity` 字段(core 有,DTO 丢了,已报 finding),阈值写在 `Severity::of_confidence` | //! | 5 | 页头保持**两行 84** | M84 定的版式(运行状态在副标题上);计数留在两张卡片自己的标题行,避免为了拼计数把 i18n 字符串漏进页头生命期 | //! //! ## 指令(只有真业务命令才 dispatch) //! //! | 控件 | 消息 | 落到哪 | //! |---|---|---| //! | Copy summary | [`Message::CopySummary`] | `app/update.rs` → `iced::clipboard::write` | //! | Open folder | `Action::OpenLogs` | **还没有 `AppCommand`**(报 finding,现在是占位分支) | //! | Clear | `Action::ClearLogs` | `app/update.rs` → 清空 presenter 的 `log_lines` | //! | 空态的 CTA | [`Message::Navigate`] | 压一层路由栈回实例页(真导航,不是占位动作) | //! //! 截图的强弱边界写在 `logs/tests.rs` 每条断言下面。 mod demo; mod diagnostic; mod lines; mod model; mod panel; #[cfg(test)] mod tests; /// 诊断卡在正文里的高度份额(见 [`panel`] 的模块文档:两块必须都是定高 + 内部滚动)。 const DIAGNOSIS_SHARE: u16 = 3; /// 日志卡的高度份额。 const LOGS_SHARE: u16 = 2; pub use demo::demo; pub use model::{Finding, Level, Line, Logs, Outcome, Severity}; use iced::widget::column; use iced::{Element, Length}; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_i18n::catalog::{Catalog, Key}; use unlucky_i18n::Lang; use unlucky_presenter::router::Route; use crate::app::components::buttons::{self, Button}; use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; use crate::theme::Ui; use super::frame::{self, Header}; use super::layout::FrameMetrics; use super::{Action, Message}; /// 整页。 pub fn view<'a>( ui: &'a Ui, metrics: FrameMetrics, logs: &'a Logs, lang: Lang, ) -> Element<'a, Message> { let catalog = Catalog::of(lang); let header = Header::new(catalog.text(Key::NavLogs)) .subtitle(subtitle(&catalog, logs)) .action(buttons::text( ui, Button::new( catalog.text(Key::LogsOpenFolder), Message::Action(Action::OpenLogs), ), )) .action(buttons::text( ui, Button::new( catalog.text(Key::LogsClear), Message::Action(Action::ClearLogs), ), )); let body = if logs.is_empty() { frame::single_column(metrics, empty(ui, &catalog)) } else if logs.findings.is_empty() { // 没有结论:日志卡片独吞整页(这样「正常运行的一场」不会留一块空卡片)。 frame::single_column(metrics, lines::card(ui, logs, lang, Length::Fill)) } else { // 有结论:两块**都定高**、各自独立滚动(诊断 3 : 日志 2)。 // 定高不是偷懒:第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(截图见报告)。 let stack = column![ diagnostic::card(ui, logs, lang, Length::FillPortion(DIAGNOSIS_SHARE)), lines::card(ui, logs, lang, Length::FillPortion(LOGS_SHARE)), ] .width(Length::Fill) .height(Length::Fill) .spacing(ui.space(Space::X4)); frame::single_column(metrics, stack) }; frame::page(ui, metrics, header, body) } /// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。 fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str { if logs.is_empty() { catalog.text(Key::LogsSubtitleIdle) } else { catalog.text(Key::LogsSubtitleActive) } } /// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。 fn empty<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> { empty_state( ui, EmptyState::new( IconKind::Logs, catalog.text(Key::LogsEmptyTitle), catalog.text(Key::LogsEmptyBody), ) .action( catalog.text(Key::LogsEmptyAction), Message::Navigate(Route::Instances), ), ) }