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:
1 parent
c0396544c5
commit
063a1dba39
16 files changed
+1392
-157
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 秒)
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,随手传值)。
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
Reference in new issue
Block a user