- 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 证据)。
274 lines
9.5 KiB
Rust
274 lines
9.5 KiB
Rust
//! 日志页的视图模型:**纯展示字段**(沿用 `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)),
|
||
],
|
||
))
|
||
}
|