Files
Unlucky-Launcher/crates/app-iced/src/pages/logs.rs
T
AskaEth 063a1dba39 feat(app-iced): M95 日志页接真组件 + 诊断面板(等宽折行 / 按严重度分组 / 一行摘要)
日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘):

- 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形
  折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 +
  mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。
- 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE),
  每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」,
  摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。
- 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。
- 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`):
  第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。
- 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。
- 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 /
  `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段;
  `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的
  `log_lines`(呈现层操作,不造命令)。
- i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把
  `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs`
  (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。

验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净;
`test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图
`.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。

遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`;
`Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。
2026-11-02 22:26:53 +08:00

150 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 日志页(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),
),
)
}