Files
Unlucky-Launcher/crates/app-iced/src/pages/content/packui/model/report.rs
T
AskaEth 99538e45b9 fix(packui): M113 r1 复审整改 —— 2 处「界面说假话」+ 7 条必修 P2
复审 r1(p1-2items / fix-then-merge)逐条整改。**P2-6(core 的本地化契约)按塔台指示本轮不动**,
已登记为独立 mission 的债。

## P1

1. **「Save to」是死输入框**:`ExportRequestView` 的唯一构造点硬编码 `target: None`,用户敲的路径
   从不进请求。现在构造收成纯函数 `packui::model::export::request_of`(预检与确认共用同一份),
   `target` 由字段去空白后给出(空 ⇒ `None`,交给 host 给缺省)。
2. **取消导入的 toast 撒谎**:下载页那条说「安装已取消、实例未创建」,而导入会在后台跑完并
   **登记实例**。现在 `cancel_pack()` 回报「刚才有没有导入在跑」,`dispatch` 据此改发
   `PackEvent::Cancelled`(不再发 `InstallCancelled`),界面与提示改成
   「导入会在后台继续跑完 —— 本次结果作废;已写下的文件会留下」,并把这句话**留在屏上**
   (`ImportState.cancelled` + 面板位)。

## P2 必修(7 条)

3. **`Dismiss` 误伤下载页安装**(跨功能污染,最高优先):pack 原来共用 `CoreHost::install_epoch`,
   关一次导入面板会把下载页正在跑的安装静默丢掉。现在 pack 有自己的
   `PACK_EPOCH`(模块级 `AtomicU64`),`handle` 的 pack 分支只比它,`Dismiss`/取消只动它。
4. **中文实例名塌成 `pack`**:新增 `convert::dir_name` —— 名字全 ASCII 安全字符时保持原样
   (`Better MC` → `Better-MC`),一旦折过东西(中文/空格/符号)就补 `-<hash8>`(FNV-1a),
   保证两个中文名得到不同目录。
5. **失败回滚后空目录挡住重试**:`target_of` 对「已存在但空」的目录**复用**(非空才拒),
   并在失败回滚后清掉自己建的空目录;`failed_report` 的 `target` 语义改正
   (以前把包路径塞进「目标实例目录」)。
6. **新实例流程下「以包为准」必然失败**:`can_overwrite` 追加 `page.instance.is_some()` ——
   新建实例那条路上不再显示一个必然失败的按钮。
7. **阶段词打架且文档说修好了**:`progress.rs` 的模块文档改成与屏幕一致(粗/细两层 + 已知重叠),
   并写清彻底解决要靠「安装面板提成共享组件」那条 finding。
8. **导入侧标题计数与行数对不上**:被拒 component 有了自己的标题(`The client does not support
   these: {count}`);冲突标题改成中性的 `Conflicts: {count}`(逐条自带成因与处置)。
9. **预检「没进包」列表没有有界滚动**:13 条是真实形态。列表改成有界滚动(110px),
   并且**把动作按钮提到摘要下面**(`Write the pack` / `Back`)—— 明细再长也顶不掉按钮。
10. **导入成功后不刷新实例列表**:`handle` 的 pack 分支在收到 `Report` 时调 `refresh_instances()`
    (与 `InstallDone` 同一条),新实例不用等下一次无关的 `RefreshAll`。

## 证据

- 新增测试(都跑真管线/真函数):`the_save_to_field_really_reaches_the_request`、
  `pack_epoch_is_its_own_counter`、`chinese_instance_names_get_distinct_directories`、
  `a_failed_new_instance_directory_can_be_reused_for_retry`、
  `overwrite_is_offered_only_when_importing_into_an_existing_instance`、
  `cancelled_state_is_visible_and_says_the_import_keeps_running`。
- 出图重出 12 张(6 态 × 浅深),新增 `06-cancelled-*`;`04-export-*` 用 13 条 skipped 的真实形态。

门禁:`cargo fmt --all --check` 通过;`cargo clippy -p unlucky-app-iced -p unlucky-presenter
-p unlucky-i18n --all-targets -- -D warnings` EXIT=0;`cargo test -p unlucky-app-iced --lib`
507 passed;`cargo test -p unlucky-presenter --lib` 184 passed;`cargo test -p unlucky-i18n --lib`
24 passed;全部改动文件 ≤400 行。
2026-11-05 04:06:42 +08:00

256 lines
9.1 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, 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,
}
/// 致命失败块(**已整包回滚**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fatal {
/// 「已回滚 —— 目标目录零残留」
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.status, 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, 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)
}
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
match status {
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
ImportStatusView::Failed => 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, catalog: Catalog) -> Fatal {
Fatal {
title: catalog.text(Key::PackFatalTitle).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()
}