复审 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 行。
256 lines
9.1 KiB
Rust
256 lines
9.1 KiB
Rust
//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。
|
||
//!
|
||
//! ## 为什么容忍域与致命域连字段类型都不同
|
||
//!
|
||
//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。
|
||
//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以:
|
||
//!
|
||
//! | 域 | 类型 | 画法 |
|
||
//! |---|---|---|
|
||
//! | 容忍(缺文件 / 被拒 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()
|
||
}
|