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

+153
View File
@@ -0,0 +1,153 @@
//! 日志页演示数据:**只给 `pages_demo` 出图与单测**,产品运行时的数据来自 presenter 快照
//! (`app/view.rs::logs_model`)。页面任何路径都不读盘:日志文本要么来自快照,要么来自
//! 本文件 `include_str!` 内嵌的 [`SAMPLE`](编译期进二进制,运行时零 I/O)。
//!
//! ## 为什么用环境变量挑状态
//!
//! 三种状态(空态 / 有日志 / 有诊断)要分别出图,而 `pages_demo` 的 `--screen` 只有一个
//! `logs`。环境变量是这个 crate 已有的 harness 口径(`ULL_GALLERY_SHOT` / `ULL_FEEDBACK_PART`
//! 都是这么挑的,见 `components/gallery/mod.rs` 的说明):既能被测试直接调纯函数覆盖,
//! 又不用跟别人的 CLI 解析抢同一个文件。
//!
//! ```text
//! ULL_LOGS_FIXTURE=empty pages_demo --screen=logs --screenshot=logs-empty-light.png
//! ```
//!
//! 取值:`empty`(空态)/ `live`(有日志、无诊断)/ 其它或未设 = `crash`(日志 + 三条诊断)。
//! 默认取 `crash`:一屏就能看见页面的全部结构,reviewer 不必先猜环境变量。
//!
//! ## 样例内容的出处
//!
//! [`SAMPLE`] 是按 `core/diagnose/rules.d/samples/graphics/graphics_driver.log` 与
//! `integrity/unsatisfied_link.log` 的形状写的(含中文 + 堆栈 + 200 字符以上的长行),
//! 诊断结论的 `rule` / `title` / `hint` 抄自规则表 `rules.d/integrity.yaml` 与 `graphics.yaml`。
//! **中文在这里是「规则表的输出(数据)」,不是界面文案**:界面文案(标题 / 按钮 / 分组名 /
//! 空态)全部走 `unlucky_i18n` 的 key,见 `logs.rs`。
use super::model::{Finding, Level, Line, Logs, Outcome, Severity};
/// 挑演示状态的环境变量。
pub const STATE_ENV: &str = "ULL_LOGS_FIXTURE";
/// 内嵌的崩溃日志片段(见模块文档的出处)。
const SAMPLE: &str = include_str!("sample_crash.log");
/// 演示状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
/// 空态:没有输出,也没有诊断。
Empty,
/// 有日志、无诊断(正常跑着的一场)。
Live,
/// 崩溃现场:日志 + 诊断结论。
Crash,
}
impl State {
/// 从环境变量取值解析(未设 / 不认识一律 `Crash`)。
#[must_use]
pub fn of_env_value(raw: Option<&str>) -> Self {
match raw {
Some("empty") => Self::Empty,
Some("live") => Self::Live,
_ => Self::Crash,
}
}
/// 造出这个状态的视图模型。
#[must_use]
pub fn build(self) -> Logs {
match self {
Self::Empty => Logs::default(),
Self::Live => Logs {
outcome: Some(Outcome::Ok),
findings: Vec::new(),
..crash()
},
Self::Crash => crash(),
}
}
}
/// harness 入口:按 [`STATE_ENV`] 挑状态。
#[must_use]
pub fn demo() -> Logs {
State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build()
}
/// 崩溃现场(日志 + 三条诊断,覆盖 critical / warning 两个分组)。
#[must_use]
pub fn crash() -> Logs {
Logs {
instance: Some("开发测试".to_owned()),
when: Some("11-02 19:40".to_owned()),
outcome: Some(Outcome::Crashed),
lines: parse(SAMPLE),
findings: findings(),
}
}
/// 解析内嵌样例:`#` 注释与空行跳过,首字符是等级前缀。
fn parse(text: &str) -> Vec<Line> {
text.lines()
.filter_map(|raw| {
let raw = raw.trim_end();
if raw.is_empty() || raw.starts_with('#') {
return None;
}
let (prefix, rest) = raw.split_at(1);
let level = match prefix {
"D" => Level::Debug,
"I" => Level::Info,
"W" => Level::Warn,
"E" => Level::Error,
_ => return None,
};
Some(Line {
text: rest.trim_start().to_owned(),
level,
})
})
.collect()
}
/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;`severity` 走 [`Severity::of_confidence`],
/// 也就是 DTO 缺 `severity` 时产品路径用的同一条折算)。
fn findings() -> Vec<Finding> {
vec![
finding(
"integrity.unsatisfied_link",
85,
"找不到原生库(natives):lwjgl_opengl.dll 没解压出来或被杀软删了",
"重装组件(重新解压 natives);Windows 上检查杀软隔离区",
Some("reinstall natives"),
),
finding(
"graphics.gl_operation_failure",
70,
"某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)",
"换掉最近装的光影/资源包,或把资源包分辨率降下来",
Some("change_graphics.renderer = OpenGL 兼容模式"),
),
finding(
"integrity.no_class_def_found",
60,
"NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)",
"先检查 libraries 是否完整,再排查 mods 里的解压目录",
Some("reinstall libraries"),
),
]
}
fn finding(rule: &str, confidence: u8, title: &str, hint: &str, action: Option<&str>) -> Finding {
Finding {
rule: rule.to_owned(),
severity: Severity::of_confidence(confidence),
title: title.to_owned(),
hint: hint.to_owned(),
confidence,
action: action.map(str::to_owned),
}
}