Files
Unlucky-Launcher/crates/app-iced/src/pages/content/packui/model/report.rs
T
AskaEth 03741ecd2d fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债
① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
   - 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
     SettingSchema 加 with_all 供域键表批量登记。
   - 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
     四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
     主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
   - presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
     auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
     保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
   - CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。

② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
   (记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。

③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
   设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
   (忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。

④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
   用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
   ≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。

⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
   改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
   Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
   InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。

⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
   由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。

顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
2026-11-07 15:02:13 +08:00

315 lines
12 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.
//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。
//!
//! ## 为什么容忍域与致命域连字段类型都不同
//!
//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。
//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以:
//!
//! | 域 | 类型 | 画法 |
//! |---|---|---|
//! | 容忍(缺文件 / 被拒 component / 注记) | [`Row`](路径 + 原因) | 中性容器 + `Info` 图标 + 逐条列表 |
//! | 致命(已回滚) | [`Fatal`](大类 + 原文 + 建议 + 稳定码) | `ErrorContainer` + `Error` 图标 + 标题换措辞 |
//!
//! 一个 `Option<Fatal>` 与一个 `Vec<Row>` 之间没有「误合并」的写法,这比任何样式约定都硬。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
};
use super::PackPageState;
/// 报告里的一行(容忍域:缺文件 / 被拒 component)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 左列(路径 / 组件名)
pub path: String,
/// 右列(原因的人读文案)
pub note: String,
}
/// 一条冲突(三方哈希 + 本次处置)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictRow {
/// 实例内路径
pub path: String,
/// 成因(`你改过` / `你删过` / `本来就在`)
pub reason: String,
/// 本次实际处置(`保留我的` / `用包里的`)
pub choice: String,
/// 包内 SHA-1(截短)
pub pack_sha1: String,
/// 基线 SHA-1(`None` = 没有基线)
pub base_sha1: Option<String>,
/// 磁盘 SHA-1(`None` = 文件不在)
pub disk_sha1: Option<String>,
/// 展开中(展开才画三方哈希那行)
pub expanded: bool,
}
/// 致命失败块(**两种措辞,不共用**)。
///
/// 为什么不是一句「失败」:`NotStarted`(预检就没过,一个字节都没写)与 `RolledBack`
/// (写进去了,又整包回滚干净)对用户是不同的消息——前者「去修包再试」,后者「刚才动过你的目录,
/// 已经复原」。M161 之前两者共用一句「已回滚 —— 目标目录零残留」,预检失败时那句话是假的。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FatalKind {
/// 预检/装配阶段失败:目标目录**一个字节都没写**
NotStarted,
/// 写过目标目录,回滚**完整**完成
RolledBack,
/// 写过,但回滚有失败项(目标目录可能与导入前不同)
RollbackIncomplete,
}
impl FatalKind {
/// 图标(与 [`Fatal::title`] 各说一遍:颜色/图标关掉时标题仍然分得清)。
#[must_use]
pub const fn icon(self) -> unlucky_design::icons::IconKind {
match self {
// 预检没过 = 「还没开始」,用警示而不是「已出错」:目录是干净的
Self::NotStarted => unlucky_design::icons::IconKind::Warn,
Self::RolledBack => unlucky_design::icons::IconKind::Restore,
Self::RollbackIncomplete => unlucky_design::icons::IconKind::Error,
}
}
}
/// 致命失败块。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fatal {
/// 三种阶段之一(决定标题/图标/底色)
pub kind: FatalKind,
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
pub title: String,
/// 大类名(`路径穿越` / `清单损坏` …)
pub label: String,
/// 具体是什么
pub message: String,
/// 稳定码
pub code: String,
/// 补救建议
pub hint: Option<String>,
}
/// 导入结果块。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
/// 三态
pub status: ImportStatusView,
/// 标题(已导入 / 有缺口 / 已回滚)
pub title: String,
/// 副标题(`包名 · 格式 · 游戏版本 · 加载器`)
pub subtitle: String,
/// 写盘行(`12 个文件已写盘 · 3.4 MB`)
pub written_line: String,
/// 致命失败(有它就**没有**下面的缺口区块)
pub fatal: Option<Fatal>,
/// 缺文件标题
pub missing_title: Option<String>,
/// 缺文件逐条(容忍域)
pub missing: Vec<Row>,
/// 冲突标题
pub conflicts_title: Option<String>,
/// 冲突逐条
pub conflicts: Vec<ConflictRow>,
/// 被拒 component 的标题(带计数,与下面逐条行数一致)
pub rejected_title: Option<String>,
/// 被拒 component 逐条
pub rejected: Vec<Row>,
/// 注记逐条
pub warnings: Vec<String>,
/// 能不能给「以包为准重新导入」(有冲突、且实例可用时才有意义)
pub can_overwrite: bool,
/// 「两者都留改名」为何不支持(有冲突时如实写)
pub overwrite_note: Option<String>,
}
impl Report {
/// 实例可用吗(`Success` / `Partial` 都是装上了)。
pub const fn installed(&self) -> bool {
self.status.installed()
}
}
/// 一次导入结果 → 视图模型。
pub(super) fn report_of(
report: &ImportReportView,
page: &PackPageState,
catalog: Catalog,
) -> Report {
let missing_title = (!report.missing.is_empty()).then(|| {
catalog.render(
Key::PackReportMissing,
&[("count", &report.missing.len().to_string())],
)
});
let conflicts_title = (!report.conflicts.is_empty()).then(|| {
catalog.render(
Key::PackReportConflicts,
&[("count", &report.conflicts.len().to_string())],
)
});
Report {
status: report.status,
title: status_title(report, catalog).to_owned(),
subtitle: subtitle(report, catalog),
written_line: catalog.render(
Key::PackReportWritten,
&[
("count", &report.written().to_string()),
("bytes", &human(report)),
],
),
fatal: report
.error
.as_ref()
.map(|error| fatal_of(error, report.failure_stage, catalog)),
missing_title,
missing: report
.missing
.iter()
.map(|file| missing_row(file, catalog))
.collect(),
conflicts_title,
conflicts: report
.conflicts
.iter()
.map(|file| conflict_row(file, page.expanded.as_deref(), catalog))
.collect(),
rejected_title: (!report.rejected.is_empty()).then(|| {
catalog.render(
Key::PackReportRejected,
&[("count", &report.rejected.len().to_string())],
)
}),
rejected: report
.rejected
.iter()
.map(|component| Row {
path: component.clone(),
note: catalog.text(Key::PackReportRejectedNote).to_owned(),
})
.collect(),
warnings: report.warnings.clone(),
// 「以包为准重新导入」只在**导入到已有实例**时有意义(复审 r1 P2-2):新建实例那条路上
// 目标目录已经建好,再点一次必然撞「directory already exists」——一个必然失败的按钮
// 等于死按钮,所以那种流程下不显示。
can_overwrite: !report.conflicts.is_empty()
&& report.status == ImportStatusView::Partial
&& page.instance.is_some(),
overwrite_note: (!report.conflicts.is_empty())
.then(|| catalog.text(Key::PackOverwriteNote).to_owned()),
}
}
/// 字节数的人读串。
fn human(report: &ImportReportView) -> String {
unlucky_presenter::dto::human_bytes(report.bytes_written)
}
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
match report.status {
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
ImportStatusView::Failed => match report.failure_stage {
Some(ImportFailureStageView::NotStarted) => {
catalog.text(Key::PackStatusFailedNotStarted)
}
Some(ImportFailureStageView::RollbackIncomplete) => {
catalog.text(Key::PackStatusFailedIncomplete)
}
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
_ => catalog.text(Key::PackStatusFailed),
},
}
}
/// 副标题:包名 · 格式 · 游戏版本 · 加载器(缺哪个就不写哪个,不拼「未知」)。
fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(format) = report.format {
parts.push(super::format_label(format, catalog).to_owned());
}
if let Some(version) = &report.game_version {
parts.push(version.clone());
}
if let Some(loader) = &report.loader {
parts.push(loader.clone());
}
if let Some(name) = &report.pack_name {
parts.insert(0, name.clone());
}
parts.join(" · ")
}
fn fatal_of(
error: &ImportErrorView,
stage: Option<ImportFailureStageView>,
catalog: Catalog,
) -> Fatal {
let kind = match stage {
Some(ImportFailureStageView::NotStarted) => FatalKind::NotStarted,
Some(ImportFailureStageView::RollbackIncomplete) => FatalKind::RollbackIncomplete,
_ => FatalKind::RolledBack,
};
let title = match kind {
FatalKind::NotStarted => catalog.text(Key::PackFatalTitleNotStarted),
FatalKind::RolledBack => catalog.text(Key::PackFatalTitle),
FatalKind::RollbackIncomplete => catalog.text(Key::PackFatalTitleIncomplete),
};
Fatal {
kind,
title: title.to_owned(),
label: error.label.clone(),
message: error.message.clone(),
code: error.code.clone(),
hint: error.hint.clone(),
}
}
fn missing_row(file: &MissingFileView, catalog: Catalog) -> Row {
Row {
path: file.path.clone().unwrap_or_else(|| file.display.clone()),
note: match file.reason {
MissingReasonView::Offline => catalog.text(Key::PackMissingOffline).to_owned(),
MissingReasonView::NoSource => catalog.text(Key::PackMissingNoSource).to_owned(),
MissingReasonView::FetchFailed => catalog.render(
Key::PackMissingFetchFailed,
&[("detail", file.detail.as_str())],
),
},
}
}
fn conflict_row(file: &ConflictFileView, expanded: Option<&str>, catalog: Catalog) -> ConflictRow {
ConflictRow {
path: file.path.clone(),
reason: match file.reason {
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
}
.to_owned(),
choice: match file.choice {
ConflictChoiceView::KeepUser => catalog.text(Key::PackChoiceKeepUser),
ConflictChoiceView::Overwrite => catalog.text(Key::PackChoiceOverwrite),
}
.to_owned(),
pack_sha1: short_sha(&file.pack_sha1),
base_sha1: file.base_sha1.as_deref().map(short_sha),
disk_sha1: file.disk_sha1.as_deref().map(short_sha),
expanded: expanded == Some(file.path.as_str()),
}
}
/// SHA-1 截短(前 8 位够人眼区分;全 40 位会把一行撑爆)。
#[must_use]
pub fn short_sha(sha: &str) -> String {
sha.chars().take(8).collect()
}