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`(点了只有占位日志)。
This commit is contained in:
AskaEth committed 2026-11-02 22:26:53 +08:00
1 parent c0396544c5
commit 063a1dba39
16 files changed
+1392 -157

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
//! 日志行列表:**等宽 + 折行 + 独立滚动**。
//!
//! ## 为什么折行而不是横向滚动(tower 要求二选一,这里选折行)
//!
//! 正文列硬上限 424px,而崩溃堆栈动辄 200+ 字符(样例里最长的一行 214 字符)。两条路:
//!
//! | 方案 | 代价 |
//! |---|---|
//! | 横向滚动 | 424px 里 200 字符 ≈ 8 屏,读一条 `Caused by:` 要来回甩;外壳已经是竖向滚动,横竖两向嵌套后滚轮归属会打架 |
//! | **折行(本页选)** | 一条日志可能占 2~3 行,行号/等级栏必须能标明「这是同一条」 |
//!
//! 选折行的决定性理由:**折行让等宽族只承担「视觉密度」,不承担「列对齐」**。等宽栈在
//! Windows 上是 `Cascadia Mono` / `Consolas`,它们没有 CJK 字形——中文日志要靠 cosmic-text
//! 逐字回退到系统 CJK 族(本机实测回退到 `Microsoft YaHei UI`),回退字形的 advance 不是 1ch。
//! 横向滚动方案里「第 40 列对齐」是有意义的(堆栈缩进、`at a.b.C(File.java:12)` 的括号列),
//! 一旦 CJK 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。
//! 左栏那条 2px 等级条是跨行的(`Length::Fill`),所以「同一条日志折了 3 行」在视觉上仍是
//! 一个整体。
//!
//! ## 等宽族的实际落点
//!
//! `ui.families().mono` 是启动时用系统字体库核对过的族名([`crate::fonts`] 求值,启动日志里
//! 有一行 `mono=…`),本页只通过 [`mono_style`] 用它,不写死族名。字号/行高走
//! `TypeRole::BodySmall`(12/16),与报告里的数值表对得上(见 `logs/tests.rs`)。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, space};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::{FontFamily, TypeRole, TypeStyle};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Level, Line, Logs};
use super::panel;
/// 等级条宽度(2px:够看见,又不抢日志正文的起始列)。
const BAR_WIDTH: f32 = 2.0;
/// 日志卡片:标题行(`OUTPUT` + 行数)+ 独立滚动的行列表。高度由调用方定(见 [`panel`])。
pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> {
let catalog = Catalog::of(lang);
let head = row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::LogsOutputTitle),
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
logs.lines.len().to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::default().bottom(ui.space(Space::X2)));
let rows = column(
logs.lines
.iter()
.map(|line| entry(ui, line))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill);
panel::card(ui, height, column![head, panel::scroll(ui, rows)])
}
/// 一行:等级条(跨行)+ 等宽正文(折行)。
fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> {
let color = color_of(ui, line.level);
let bar = container(space::horizontal())
.width(BAR_WIDTH)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
radius: ui.radius(ShapeScale::Full, BAR_WIDTH),
..Border::default()
},
..container::Style::default()
});
row![
bar,
mono(ui, &line.text, color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Start)
.into()
}
/// 等级 → 颜色(错误用 `Error`,警告用 `Tertiary`:与主题的 warning 槽位同源)。
fn color_of(ui: &Ui, level: Level) -> Color {
match level {
Level::Error => ui.color(Role::Error),
Level::Warn => ui.color(Role::Tertiary),
Level::Info => ui.color(Role::OnSurface),
Level::Debug => ui.color(Role::OnSurfaceVariant),
}
}
/// 等宽排版档:**同一个 `BodySmall` 档,只把字体族换成 mono 栈**。
///
/// 不新造字号(12/16 与辅助说明同档),所以「等宽」这件事只改字体族这一个维度,
/// 行高仍是令牌值——CJK 回退只换字形,行高不会跳(这也是折行方案的前提之一)。
#[must_use]
pub fn mono_style(ui: &Ui) -> TypeStyle {
TypeStyle {
family: FontFamily::Mono,
..ui.style(TypeRole::BodySmall)
}
}
/// 等宽文本(字号 / 行高 / 字重 / 族全来自 [`mono_style`])。
#[must_use]
pub fn mono<'a>(ui: &Ui, content: &'a str, color: Color) -> iced::widget::Text<'a> {
mono_styled(ui, content, color)
}
/// 同 [`mono`],但文案是**拥有的**字符串(摘要行这类运行时拼出来的短串)。
#[must_use]
pub fn mono_owned<'a>(ui: &Ui, content: String, color: Color) -> iced::widget::Text<'a> {
mono_styled(ui, content, color)
}
fn mono_styled<'a>(
ui: &Ui,
content: impl iced::widget::text::IntoFragment<'a>,
color: Color,
) -> iced::widget::Text<'a> {
let style = mono_style(ui);
iced::widget::text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
}