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 行。
This commit is contained in:
AskaEth committed 2026-11-05 04:06:42 +08:00
1 parent 27bbc74eaa
commit 99538e45b9
23 files changed
+474 -74

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ mod tests;
pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
// M113: import/export
pub use packui::{Pack, PackMessage, PackPageState};
pub use packui::{request_export, Pack, PackMessage, PackPageState};
use iced::Element;
+1 -1
View File
@@ -46,7 +46,7 @@ mod view;
mod tests;
pub use message::Message as PackMessage;
pub use model::{Pack, PackPageState};
pub use model::{request_export, Pack, PackPageState};
/// 区入口(内容库页的正列调它)。
pub(crate) use view::section;
@@ -86,6 +86,8 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
fatal.bytes_written = 0;
import.report = Some(fatal);
}
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
"cancelled" => import.cancelled = true,
"export" => {
page.export_open = true;
page.export_name = "Survival 1.20.1".to_string();
@@ -210,10 +212,20 @@ fn preview() -> ExportPreviewView {
disabled: false,
});
}
// 13 条「没进包」是真实形态:预检那块必须自己做有界滚动,否则把「确认写盘」顶出屏幕
for index in 0..12 {
files.push(ExportFileView {
path: format!("mods/.cache/backup-{index:02}.jar"),
class: ExportClassView::Skipped,
reason: ExportReasonView::SymbolicLink,
bytes: 0,
disabled: false,
});
}
files.push(ExportFileView {
path: "mods/.cache/backup.jar".to_string(),
path: "mods/bad:name.jar".to_string(),
class: ExportClassView::Skipped,
reason: ExportReasonView::SymbolicLink,
reason: ExportReasonView::UnsafePath,
bytes: 0,
disabled: false,
});
@@ -29,7 +29,7 @@ mod export;
mod progress;
mod report;
pub use export::{format_label, Export, Preview};
pub use export::{format_label, request_of as request_export, Export, Preview};
pub use progress::Running;
pub use report::{ConflictRow, Report, Row};
@@ -125,6 +125,11 @@ pub struct Pack {
pub running: Option<Running>,
/// 探测失败诊断
pub probe: Option<Probe>,
/// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌),
/// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。
pub cancelled: bool,
/// 取消提示的正文
pub cancelled_note: &'static str,
/// 导入结果
pub report: Option<Report>,
/// 导出块
@@ -170,6 +175,8 @@ impl Pack {
.as_ref()
.map(|progress| progress::running_of(progress, catalog)),
probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)),
cancelled: import.cancelled,
cancelled_note: catalog.text(Key::PackCancelled),
report: import
.report
.as_ref()
@@ -183,6 +190,7 @@ impl Pack {
self.running.is_none()
&& self.probe.is_none()
&& self.report.is_none()
&& !self.cancelled
&& !self.export.open
&& self.draft.is_empty()
}
@@ -18,12 +18,38 @@
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportState,
PackFormatView,
human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView,
ExportRequestView, ExportState, PackFormatView,
};
use super::{PackPageState, PackTarget, Row};
/// 页内选择 → 导出请求(预检与写盘**共用同一份**)。
///
/// `target` 必须从这里进去(复审 r1 P1-1):之前全仓唯一构造点硬编码 `target: None`,
/// 于是用户敲的「Save to」永远不进请求 —— 输入框是死的。
#[must_use]
pub fn request_of(page: &PackPageState, instance: &str) -> ExportRequestView {
let name = page.export_name.trim();
let version = page.export_version.trim();
let output = page.output.trim();
ExportRequestView {
instance: instance.to_string(),
format: page.format,
name: if name.is_empty() {
instance.to_string()
} else {
name.to_string()
},
version: if version.is_empty() {
"1.0.0".to_string()
} else {
version.to_string()
},
target: (!output.is_empty()).then(|| std::path::PathBuf::from(output)),
}
}
/// 预检里「内联文件样例」最多列几条(其余用一句「还有 N 个」收口)。
const INLINE_SAMPLE: usize = 6;
@@ -8,13 +8,16 @@
//! `InstallProgressView` → [`InstallView`]);
//! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 /
//! 取消按钮都归它);
//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载,
//! 面板右边那个阶段词**留空**。
//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载。
//!
//! 第 3 点的理由:面板自带的四个阶段词是**安装**语义(规划 / 下载 / 生成版本 / 登记实例),
//! 套到导入上会出现「Step 2/5 · Unpacking overrides」旁边写着「Building the version」——
//! 同一块地方两种说法(出图时实测到了)。留空 + 由步骤行说真话,是**不动那个组件**的最小解;
//! 多出来的只有一句文字,没有第二条进度条。
//! **已知重叠(如实写,别再说成修好了)**:面板自带的阶段词是**安装**语义
//! (`Resolving version` / `Downloading files` / `Building the version` / `Registering instance`),
//! 而 `install_panel_model` 永远会填它(M109 合流 M105 后 `InstallView` 不再对外导出,
//! 没法从外面塞一个自定义标签或清空)。所以屏幕上同时出现「Step 2/5 · Unpacking overrides」
//! 与「Building the version」:**粗/细两层,不是两句矛盾的话**,但 `Extract`/`Commit` 都映射到
//! `Installing`,那一档的用词偏「安装」。
//!
//! 要彻底解决得让面板接受调用方给的阶段词(已报 finding:把安装面板提成共享组件)。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
@@ -84,6 +84,8 @@ pub struct Report {
pub conflicts_title: Option<String>,
/// 冲突逐条
pub conflicts: Vec<ConflictRow>,
/// 被拒 component 的标题(带计数,与下面逐条行数一致)
pub rejected_title: Option<String>,
/// 被拒 component 逐条
pub rejected: Vec<Row>,
/// 注记逐条
@@ -144,6 +146,12 @@ pub(super) fn report_of(
.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()
@@ -153,7 +161,12 @@ pub(super) fn report_of(
})
.collect(),
warnings: report.warnings.clone(),
can_overwrite: !report.conflicts.is_empty() && report.status == ImportStatusView::Partial,
// 「以包为准重新导入」只在**导入到已有实例**时有意义(复审 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()),
}
@@ -261,7 +261,11 @@ fn conflicts_default_to_keeping_the_user_copy() {
bytes_written: 0,
rolled_back: true,
};
let page = PackPageState::default();
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
let page = PackPageState {
instance: Some("survival".to_string()),
..PackPageState::default()
};
let block = report_of(&report, &page, catalog());
assert_eq!(block.conflicts.len(), 1);
assert!(!block.conflicts[0].expanded, "默认收起");
@@ -276,7 +280,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
// 展开的那一条才画三方哈希
let expanded = PackPageState {
expanded: Some("config/x.json".to_string()),
..PackPageState::default()
..page.clone()
};
let block = report_of(&report, &expanded, catalog());
assert!(block.conflicts[0].expanded);
@@ -294,3 +298,97 @@ fn an_idle_pack_has_nothing_to_draw_but_the_entry() {
assert!(!model.drop_hint.is_empty());
assert!(!model.no_dialog_note.is_empty());
}
#[test]
fn the_save_to_field_really_reaches_the_request() {
// 复审 r1 P1-1:这个字段以前是死的(构造点硬编码 target: None)
let page = PackPageState {
output: " D:/packs/my.mrpack ".to_string(),
..PackPageState::default()
};
let request = super::export::request_of(&page, "survival");
assert_eq!(
request.target.as_deref(),
Some(std::path::Path::new("D:/packs/my.mrpack")),
"用户敲的路径必须真的进请求(去首尾空白)"
);
// 留空 ⇒ None(由 host 给缺省路径),不是空串路径
let blank = PackPageState {
output: " ".to_string(),
..PackPageState::default()
};
assert!(super::export::request_of(&blank, "survival")
.target
.is_none());
// 包名/版本留空时的缺省值也钉一下(预检与写盘共用同一份请求,不能各算一套)
let request = super::export::request_of(&blank, "survival");
assert_eq!(request.name, "survival");
assert_eq!(request.version, "1.0.0");
}
#[test]
fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
// 复审 r1 P2-2:新建实例那条路上再点「以包为准」必然撞「directory already exists」
let report = unlucky_presenter::dto::ImportReportView {
status: ImportStatusView::Partial,
format: Some(PackFormatView::Modrinth),
pack_name: Some("Pack".to_string()),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target: std::path::PathBuf::from("C:/tmp"),
files: Vec::new(),
conflicts: vec![ConflictFileView {
path: "config/x.json".to_string(),
reason: ConflictReasonView::UserModified,
choice: ConflictChoiceView::KeepUser,
pack_sha1: "abc".to_string(),
base_sha1: None,
disk_sha1: None,
pack_size: 1,
}],
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
bytes_written: 0,
rolled_back: true,
};
let new_instance = PackPageState::default();
assert!(
!super::report::report_of(&report, &new_instance, catalog()).can_overwrite,
"新建实例流程下不该出现「以包为准」(那按钮必然失败)"
);
let existing = PackPageState {
instance: Some("survival".to_string()),
..PackPageState::default()
};
assert!(
super::report::report_of(&report, &existing, catalog()).can_overwrite,
"导入到已有实例时才给翻档动作"
);
}
#[test]
fn cancelled_state_is_visible_and_says_the_import_keeps_running() {
// 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」
let import = unlucky_presenter::dto::ImportState {
cancelled: true,
..unlucky_presenter::dto::ImportState::default()
};
let export = unlucky_presenter::dto::ExportState::default();
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
assert!(model.cancelled);
assert!(
model.cancelled_note.to_lowercase().contains("background"),
"取消提示必须说清后台还在跑:{}",
model.cancelled_note
);
assert!(!model.is_idle(), "取消态不能被当成「什么都没有」");
}
@@ -92,6 +92,25 @@ pub(crate) fn section<'a>(
block = block.push(running_block(ui, catalog, running));
} else if let Some(report) = &model.report {
block = block.push(report_block(ui, catalog, report));
} else if model.cancelled {
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
block = block.push(
column![
frame::text_role(
ui,
TypeRole::BodySmall,
false,
model.cancelled_note,
ui.color(Role::Tertiary),
),
buttons::text(
ui,
Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
} else {
block = block.push(entry(ui, catalog, model));
if let Some(probe) = &model.probe {
@@ -139,14 +139,8 @@ pub(super) fn export_block<'a>(
));
}
if let Some(preview) = &export.preview {
// 动作在 `preview_block` 里紧跟摘要(明细再长也不会把「确认写盘」顶出屏幕)
block = block.push(preview_block(ui, catalog, preview));
block = block.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::PackExportConfirm),
intent(PackMessage::Confirm),
),
));
}
}
@@ -226,6 +220,29 @@ pub(super) fn preview_block<'a>(
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// **动作紧跟摘要**:明细(13 条 skipped + 样例 + 已知损失)再长,也不会把
// 「Write the pack」顶出 900px(复审 r1 P2-3 的正解:先给结论与动作,明细往下滚)
block = block.push(
row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::PackExportConfirm),
intent(PackMessage::Confirm),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::PackExportAbort),
intent(PackMessage::AbortExport)
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
);
if !preview.skipped.is_empty() {
// 标题写成「Not in the pack: 13」而不是「逐条列出」:13 条**确实**全列出来了,
// 但下面那个「…还有 N 个(这里不展开)」说的是**内联样例**,两句挨着容易被读成矛盾
@@ -236,7 +253,14 @@ pub(super) fn preview_block<'a>(
&[("count", &preview.skipped.len().to_string())],
),
));
block = block.push(rows_block(ui, catalog, &preview.skipped));
// 13 条是真实形态:摊开会把「Write the pack」顶出 900px(复审 r1 P2-3)
block = block.push(bounded(
ui,
rows_block(ui, catalog, &preview.skipped),
preview.skipped.len(),
3,
110.0,
));
}
// 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把
@@ -292,7 +316,7 @@ pub(super) fn preview_block<'a>(
}
}
let extras_rows = preview.inline_sample.len() + preview.notes.len();
block = block.push(bounded(ui, extras, extras_rows, 4, 160.0));
block = block.push(bounded(ui, extras, extras_rows, 3, 120.0));
block.into()
}
@@ -131,8 +131,17 @@ pub(super) fn report_block<'a>(
.width(Length::Fill),
);
}
if !report.rejected.is_empty() {
block = block.push(rows_block(ui, catalog, &report.rejected));
if let Some(title) = &report.rejected_title {
// 没有这句的话,「Could not fetch (tolerated): 3」下面会多出一行没有归属的
// 「not supported by the client」(复审 r1 P2-4)
block = block.push(
column![
tolerable_title(ui, title.clone()),
rows_block(ui, catalog, &report.rejected),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
}
if let Some(title) = &report.conflicts_title {