Files
Unlucky-Launcher/crates/app-iced/src/app/view.rs
T
AskaEth 063a1dba39 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`(点了只有占位日志)。
2026-11-02 22:26:53 +08:00

403 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
//!
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
use iced::{Element, Theme};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
Diagnosis, InstanceRuntime, InstanceSummary, LaunchStage, LogLevel, LogOutcome,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
use crate::app::components::interaction::Interaction;
use crate::cli::Screen;
use crate::pages::{self, Detail, Meta, Row};
use crate::shell::{self, NavItem};
use super::message::{nav_item_of, Message};
use super::state::{log_first_frame, App};
use super::APP_TITLE;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
pub(super) fn theme(&self) -> Theme {
self.ui().theme()
}
/// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。
pub(super) fn title(&self) -> String {
APP_TITLE.to_owned()
}
/// 整棵树。
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
return super::components::gallery::view(&self.ui).map(Message::Demo);
}
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
// `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何
// 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` /
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
match pages::Screen::of(self.state.route()) {
Some(_) => {
let background =
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
.map(|element| element.map(Message::Page));
shell::view_with_background(
&self.ui,
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
self.options.force_state,
)
}
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
None => shell::view(
&self.ui,
NavItem::Ai,
APP_TITLE,
NavItem::Ai.label(),
"The assistant page is not built yet (P1). Chat lives in the docked panel once \
the AI mission lands; this screen is the M68 skeleton fallback.",
self.options.force_state,
),
}
}
}
// --------------------------------------------------------------- 快照 → 视图模型
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
///
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
pub(super) fn model(
state: &AppState,
width: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
let rows: Vec<Row> = state
.tiles()
.into_iter()
.map(|cell| Row {
id: cell.instance.id.clone(),
name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance),
status: runtime_status(&cell.instance.runtime),
})
.collect();
let detail = match &route {
Route::Detail(id) => state
.instances
.iter()
.find(|instance| &instance.id == id)
.map(detail_of),
_ => None,
};
pages::Model {
screen,
window_width: width,
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,
}),
}
}
/// 日志页视图模型(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())
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
fn runtime_status(runtime: &InstanceRuntime) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")),
InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")),
}
}
/// 阶段文案(英文基线;i18n mission 接手后统一走词表)。
///
/// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线
/// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。
fn stage_label(stage: LaunchStage) -> &'static str {
match stage {
LaunchStage::Download => "Downloading",
LaunchStage::Verify => "Verifying",
LaunchStage::Launch => "Starting",
LaunchStage::Window => "Waiting for window",
}
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
fn detail_of(instance: &InstanceSummary) -> Detail {
// 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码
// 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一,
// 届时两处会同时变——这正是要的。
let loader = instance.loader.badge();
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance),
meta: vec![
meta("VERSION", &instance.mc_version),
meta("LOADER", &loader),
meta("ISOLATION", &instance.isolation),
meta("VERSION ID", &instance.version_id),
meta(
"FILES",
if instance.version_present {
"Present on disk"
} else {
"Missing"
},
),
meta("LAST PLAYED", &last_played(instance.last_played.as_deref())),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
),
can_stop: matches!(
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
}
}
fn meta(label: &str, value: &str) -> Meta {
Meta {
label: label.to_string(),
value: value.to_string(),
}
}
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>) -> String {
let Some(text) = raw else {
return "Never".to_string();
};
match (text.get(0..10), text.get(11..16)) {
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
format!("{date} {time} UTC")
}
_ => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
}
#[test]
fn timestamps_are_sliced_not_guessed() {
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = model(&state, 1180.0, None, pages::Category::All);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
}