Files
Unlucky-Launcher/crates/app-iced/src/pages/logs/model.rs
T
AskaEth 1bccf89e5c fix(app-iced): M95 r1 五条视觉复审(悬挂缩进 / 会话身份 / 摘要可见 / 贴底 / 描边)
- P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`):
  每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格;
  `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒
  续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。
  文档同步写清代价:续行缩进量 = 该行前缀宽度。
- P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼
  `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent`
  三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。
  中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。
- P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary
  紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。
- P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时
  跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结,
  冻结要 app 层回灌滚动状态,已登记)。
- P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` +
  `ui.outlined(..)`,与 `frame::card` 同一套令牌。

测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与
「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`;
key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净;
`test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。
图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、
`r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。
2026-11-02 22:26:59 +08:00

274 lines
9.5 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.
//! 日志页的视图模型:**纯展示字段**(沿用 `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。
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
/// 一行日志的等级(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>,
/// 页头副标题(`实例 · 时间 · 结局`):**构造时**拼好(见 [`Logs::with_status`]),
/// 页面只负责显示——i18n 的「一句话 = 一个 key」不许在渲染里拼句子。
pub status: Option<String>,
/// 日志行(顺序 = 文件顺序)。
pub lines: Vec<Line>,
/// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。
pub findings: Vec<Finding>,
}
impl Logs {
/// 补上页头副标题(M95 r2,视觉复审 P2-2)。
///
/// 为什么在这里做而不是在页面里:`Header::subtitle` 要 `&'a str`,而拼出来的是 `String`;
/// 让模型持有它,页面就永远只借不拼。
#[must_use]
pub fn with_status(mut self, lang: Lang) -> Self {
self.status = status_line(
self.instance.as_deref(),
self.when.as_deref(),
self.outcome,
lang,
);
self
}
/// 没有任何可显示的东西(空态判据:行与诊断都空)。
#[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";
/// 页头副标题:`{instance} · {when} · {outcome}`。
///
/// 结局文案走 [`Key::LaunchOutcomeNormal`] / [`Key::LaunchOutcomeCrash`] /
/// [`Key::LaunchOutcomeSilent`] —— 就是 `unlucky_i18n::bridge` 文档里写了很久的那条
/// 「`LogOutcome` 三态 → 三 key 结构化出口」,M95 r2 之前它们在 UI 侧**零调用点**(视觉复审 P2-2)。
///
/// 没有实例名或结局时返回 `None`(不知道是哪一场就别编),页头退回静态副标题。
#[must_use]
pub fn status_line(
instance: Option<&str>,
when: Option<&str>,
outcome: Option<Outcome>,
lang: Lang,
) -> Option<String> {
let instance = instance?;
let outcome = outcome?;
let catalog = Catalog::of(lang);
let outcome_key = match outcome {
Outcome::Ok => Key::LaunchOutcomeNormal,
Outcome::Crashed => Key::LaunchOutcomeCrash,
Outcome::SilentFail => Key::LaunchOutcomeSilent,
};
Some(catalog.render(
Key::LogsStatus,
&[
("instance", instance),
("when", when.unwrap_or("—")),
("outcome", catalog.text(outcome_key)),
],
))
}