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

+218
View File
@@ -0,0 +1,218 @@
//! 日志页的视图模型:**纯展示字段**(沿用 `pages::model` 的分层——不含 iced 类型,也不认识 presenter)。
//!
//! ## 为什么单独一层
//!
//! 渲染函数只吃 [`Logs`],于是同一棵树能被三份数据喂:
//!
//! | 数据源 | 谁造 | 用途 |
//! |---|---|---|
//! | presenter 快照(`AppState`) | `app/view.rs::logs_model` | 产品运行时 |
//! | [`super::demo`] 的内嵌崩溃日志片段 | `pages::demo_model` | `pages_demo --screen=logs` 出图 |
//! | [`Logs::default`] | 空态 | 真的没有输出(未接线时也是这个兜底) |
//!
//! 页面**不读盘**:日志文本只有两条来路——presenter 快照里的 `LogLine`,或 `demo` 里
//! `include_str!` 内嵌的样例。日志文件的路径从头到尾没进过页面。
//!
//! ## 缺口(M95 登记,已报 finding)
//!
//! presenter 的 `dto::Diagnosis` 只有 `rule` / `title` / `hint` / `confidence` / `fix`:
//!
//! 1. **core 的 `Severity`(info / warning / critical)在半路丢了**——`core::diagnose::rules`
//! 里每条规则都带它,DTO 没有。页面要「按严重度分组」就只能用
//! [`Severity::of_confidence`] 按置信度**折算**(阈值写死在函数里,是债,不是规格)。
//! 2. `title` / `hint` 只有中文单语,而且规则表里其实已经有结构化出口
//! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文
//! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。
/// 一行日志的等级(presenter `LogLevel` 的展示投影)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
/// 调试噪音。
Debug,
/// 正常流水。
Info,
/// 可疑。
Warn,
/// 错误行(崩溃现场)。
Error,
}
impl Level {
/// 稳定 slug(摘要行与测试用;不是文案)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
}
}
}
/// 诊断结论的严重度(`core::diagnose::rules::Severity` 的展示投影)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
/// 提示:能跑但不最佳。
Info,
/// 警告:可疑但结论不确定。
Warning,
/// 严重:基本可以确定崩溃原因。
Critical,
}
/// 分区顺序(**主因在上**:崩溃日志换 issue 时第一段就是最该看的)。
pub const GROUPS: [Severity; 3] = [Severity::Critical, Severity::Warning, Severity::Info];
impl Severity {
/// 稳定 slug(摘要行用;不是文案——分组标题走 i18n key)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Critical => "critical",
}
}
/// 按置信度折算严重度——**过渡口径**,见模块文档的缺口 #1。
///
/// 阈值:`≥80` 严重 / `≥50` 警告 / 其余提示。DTO 补上 `severity` 之后这个函数就该删掉。
#[must_use]
pub const fn of_confidence(confidence: u8) -> Self {
if confidence >= 80 {
Self::Critical
} else if confidence >= 50 {
Self::Warning
} else {
Self::Info
}
}
}
/// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
/// 原始文本(**不截断**:折行是渲染的事,数据要留全,否则用户永远拿不回堆栈)。
pub text: String,
/// 等级。
pub level: Level,
}
/// 一条诊断结论。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
/// 命中的规则 id(`graphics.opengl_not_supported`):技术标识,原样显示。
pub rule: String,
/// 严重度(DTO 没有,见 [`Severity::of_confidence`])。
pub severity: Severity,
/// 结论标题(规则表的 `cause.summary`,**数据不是文案**)。
pub title: String,
/// 建议说明(规则表的 `cause.detail`)。
pub hint: String,
/// 0~100 置信度。
pub confidence: u8,
/// 建议动作(规则表的 `fix_action`,技术标识 / 设置项名)。
pub action: Option<String>,
}
/// 一次运行的结局(`presenter::dto::LogOutcome` 的展示投影;文案走 `LaunchOutcome*` key)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// 正常退出。
Ok,
/// 崩溃。
Crashed,
/// exit 0 但游戏没起来。
SilentFail,
}
impl Outcome {
/// 稳定 slug(摘要行用)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Crashed => "crashed",
Self::SilentFail => "silent-fail",
}
}
}
/// 日志页要画的东西。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Logs {
/// 会话所属实例名(`None` = 不知道,页头副标题退成通用文案)。
pub instance: Option<String>,
/// 会话时间(展示用短串 `10-29 18:40`,presenter 已经格式化好)。
pub when: Option<String>,
/// 结局。
pub outcome: Option<Outcome>,
/// 日志行(顺序 = 文件顺序)。
pub lines: Vec<Line>,
/// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。
pub findings: Vec<Finding>,
}
impl Logs {
/// 没有任何可显示的东西(空态判据:行与诊断都空)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.lines.is_empty() && self.findings.is_empty()
}
/// 某一严重度下的结论(顺序不变;空组返回空 `Vec`)。
#[must_use]
pub fn group(&self, severity: Severity) -> Vec<&Finding> {
self.findings
.iter()
.filter(|finding| finding.severity == severity)
.collect()
}
/// **可复制的一行摘要**(崩溃日志换 issue 时直接粘)。
///
/// 格式是刻意的:ASCII 骨架 + 稳定字段序 + ` | ` 分隔,人读与机器读都行;
/// 不含任何界面文案(所以不随语言变),只带数据与控制面的技术标识。
///
/// ```text
/// ULL-LOGS v1 | instance=开发测试 | when=10-29 18:40 | outcome=crashed | lines=6 \
/// | findings=graphics.opengl_not_supported:critical(85)
/// ```
#[must_use]
pub fn summary(&self) -> String {
let mut parts = vec![SUMMARY_HEADER.to_owned()];
if let Some(instance) = &self.instance {
parts.push(format!("instance={instance}"));
}
if let Some(when) = &self.when {
parts.push(format!("when={when}"));
}
if let Some(outcome) = self.outcome {
parts.push(format!("outcome={}", outcome.slug()));
}
parts.push(format!("lines={}", self.lines.len()));
if !self.findings.is_empty() {
let hits: Vec<String> = self
.findings
.iter()
.map(|finding| {
format!(
"{}:{}({})",
finding.rule,
finding.severity.slug(),
finding.confidence
)
})
.collect();
parts.push(format!("findings={}", hits.join(",")));
}
parts.join(" | ")
}
}
/// 摘要行的版本头(改了格式就换代,别让旧 issue 里的摘要含义漂移)。
pub const SUMMARY_HEADER: &str = "ULL-LOGS v1";