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

+2 -29
View File
@@ -42,6 +42,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod download;
mod page_messages;
impl App {
/// 一条消息进来,改状态并返回后续任务。
@@ -96,10 +97,7 @@ impl App {
}
Task::none()
}
Message::Page(message) => {
self.on_page(message);
Task::none()
}
Message::Page(message) => self.on_page(message),
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
@@ -275,31 +273,6 @@ impl App {
}
}
/// 页面消息:导航走路由栈,动作翻成 `AppCommand`。
fn on_page(&mut self, message: pages::Message) {
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:本文件 M93 合入后已贴 400 行
if self.on_download(&message) {
return;
}
match message {
pages::Message::Navigate(route) => {
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
// 下载页四支 +「刷新下载源」已由上面的 `on_download` 消化,这里只是穷尽性兜底
_ => {}
}
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
pub(super) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
@@ -41,10 +41,12 @@ fn download_intents_dispatch_real_commands() {
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
app.on_page(pages::Message::CancelInstall);
app.on_page(pages::Message::SetCategory(pages::Category::Release));
app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
// M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型,
// 任务本身没有需要断言的后效,显式丢弃。
let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
let _ = app.on_page(pages::Message::CancelInstall);
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
let expected = vec![
AppCommand::InstallVersion("1.21.9".to_string()),
AppCommand::CancelInstall,
@@ -0,0 +1,91 @@
//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。
//!
//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到
//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。
//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。
//!
//! ## 穷尽性(M94 复审的 p3,本轮收掉)
//!
//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底:
//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」——
//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。
//!
//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要
//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。
//!
//! ## 边界(抓得住什么 / 抓不住什么)
//!
//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默;
//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行
//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。
use super::*;
impl App {
/// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。
pub(super) fn on_page(&mut self, message: pages::Message) -> Task<Message> {
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行
if self.on_download(&message) {
return Task::none();
}
match message {
pages::Message::Navigate(route) => {
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
pages::Message::SelectVersion(_)
| pages::Message::InstallVersion(_)
| pages::Message::CancelInstall
| pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"),
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
// 不派业务命令、不写持久状态)
pages::Message::Action(pages::Action::ClearLogs) => {
self.state.log_lines.clear();
self.view_dirty = true;
self.refresh_view();
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
/// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3:
/// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。
/// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体),
/// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。
#[test]
fn the_page_match_has_no_wildcard_fallback() {
let source = include_str!("page_messages.rs");
let wildcards: Vec<(usize, &str)> = source
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim();
trimmed.starts_with("_ =>") || trimmed.starts_with("_=>")
})
.map(|(index, line)| (index + 1, line))
.collect();
assert!(
wildcards.is_empty(),
"on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\
新变体漏接线时编译器不再报 E0004",
wildcards
);
}
}
+76 -1
View File
@@ -4,7 +4,10 @@
use iced::{Element, Theme};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
Diagnosis, InstanceRuntime, InstanceSummary, LaunchStage, LogLevel, LogOutcome,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
@@ -115,6 +118,9 @@ pub(super) fn model(
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
logs: logs_model(state),
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
lang: Lang::En,
forced: forced.map(|state| match state {
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
@@ -122,6 +128,75 @@ pub(super) fn model(
}
}
/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。
///
/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息
/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。
///
/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的
/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。
fn logs_model(state: &AppState) -> pages::Logs {
let report = state.diagnosis.as_ref();
let current = state.selected_session.as_deref();
let session = report
.and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session))
.or_else(|| {
state
.log_sessions
.iter()
.find(|s| Some(s.id.as_str()) == current)
});
pages::Logs {
instance: session.map(|s| s.instance.clone()),
when: session.map(|s| s.when.clone()),
outcome: session.map(|s| log_outcome(s.outcome)),
lines: state
.log_lines
.iter()
.map(|line| pages::Line {
text: line.text.clone(),
level: log_level(line.level),
})
.collect(),
findings: report
.map(|report| diagnosis_of(&report.diagnosis))
.into_iter()
.collect(),
}
}
/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。
fn log_level(level: LogLevel) -> pages::Level {
match level {
LogLevel::Debug => pages::Level::Debug,
LogLevel::Info => pages::Level::Info,
LogLevel::Warn => pages::Level::Warn,
LogLevel::Error => pages::Level::Error,
}
}
/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。
fn log_outcome(outcome: LogOutcome) -> pages::Outcome {
match outcome {
LogOutcome::Ok => pages::Outcome::Ok,
LogOutcome::Crashed => pages::Outcome::Crashed,
LogOutcome::SilentFail => pages::Outcome::SilentFail,
}
}
/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。
fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding {
pages::Finding {
rule: diagnosis.rule.clone(),
severity: pages::Severity::of_confidence(diagnosis.confidence),
title: diagnosis.title.clone(),
hint: diagnosis.hint.clone(),
confidence: diagnosis.confidence,
action: diagnosis.fix.clone(),
}
}
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。
fn supporting_line(instance: &InstanceSummary) -> String {
format!("{} · {}", instance.mc_version, instance.loader.badge())
+125 -121
View File
@@ -1,145 +1,149 @@
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。
//! 日志页(M95):**单列**版面的第二个用例,也是本项目卖点(崩溃日志诊断)的落地页。
//!
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
//! ## 一屏由三块组成
//!
//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于
//! 后续「日志页接事件流」的 mission,本页只负责版式。
//! ```text
//! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐
//! ├ 诊断卡片(只在有结论时出现,**按严重度分组**)──────────────────────┤
//! │ DIAGNOSIS 3 CRITICAL 1 / WARNING 2 │
//! │ integrity.unsatisfied_link(规则 id,等宽) │
//! │ 结论(规则表原文) / 说明 / Suggested fix / Confidence 85% │
//! │ ─ One-line summary ─ (等宽可复制的一行) [Copy summary] │
//! ├ 日志行列表(等宽 + 折行 + **独立滚动**,吃满剩余高度)──────────────┤
//! └──────────────────────────────────────────────────────────────────┘
//! ```
//!
//! 数据源只有两个(页面**不读盘**,路径从不进过页面):
//!
//! | 来源 | 谁造 | 用途 |
//! |---|---|---|
//! | presenter 快照(`log_lines` / `diagnosis` / `log_sessions`) | `app/view.rs::logs_model` | 产品运行时 |
//! | [`demo`] 内嵌的崩溃日志片段 | `pages::demo_model`(`ULL_LOGS_FIXTURE=empty\|live\|crash`) | 出图 |
//!
//! ## M95 的取舍(逐条)
//!
//! | # | 取舍 | 为什么 |
//! |---|---|---|
//! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 |
//! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 |
//! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) |
//! | 4 | 严重度按**置信度折算** | `dto::Diagnosis` 没有 `severity` 字段(core 有,DTO 丢了,已报 finding),阈值写在 `Severity::of_confidence` |
//! | 5 | 页头保持**两行 84** | M84 定的版式(运行状态在副标题上);计数留在两张卡片自己的标题行,避免为了拼计数把 i18n 字符串漏进页头生命期 |
//!
//! ## 指令(只有真业务命令才 dispatch)
//!
//! | 控件 | 消息 | 落到哪 |
//! |---|---|---|
//! | Copy summary | [`Message::CopySummary`] | `app/update.rs` → `iced::clipboard::write` |
//! | Open folder | `Action::OpenLogs` | **还没有 `AppCommand`**(报 finding,现在是占位分支) |
//! | Clear | `Action::ClearLogs` | `app/update.rs` → 清空 presenter 的 `log_lines` |
//! | 空态的 CTA | [`Message::Navigate`] | 压一层路由栈回实例页(真导航,不是占位动作) |
//!
//! 截图的强弱边界写在 `logs/tests.rs` 每条断言下面。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length, Padding};
mod demo;
mod diagnostic;
mod lines;
mod model;
mod panel;
#[cfg(test)]
mod tests;
/// 诊断卡在正文里的高度份额(见 [`panel`] 的模块文档:两块必须都是定高 + 内部滚动)。
const DIAGNOSIS_SHARE: u16 = 3;
/// 日志卡的高度份额。
const LOGS_SHARE: u16 = 2;
pub use demo::demo;
pub use model::{Finding, Level, Line, Logs, Outcome, Severity};
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::FrameMetrics;
use super::{Action, Message};
/// 演示日志源(文件名 + 大小/时间)。
const SOURCES: [(&str, &str); 3] = [
("launcher.log", "128 KiB · live"),
("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"),
("diagnose-2026-10-31.yaml", "18 KiB · yesterday"),
];
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let header = Header::new("Logs")
.subtitle("Launcher and game output · nothing is running right now")
.action(frame::text_action(ui, "Open folder", Action::OpenLogs))
.action(frame::text_action(
ui,
"Copy diagnostics",
Action::CopyDiagnostics,
))
.action(frame::text_action(ui, "Clear", Action::ClearLogs));
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
logs: &'a Logs,
lang: Lang,
) -> Element<'a, Message> {
let catalog = Catalog::of(lang);
let body = frame::single_column(
metrics,
column![
source_card(ui),
frame::empty_state(
ui,
IconKind::Logs,
"No output yet",
"Logs stream here while the launcher or the game is running. Start an instance to see \
the first lines appear.",
vec![frame::text_action(ui, "Start an instance", Action::Play)],
let header = Header::new(catalog.text(Key::NavLogs))
.subtitle(subtitle(&catalog, logs))
.action(buttons::text(
ui,
Button::new(
catalog.text(Key::LogsOpenFolder),
Message::Action(Action::OpenLogs),
),
))
.action(buttons::text(
ui,
Button::new(
catalog.text(Key::LogsClear),
Message::Action(Action::ClearLogs),
),
));
let body = if logs.is_empty() {
frame::single_column(metrics, empty(ui, &catalog))
} else if logs.findings.is_empty() {
// 没有结论:日志卡片独吞整页(这样「正常运行的一场」不会留一块空卡片)。
frame::single_column(metrics, lines::card(ui, logs, lang, Length::Fill))
} else {
// 有结论:两块**都定高**、各自独立滚动(诊断 3 : 日志 2)。
// 定高不是偷懒:第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(截图见报告)。
let stack = column![
diagnostic::card(ui, logs, lang, Length::FillPortion(DIAGNOSIS_SHARE)),
lines::card(ui, logs, lang, Length::FillPortion(LOGS_SHARE)),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.height(Length::Fill),
);
.height(Length::Fill)
.spacing(ui.space(Space::X4));
frame::single_column(metrics, stack)
};
frame::page(ui, metrics, header, body)
}
/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。
fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> {
let spec = Component::ListItem.spec();
let mut rows = column![].spacing(0.0).width(Length::Fill);
for (index, (name, meta)) in SOURCES.iter().enumerate() {
if index > 0 {
rows = rows.push(frame::divider(ui));
}
rows = rows.push(
row![
frame::glyph_box(
IconKind::Logs,
ui.color(Role::OnSurfaceVariant),
frame::ICON_PX
),
column![
frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)),
frame::text_role(
ui,
spec.support.unwrap_or(TypeRole::BodySmall),
false,
meta,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(0.0)
.width(Length::Fill),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])),
);
}
frame::card(ui, rows)
}
#[cfg(test)]
mod tests {
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
#[test]
fn logs_page_is_single_column_and_capped() {
for width in [700.0, 1180.0, 1600.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert!(metrics.secondary.is_none());
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
}
}
#[test]
fn header_is_two_lines_because_of_the_status_subtitle() {
let ui = Ui::with_platform(
SchemeFlavor::Dark,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
);
assert_eq!(ui.metrics().header_height(true), 84.0);
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
}
#[test]
fn every_source_row_has_a_name_and_meta() {
assert_eq!(SOURCES.len(), 3);
for (name, meta) in SOURCES {
assert!(name.contains("log") || name.contains("yaml"));
assert!(!meta.is_empty());
}
/// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。
fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str {
if logs.is_empty() {
catalog.text(Key::LogsSubtitleIdle)
} else {
catalog.text(Key::LogsSubtitleActive)
}
}
/// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。
fn empty<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> {
empty_state(
ui,
EmptyState::new(
IconKind::Logs,
catalog.text(Key::LogsEmptyTitle),
catalog.text(Key::LogsEmptyBody),
)
.action(
catalog.text(Key::LogsEmptyAction),
Message::Navigate(Route::Instances),
),
)
}
+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),
}
}
@@ -0,0 +1,222 @@
//! 诊断结果区:**按严重度分组**的结论卡片 + 可直接粘进 issue 的一行摘要。
//!
//! ## 一条结论要能看出什么(tower 的三问)
//!
//! | 问题 | 落点 | 数据来源 |
//! |---|---|---|
//! | 哪条规则命中 | 规则 id(等宽、`LabelMedium`) | `Finding::rule` |
//! | 严重度 | 分组标题 + 分组顺序(`CRITICAL` → `WARNING` → `NOTICE`) | `Finding::severity` |
//! | 建议动作 | `Suggested fix` 一行(规则表的 `fix_action`,技术标识原样) | `Finding::action` |
//!
//! ## 用户真的要拿去用的两样东西
//!
//! 1. **结论原文**(`title` / `hint`):规则表 `rules.d/*.yaml` 写的 `cause.summary` / `detail`。
//! 它是**数据**(诊断结论),页面不翻译也不改写——改写会让「规则命中」与「用户读到的」
//! 变成两套说法。界面文案(分组名 / 标签 / 按钮)仍然全部走 i18n key。
//! 2. **一行摘要**([`Logs::summary`]):等宽显示 + 「Copy summary」按钮;按钮把字符串塞进
//! 剪贴板(`Message::CopySummary` → `iced::app/update.rs` → `iced::clipboard::write`)。
//! 崩溃日志换 issue 时粘这一行就够:实例 / 时间 / 结局 / 行数 / 命中的规则与置信度都在里面。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{column, row, text};
use iced::{Alignment, Color, Element, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::{TypeRole, TypeStyle};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::lines::{mono, mono_owned};
use super::model::{Finding, Logs, Severity, GROUPS};
use super::panel;
/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`]),组与摘要在卡片内滚动。
pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> {
let catalog = Catalog::of(lang);
let mut group = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
for severity in GROUPS {
let findings = logs.group(severity);
if findings.is_empty() {
continue;
}
group = group.push(frame::divider(ui));
group = group.push(title_row(
ui,
catalog.text(key_of(severity)),
findings.len(),
));
for finding in findings {
group = group.push(finding_block(ui, &catalog, finding));
}
}
group = group.push(frame::divider(ui));
group = group.push(summary_block(ui, &catalog, logs));
panel::card(
ui,
height,
column![head_row(ui, &catalog, logs), panel::scroll(ui, group)].width(Length::Fill),
)
}
/// 卡片头部:`DIAGNOSIS` + 条数 + **始终可见的「Copy summary」**。
///
/// 复制按钮钉在头上而不是跟着摘要滚:三条结论就足以把摘要推到折叠线以下,
/// 而「换 issue 时粘一行」正是这页的出口,不能要用户先滚一下才发现它。
fn head_row<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> {
row![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::LogsDiagnosisTitle),
ui.color(Role::OnSurfaceVariant),
),
iced::widget::Space::new().width(Length::Fill),
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
logs.findings.len().to_string(),
ui.color(Role::OnSurfaceVariant),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::LogsCopySummary),
Message::CopySummary(logs.summary()),
),
),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.spacing(ui.space(Space::X2))
.into()
}
/// 标题行:左边标签(大写字距靠令牌档),右边条数。
fn title_row<'a>(ui: &'a Ui, label: &'a str, count: usize) -> Element<'a, Message> {
row![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
label,
ui.color(Role::OnSurfaceVariant),
),
iced::widget::Space::new().width(Length::Fill),
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
count.to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 一条结论:规则 id / 结论 / 说明 / 建议动作 / 置信度。
fn finding_block<'a>(ui: &'a Ui, catalog: &Catalog, finding: &'a Finding) -> Element<'a, Message> {
let mut block = column![
mono(ui, &finding.rule, ui.color(Role::OnSurfaceVariant)),
wrapped(
ui,
TypeRole::BodyMedium,
&finding.title,
ui.color(Role::OnSurface)
),
wrapped(
ui,
TypeRole::BodySmall,
&finding.hint,
ui.color(Role::OnSurfaceVariant)
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(action) = &finding.action {
block = block.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::LogsSuggestedFix),
ui.color(Role::OnSurfaceVariant),
),
mono(ui, action, ui.color(Role::OnSurface)),
]
.spacing(0.0)
.width(Length::Fill),
);
}
block = block.push(frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
catalog.render(
Key::LogsConfidence,
&[("percent", &finding.confidence.to_string())],
),
ui.color(Role::OnSurfaceVariant),
));
block.into()
}
/// 一行摘要(等宽、可整段带走;复制按钮在卡片头上,见 [`head_row`])。
fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::LogsSummaryLabel),
ui.color(Role::OnSurfaceVariant),
),
mono_owned(ui, logs.summary(), ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(Wrapping::WordOrGlyph),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 严重度 → 分组标题的 key。
fn key_of(severity: Severity) -> Key {
match severity {
Severity::Critical => Key::LogsSeverityCritical,
Severity::Warning => Key::LogsSeverityWarning,
Severity::Info => Key::LogsSeverityInfo,
}
}
/// 令牌排版档 + **按词或字形折行**(长堆栈里的点分标识符没有空格,只按词会溢出)。
fn wrapped<'a>(ui: &'a Ui, role: TypeRole, content: &'a str, color: Color) -> Element<'a, Message> {
let style: TypeStyle = ui.style(role);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.width(Length::Fill)
.wrapping(Wrapping::WordOrGlyph)
.into()
}
+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)
}
+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";
+63
View File
@@ -0,0 +1,63 @@
//! 两块的共用外壳:**定高的卡片 + 内部独立滚动**。
//!
//! ## 为什么必须定高(M95 r1 的实拍 bug)
//!
//! 第一版把诊断卡按内容自然高度画、日志卡吃剩余高度。三条结论在 424×540 的正文里就要
//! ~500px,结果:诊断卡被裁掉尾部(一行摘要整块消失)、日志卡拿到 **0** 高度(整块不见)。
//! 截图见报告里的「r1 崩了」。
//!
//! 所以两块都**由调用方给定高**(`FillPortion`),各自内部滚动:
//!
//! | 块 | 高度 | 为什么 |
//! |---|---|---|
//! | 诊断 | `FillPortion(3)` | 崩溃时先读结论;结论通常比日志短,给 3 份 |
//! | 日志 | `FillPortion(2)` | 日志是长流水,滚就是了 |
//!
//! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。
use iced::widget::{container, scrollable};
use iced::{Background, Border, Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状,令牌与 `frame::card` 同源)。
pub fn card<'a>(
ui: &'a Ui,
height: Length,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(content)
.width(Length::Fill)
.height(height)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 卡片内的竖向滚动区(滚条样式与实例页列表列同源)。
pub fn scroll<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.width(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
@@ -0,0 +1,23 @@
# M95 演示数据:一段真实崩溃日志的片段(页面用 include_str! 编译期内嵌,运行时不读盘)。
#
# 每行格式:`<等级> <原文>`,等级 = D(debug) / I(info) / W(warn) / E(error);`#` 开头是注释。
#
# 这份样例是故意挑的(tower 要求的两条真风险都压在这里):
# 1. 中文 + 堆栈混排 —— mono 族(Windows 上是 Consolas / Cascadia Mono)没有 CJK 字形,
# 中文要靠 cosmic-text 逐字回退到系统 CJK 族,字宽与 Latin 1ch 不等;页面选的是折行,
# 所以「列对齐」不参与版面,回退只影响同一行内的视觉密度;
# 2. 超长行 —— 第 9/10/11 行都在 200 字符以上(真实 Java 堆栈的长度),正文列上限 424px,
# 折行策略见 `logs.rs` 模块文档。
I [19:40:12] [main/INFO] 正在启动 1.20.4,实例「开发测试」,加载器 NeoForge 21.1.72
I [19:40:12] [main/INFO] Java 21.0.5 (Microsoft build 21.0.5+11-LTS), 内存 4096 MiB, JVM 参数 -Xmx4G -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.library.path=C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives
W [19:40:13] [Render thread/WARN] GLFW 报告 OpenGL 2.1,低于所需的 3.2;显卡驱动可能是 Microsoft Basic Display Adapter
E [19:40:14] [Render thread/ERROR] 显卡驱动不支持 OpenGL 3.2,初始化窗口失败
E java.lang.IllegalStateException: GLFW error before init: [0x10008] Failed to create window: WGL: The driver does not appear to support OpenGL
E at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)
E at net.minecraft.client.MainWindow.<init>(MainWindow.java:261)
E at net.minecraft.client.Minecraft.<init>(Minecraft.java:472)
E at net.neoforged.fml.loading.targets.CommonLaunchHandler.clientService(CommonLaunchHandler.java:98)
E Caused by: java.lang.UnsatisfiedLinkError: C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives\lwjgl_opengl.dll: 找不到指定的模块。 (error code 126, 依赖的 d3dcompiler_47.dll 或 vcruntime140.dll 缺失时也会报同一条)
W [19:40:15] [main/WARN] 正在生成本地崩溃报告 crash-2026-11-02_19.40.15-client.txt,请勿关闭窗口
I [19:40:16] [main/INFO] 已加载 148 个模组,其中 3 个标记为客户端专用:iris-1.7.4, sodium-0.5.11, indium-1.0.32
E [19:40:16] [main/ERROR] 启动失败:exit code 1(进程存活 4 秒)
+209
View File
@@ -0,0 +1,209 @@
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
//!
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃),
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::{FontFamily, Platform};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use crate::theme::Families;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
use super::model::{GROUPS, SUMMARY_HEADER};
use super::{demo, lines, *};
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Dark,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn page_is_single_column_and_never_wider_than_the_measure_cap() {
for width in [700.0, 1180.0, 1600.0] {
let metrics = frame_metrics(PaneKit::Single, width);
// 抓得住:谁把日志页改成两列(`secondary` 出现就会把正文列挤窄);
// 谁绕开 `layout` 自己算列宽(`column` 会超 424)。
// 抓不住:列内文本是否真在这 424 里折行(那要量字形,单测量不到)。
assert!(metrics.secondary.is_none(), "w={width}");
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"w={width} 正文列 {} > 上限 {}",
metrics.column,
measure_cap_px()
);
}
}
#[test]
fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() {
let ui = ui();
let style = lines::mono_style(&ui);
// 抓得住:把等宽行换成正文栈(`family` 会变 `Text`)——那是「等宽」失真的唯一入口;
// 顺手改字号/行高(数值表要写 12/16,漂了就红)。
// 抓不住:CJK 字形到底有没有被回退到别的族(要查字体 cmap,本 crate 不做)。
assert_eq!(style.family, FontFamily::Mono);
assert_eq!(style.size_px, 12.0);
assert_eq!(style.line_height_px, 16.0);
assert_eq!(
ui.font_of(style).family,
iced::font::Family::Name("Consolas")
);
}
#[test]
fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() {
let findings = demo::crash().findings;
let logs = Logs {
findings,
..Logs::default()
};
let counts: Vec<usize> = GROUPS
.iter()
.map(|severity| logs.group(*severity).len())
.collect();
// 抓得住:分组顺序被改成 info 在前(主因会沉底);空组也被画出一个标题(截图上出现 0 条的分区)。
// 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。
assert_eq!(GROUPS[0], Severity::Critical);
assert_eq!(
counts.iter().sum::<usize>(),
logs.findings.len(),
"每条结论必须落进恰好一个分组"
);
assert!(counts[0] >= 1, "样例至少有主因");
assert!(logs.group(Severity::Info).is_empty());
}
#[test]
fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() {
let logs = demo::crash();
let summary = logs.summary();
// 抓得住:摘要里丢了规则 id / 严重度 / 置信度(换 issue 时最关键的三个字段);
// 有人把换行塞进摘要("一行"就不成立了)。
// 抓不住:粘贴到 issue 后的可读性(那是人判断的),也不守文案语言(摘要刻意不含界面文案)。
assert!(!summary.contains('\n'), "{summary}");
assert!(summary.starts_with(SUMMARY_HEADER), "{summary}");
assert!(
summary.contains("integrity.unsatisfied_link:critical(85)"),
"{summary}"
);
assert!(summary.contains("lines="), "{summary}");
assert_eq!(summary, logs.summary(), "同一份模型两次摘要必须一致");
let empty = Logs::default();
assert_eq!(empty.summary(), "ULL-LOGS v1 | lines=0");
}
#[test]
fn severity_is_derived_from_confidence_with_the_documented_thresholds() {
// 抓得住:阈值边界写成 `>` / `>=` 那种 off-by-one(80 与 50 是文档里写死的两个点)。
// 抓不住:折算本身对不对——DTO 补上 `severity` 之前这只是**过渡口径**(已报 finding)。
assert_eq!(Severity::of_confidence(100), Severity::Critical);
assert_eq!(Severity::of_confidence(80), Severity::Critical);
assert_eq!(Severity::of_confidence(79), Severity::Warning);
assert_eq!(Severity::of_confidence(50), Severity::Warning);
assert_eq!(Severity::of_confidence(49), Severity::Info);
assert_eq!(Severity::of_confidence(0), Severity::Info);
}
#[test]
fn the_longest_stack_line_reaches_the_view_model_untruncated() {
let logs = demo::crash();
let longest = logs
.lines
.iter()
.map(|line| line.text.chars().count())
.max()
.unwrap_or(0);
// 抓得住:为了排版把行截断 / 加省略号(用户就再也拿不回原始堆栈)。
// 抓不住:折行后的视觉行数(渲染期的事)。
assert!(
longest >= 200,
"样例里应有一条 ≥200 字符的堆栈行,实际 {longest}"
);
assert!(!logs.lines.iter().any(|line| line.text.ends_with('…')));
}
#[test]
fn empty_state_is_driven_by_both_lists_being_empty() {
let mut logs = Logs::default();
assert!(logs.is_empty());
logs.findings = demo::crash().findings;
// 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。
assert!(!logs.is_empty(), "只有诊断也不算空态");
let live = demo::State::Live.build();
assert!(!live.is_empty());
assert!(live.findings.is_empty(), "live 状态没有诊断");
}
#[test]
fn demo_state_comes_from_the_fixture_variable() {
// 抓得住:环境变量取值拼错(截图会静默退成 crash,出图的人以为拍到了空态)。
assert_eq!(demo::State::of_env_value(Some("empty")), demo::State::Empty);
assert_eq!(demo::State::of_env_value(Some("live")), demo::State::Live);
assert_eq!(demo::State::of_env_value(Some("crash")), demo::State::Crash);
assert_eq!(demo::State::of_env_value(None), demo::State::Crash);
}
#[test]
fn every_key_the_page_uses_is_translated_in_both_languages() {
let keys = [
Key::NavLogs,
Key::LogsSubtitleIdle,
Key::LogsSubtitleActive,
Key::LogsOpenFolder,
Key::LogsClear,
Key::LogsOutputTitle,
Key::LogsDiagnosisTitle,
Key::LogsSeverityCritical,
Key::LogsSeverityWarning,
Key::LogsSeverityInfo,
Key::LogsConfidence,
Key::LogsSuggestedFix,
Key::LogsSummaryLabel,
Key::LogsCopySummary,
Key::LogsEmptyTitle,
Key::LogsEmptyBody,
Key::LogsEmptyAction,
];
// 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。
// 抓不住:译文的语气与长度(人读的事)。
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
for key in keys {
assert!(
catalog.get(key).is_some(),
"{} 缺 {}",
lang.setting_value(),
key.name()
);
}
}
// 占位符名写错时 `render` 会把 `{percent}` 原样留在文案里——这条钉住它。
let rendered = Catalog::of(Lang::En).render(Key::LogsConfidence, &[("percent", "85")]);
assert_eq!(rendered, "Confidence 85%");
}
#[test]
fn header_stays_two_lines_because_of_the_status_subtitle() {
let ui = ui();
// 抓得住:有人把副标题去掉,页头从 84 掉回 56(版式台账上会露出来)。
assert_eq!(ui.metrics().header_height(true), 84.0);
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
}
+9 -1
View File
@@ -108,6 +108,7 @@ mod tests;
pub use download::{Category, Download};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Finding, Level, Line, Logs, Outcome, Severity};
pub use model::{Detail, Instances, Meta, Model, Row};
use iced::widget::{column, container, row, space};
@@ -150,6 +151,10 @@ pub enum Message {
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把一段现成的文本塞进剪贴板(**M95 日志页**:诊断卡片的一行摘要)。
///
/// 文本由页面造(它是唯一同时看得见规则 id / 分类 / 置信度的地方),app 层只负责落剪贴板。
CopySummary(String),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
@@ -327,7 +332,7 @@ fn render<'a>(
Screen::Instances => instances::view(ui, metrics, model),
Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En),
Screen::Settings => settings::view(ui, metrics, appearance),
Screen::Logs => logs::view(ui, metrics),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
}
}
@@ -365,6 +370,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
if screen == Screen::Download {
model.download = download::demo(unlucky_i18n::Lang::En.catalog());
}
if screen == Screen::Logs {
model.logs = logs::demo();
}
model
}
+11
View File
@@ -14,10 +14,12 @@
//!
//! 文本都是 `String`/`&'static str`,没有框架类型——这一层可以纯单测(见 `pages/tests.rs`)。
use unlucky_i18n::Lang;
use unlucky_presenter::router::NavSection;
use crate::app::components::interaction::Interaction;
use super::logs::Logs;
use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
@@ -85,6 +87,13 @@ pub struct Model {
pub instances: Instances,
/// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照)
pub download: Download,
/// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。
pub logs: Logs,
/// 界面语言(M95):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 这里只是**留口**:`theme.rs` 还没有「当前语言」的入口(那条线归 M93),
/// 产品路径先给 `Lang::En`;将来 `Ui` 拿到语言后,这个字段改由它填。
pub lang: Lang,
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
@@ -101,6 +110,8 @@ impl Model {
active,
instances: Instances::default(),
download: Download::loading(),
logs: Logs::default(),
lang: Lang::En,
forced: None,
}
}
+22
View File
@@ -167,6 +167,28 @@ catalog! {
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
// --- logs page (M95) ---
// 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
// 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
LogsSubtitleActive => ("Launcher and game output", "启动器与游戏输出"),
LogsOpenFolder => ("Open folder", "打开目录"),
LogsClear => ("Clear", "清空"),
LogsOutputTitle => ("OUTPUT", "输出"),
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
LogsSeverityCritical => ("CRITICAL", "严重"),
LogsSeverityWarning => ("WARNING", "警告"),
LogsSeverityInfo => ("NOTICE", "提示"),
LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"),
LogsSuggestedFix => ("Suggested fix", "建议动作"),
LogsSummaryLabel => ("One-line summary", "一行摘要"),
LogsCopySummary => ("Copy summary", "复制摘要"),
LogsEmptyTitle => ("No output yet", "还没有输出"),
LogsEmptyBody => (
"Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.",
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
),
LogsEmptyAction => ("Choose an instance", "选择实例"),
}
/// 一套语言的目录(不可变;`Copy`,随手传值)。
+3 -1
View File
@@ -1,7 +1,9 @@
//! `Catalog` 的守门测试(M94 从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限,
//! `Catalog` 的守门测试(从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限,
//! 而页面每加一批 key 都要在这个模块里占一行)。
//!
//! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。
//! M94(下载页)与 M95(日志页)几乎同时撞到这堵墙、各自搬了一次:两条 branch 的**用例部分
//! 逐字相同**(解冲突时按 M94 那份合并,测试数量 7 条不变)。
use super::*;