fix(packui): M113 第一批修正 —— 一次只摊开一件事、有界滚动、阶段词不再打架

D30 出图实测暴露的四个问题,都在本批范围内修掉:

1. **动作按钮被顶出屏幕**:内容库页正列没有整页滚动(只有结果列表自己滚),入口 + 报告 +
   导出三块同时摊开时,「确认写盘 / 关闭 / 以包为准重新导入」落在 900px 以下。
   改成一态一块(导出面板 > 进度 > 结果 > 入口);拖拽是窗口级事件,收起入口不丢功能。
2. **阶段词打架**:复用面板自带的阶段词是安装语义(Building the version),与
   「Step 2/5 · Unpacking overrides」并排时两种说法。现在面板阶段词留空、由步骤行说真话
   —— 仍然只有一份进度条 / 计数 / 取消按钮,没有第二套进度 UI。
3. **列表过长**:缺文件 / 冲突 / 预检样例与已知损失都套上「超过 n 条才滚」的有界容器
   (packui/view/parts.rs 的 bounded),少量条目时不浪费空间。
4. **英文单复数**:计数行改成「标签:值」(Files written: 2、cannot export: 1)。
   PackPathPlaceholder / PackFormatModrinth 的中文与英文雷同,被
   chinese_catalog_is_actually_chinese 抓到,一并改掉。

另:app/update.rs 加完拖拽分支后 408 行超线 → 两个钩子改成返回 Task、三个分支各一行,
现在 397 行。新增致命态出图夹具(ULL_CONTENT_PACK=failed),用来证明「容忍的缺文件」与
「致命回滚」在视觉上可区分。

门禁:cargo check ✓ / clippy -D warnings ✓ / cargo test -p unlucky-app-iced(467+6)✓ /
cargo test -p unlucky-i18n(24)✓ / rustfmt --check ✓ / wc -l 全表 ≤400 ✓
This commit is contained in:
AskaEth committed 2026-11-05 04:06:38 +08:00
1 parent fbeff94e55
commit 2129d8cee6
10 files changed
+161 -83

No files matched your search

+5 -16
View File
@@ -100,22 +100,11 @@ impl App {
self.view_dirty = true;
self.refresh_view();
}
// M113: import/export —— 拖入整合包(文件或 MultiMC·Prism 实例目录)。
// 这是本版**唯一**的「选文件」通道:依赖树里没有对话框依赖,加它要动根
// `Cargo.toml` 与许可门禁(见 `pages/content/packui.rs` 的边界说明)。
window::Event::FileDropped(path) => {
self.on_pack_dropped(path);
self.refresh_view();
}
window::Event::FileHovered(_) => {
self.on_pack_hover(true);
self.refresh_view();
}
window::Event::FilesHoveredLeft => {
self.on_pack_hover(false);
self.refresh_view();
}
// 其余窗口事件本仓不消费(非穷尽枚举,保留兜底免得升级 iced 时炸)
// M113: import/export —— 拖入整合包(本版唯一的选文件通道;窗口级事件,
// 所以入口收起时也照样接得住。细节在 `on_pack_dropped` / `on_pack_hover`)
window::Event::FileDropped(path) => return self.on_pack_dropped(path),
window::Event::FileHovered(_) => return self.on_pack_hover(true),
window::Event::FilesHoveredLeft => return self.on_pack_hover(false),
_ => {}
}
Task::none()
+10 -2
View File
@@ -188,16 +188,24 @@ impl App {
}
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) {
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task<Message> {
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
self.state
.navigate(unlucky_presenter::router::Route::Content);
self.on_pack(pages::content::PackMessage::Dropped(path));
self.refresh_view();
Task::none()
}
/// 拖拽悬停状态(有文件在窗口上方时高亮入口)。
pub(super) fn on_pack_hover(&mut self, hover: bool) {
pub(super) fn on_pack_hover(&mut self, hover: bool) -> Task<Message> {
// 状态没变就不重画:winit 对同一批文件会连发几次 hover 事件
let changed = self.content.pack.hover != hover;
self.on_pack(pages::content::PackMessage::Hover(hover));
if changed {
self.refresh_view();
}
Task::none()
}
/// 改一个筛选字段并**立刻重查**(第一页)。
@@ -8,7 +8,7 @@
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//!
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
@@ -19,9 +19,9 @@ use std::path::PathBuf;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportProgressView,
ImportReportView, ImportStageView, ImportState, ImportStatusView, InstanceId, LoaderKind,
MissingFileView, MissingReasonView, PackFormatView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
};
use crate::pages::content::Target;
@@ -73,6 +73,19 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
import.report = Some(report(ImportStatusView::Partial, true));
page.expanded = Some("config/sodium-options.json".to_string());
}
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
"failed" => {
let mut fatal = report(ImportStatusView::Failed, false);
fatal.error = Some(ImportErrorView {
code: "pack_traversal".to_string(),
label: "Path traversal".to_string(),
message: "overrides/../../etc/passwd".to_string(),
hint: Some("Re-download the pack from its source".to_string()),
});
fatal.files.clear();
fatal.bytes_written = 0;
import.report = Some(fatal);
}
"export" => {
page.export_open = true;
page.export_name = "Survival 1.20.1".to_string();
@@ -122,11 +135,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
files: vec![
file("mods/sodium.jar", FileActionView::Installed, 812_000),
file(
"mods/lithium.jar",
FileActionView::SkippedIdentical,
640_000,
),
file("mods/lithium.jar", FileActionView::Installed, 640_000),
file("config/xaeroplus.cfg", FileActionView::KeptUser, 4_200),
],
conflicts: Vec::new(),
@@ -151,7 +160,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
},
],
rejected: vec!["org.lwjgl3:3.3.1".to_string()],
warnings: vec!["包内 `server-overrides/` 未解压(客户端导入)".to_string()],
warnings: vec!["`server-overrides/` was not unpacked (client import)".to_string()],
error: None,
registered: Some("Survival".to_string()),
bytes_written: 3_400_000,
@@ -4,23 +4,24 @@
//!
//! 「沿用既有安装面板组件与状态机,禁止再写第二套进度 UI」——
//!
//! 1. 导入的 `ImportStageView` / 计数 / 当前路径先映射成 [`InstallProgressView`](本文件
//! [`panel_progress`]);
//! 2. 画法交给 `pages::download::install_panel`(M109 已经打过薄包装,同一个函数、同一套几何
//! 与文案);
//! 3. 面板只认四个粗阶段(规划 / 下载 / 安装 / 登记),任务书要的「解包 / overrides / 补文件」
//! 三档由**一行步骤说明**([`stage_line`])承载。
//! 1. 导入的 `ImportStageView` / 计数先映射成面板的入参形状([`panel_progress`] →
//! `InstallProgressView` → [`InstallView`]);
//! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 /
//! 取消按钮都归它);
//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载,
//! 面板右边那个阶段词**留空**。
//!
//! 第 3 点不是第二套进度 UI:多出来的只是一句文字,进度条、计数行、取消按钮仍然只有那一份。
//! 为什么不再造一条按 `ImportStageView` 五档划分的进度条:那正是「第二套」的定义,而且两块条
//! 同时出现时用户不知道该信哪一条。
//! 第 3 点的理由:面板自带的四个阶段词是**安装**语义(规划 / 下载 / 生成版本 / 登记实例),
//! 套到导入上会出现「Step 2/5 · Unpacking overrides」旁边写着「Building the version」——
//! 同一块地方两种说法(出图时实测到了)。留空 + 由步骤行说真话,是**不动那个组件**的最小解;
//! 多出来的只有一句文字,没有第二条进度条。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ImportProgressView, ImportStageView, InstallProgressView, InstallStage,
};
use crate::pages::download::Download;
use crate::pages::download::{Download, InstallView};
/// 抛进面板的进度行。
#[derive(Debug, Clone, PartialEq)]
@@ -100,14 +101,31 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
/// 进度事件 → 面板要的视图模型。
pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running {
let panel = panel_progress(progress);
Running {
pack: progress.pack.clone(),
stage_line: stage_line(progress.stage, catalog),
panel: Download {
install: Some(crate::pages::download::install_view(
&panel_progress(progress),
catalog,
)),
install: Some(InstallView {
version: panel.version.clone(),
// 只用于 `running()` / `failed()` 的判定;**不**用它给用户看(见模块文档第 3 点)
stage: panel.stage,
stage_label: String::new(),
detail: if progress.is_known() {
catalog.render(
Key::DownloadProgress,
&[
("done", &progress.done.to_string()),
("total", &progress.total.to_string()),
],
)
} else {
catalog.text(Key::DownloadProgressUnknown).to_owned()
},
rate: None,
ratio: progress.ratio(),
known: progress.is_known(),
}),
..Download::loading()
},
}
@@ -80,22 +80,27 @@ pub(crate) fn section<'a>(
catalog: Catalog,
model: &'a Pack,
) -> Element<'a, PageMessage> {
let mut block = column![entry(ui, catalog, model)]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
let mut block = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
if let Some(running) = &model.running {
// **一次只摊开一件事**:入口(拖什么/装到哪)、进度、结果、导出预检各占一态。
// 全都堆在一起时,内容库页正列没有整页滚动 ⇒ 动作按钮被顶出屏幕(出图实测:冲突态与
// 导出预检态的「确认写盘 / 关闭」都落在 900px 以下)。收起的入口不会丢功能:拖拽是窗口级
// 事件,任何时候把文件拖进来都算数。
if model.export.open {
block = block.push(export_block(ui, catalog, &model.export));
} else if let Some(running) = &model.running {
block = block.push(running_block(ui, catalog, running));
}
if let Some(probe) = &model.probe {
block = block.push(probe_block(ui, catalog, probe));
}
if let Some(report) = &model.report {
block = block.push(divider::full(ui));
} else if let Some(report) = &model.report {
block = block.push(report_block(ui, catalog, report));
} else {
block = block.push(entry(ui, catalog, model));
if let Some(probe) = &model.probe {
// 探测失败要和路径输入框在一起:用户下一步就是改路径
block = block.push(probe_block(ui, catalog, probe));
}
block = block.push(divider::full(ui));
block = block.push(export_block(ui, catalog, &model.export));
}
block = block.push(divider::full(ui));
block = block.push(export_block(ui, catalog, &model.export));
container(block).width(Length::Fill).into()
}
@@ -20,7 +20,7 @@ use crate::theme::Ui;
use super::super::model::{Export, Preview};
use super::super::PackMessage;
use super::intent;
use super::parts::{rows_block, tolerable_title};
use super::parts::{bounded, rows_block, tolerable_title};
/// 导出块。
pub(super) fn export_block<'a>(
@@ -232,8 +232,11 @@ pub(super) fn preview_block<'a>(
block = block.push(rows_block(ui, catalog, &preview.skipped));
}
// 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把
// 「确认写盘」顶出屏幕(出图时实测到了)
let mut extras = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
for path in &preview.inline_sample {
block = block.push(frame::text_role(
extras = extras.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
@@ -242,7 +245,7 @@ pub(super) fn preview_block<'a>(
));
}
if let Some(more) = &preview.more_line {
block = block.push(frame::text_role(
extras = extras.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
@@ -250,9 +253,8 @@ pub(super) fn preview_block<'a>(
ui.color(Role::OnSurfaceVariant),
));
}
if !preview.notes.is_empty() {
block = block.push(frame::text_role(
extras = extras.push(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
@@ -260,7 +262,7 @@ pub(super) fn preview_block<'a>(
ui.color(Role::OnSurfaceVariant),
));
for note in &preview.notes {
block = block.push(frame::text_role(
extras = extras.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
@@ -269,6 +271,8 @@ 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.into()
}
@@ -97,3 +97,33 @@ pub(super) fn rows_block<'a>(
.width(Length::Fill)
.into()
}
/// 逐条列表的**有界**容器:条目多时给一个固定高度的滚动区。
///
/// 为什么必须要有界:这一块长在内容库页正列的上方,而整页没有滚动(只有结果列表自己滚)⇒
/// 报告一长,「关闭 / 以包为准重新导入 / 确认写盘」这些动作就被顶出屏幕。出图时实测到了这一点
/// (冲突态与导出预检态的动作按钮落在 900px 以下)。
///
/// 少于 `rows_threshold` 条时直接铺开:给两行内容套一个 120px 的空框,比溢出更难看。
pub(super) fn bounded<'a>(
ui: &'a Ui,
content: impl Into<Element<'a, PageMessage>>,
rows: usize,
rows_threshold: usize,
height: f32,
) -> Element<'a, PageMessage> {
let content = content.into();
if rows <= rows_threshold {
return content;
}
iced::widget::scrollable(content)
.height(Length::Fixed(height))
.direction(iced::widget::scrollable::Direction::Vertical(
iced::widget::scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.style(frame::scroll_style(ui))
.into()
}
@@ -18,7 +18,7 @@ use crate::theme::Ui;
use super::super::model::{ConflictRow, Report};
use super::super::PackMessage;
use super::intent;
use super::parts::{marker_box, rows_block, tolerable_title};
use super::parts::{bounded, marker_box, rows_block, tolerable_title};
/// 结果块。
pub(super) fn report_block<'a>(
@@ -119,7 +119,13 @@ pub(super) fn report_block<'a>(
block = block.push(
column![
tolerable_title(ui, catalog, title.as_str()),
rows_block(ui, catalog, &report.missing),
bounded(
ui,
rows_block(ui, catalog, &report.missing),
report.missing.len(),
3,
120.0,
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
@@ -133,15 +139,21 @@ pub(super) fn report_block<'a>(
block = block.push(
column![
tolerable_title(ui, catalog, title.as_str()),
column(
report
.conflicts
.iter()
.map(|conflict| conflict_row(ui, catalog, conflict))
.collect::<Vec<_>>()
)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
bounded(
ui,
column(
report
.conflicts
.iter()
.map(|conflict| conflict_row(ui, catalog, conflict))
.collect::<Vec<_>>()
)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
report.conflicts.len(),
2,
160.0,
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
+9 -6
View File
@@ -96,7 +96,10 @@ macro_rules! content_entries {
"本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"
),
PackPathLabel => ("Pack path", "整合包路径"),
PackPathPlaceholder => ("D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"),
PackPathPlaceholder => (
"D:\\packs\\Better MC.mrpack",
"D:\\整合包\\更好的MC.mrpack"
),
PackImportAction => ("Import", "导入"),
PackCancel => ("Cancel import", "取消导入"),
PackDismiss => ("Dismiss", "关闭"),
@@ -126,13 +129,13 @@ macro_rules! content_entries {
PackStatusSuccess => ("Imported", "已导入"),
PackStatusPartial => ("Imported with gaps", "导入完成,但有缺口"),
PackStatusFailed => ("Import failed — rolled back", "导入失败 —— 已整包回滚"),
PackReportWritten => ("{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"),
PackReportWritten => ("Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
PackReportMissing => (
"{count} files could not be fetched (tolerated)",
"Could not fetch (tolerated): {count}",
"有 {count} 个文件没搞到(可容忍)"
),
PackReportConflicts => (
"{count} files you had changed (yours kept by default)",
"You had changed (yours kept by default): {count}",
"{count} 个文件你改过(默认保留你的)"
),
PackReportRejectedNote => ("not supported by the client", "客户端不支持"),
@@ -162,7 +165,7 @@ macro_rules! content_entries {
"包内 {pack} · 基线 {base} · 磁盘 {disk}"
),
PackConflictDetail => ("Hashes", "三方哈希"),
PackFormatModrinth => ("Modrinth .mrpack", "Modrinth .mrpack"),
PackFormatModrinth => ("Modrinth .mrpack", "Modrinth 的 .mrpack"),
PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"),
PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"),
PackExportTitle => ("Export this instance", "导出这个实例"),
@@ -182,7 +185,7 @@ macro_rules! content_entries {
PackExportFailed => ("Export failed: {reason}", "导出失败:{reason}"),
PackExportPending => ("Scanning the instance…", "正在扫描实例…"),
PackPreviewHead => (
"Will inline {inline} files · {skipped} items cannot be exported",
"Will inline {inline} files · cannot export: {skipped}",
"将内联 {inline} 个文件 · 无法导出 {skipped} 项"
),
PackPreviewRemote => (
+6 -6
View File
@@ -225,7 +225,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackDropActive", "Release to probe the pack", "松手开始探测"),
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"),
("PackPathLabel", "Pack path", "整合包路径"),
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"),
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
("PackImportAction", "Import", "导入"),
("PackCancel", "Cancel import", "取消导入"),
("PackDismiss", "Dismiss", "关闭"),
@@ -246,9 +246,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackStatusSuccess", "Imported", "已导入"),
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
("PackReportWritten", "{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"),
("PackReportMissing", "{count} files could not be fetched (tolerated)", "有 {count} 个文件没搞到(可容忍)"),
("PackReportConflicts", "{count} files you had changed (yours kept by default)", "{count} 个文件你改过(默认保留你的)"),
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
("PackReportConflicts", "You had changed (yours kept by default): {count}", "{count} 个文件你改过(默认保留你的)"),
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"),
@@ -264,7 +264,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackChoiceOverwrite", "pack version used", "用包里的"),
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
("PackConflictDetail", "Hashes", "三方哈希"),
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth .mrpack"),
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
("PackExportTitle", "Export this instance", "导出这个实例"),
@@ -280,7 +280,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
("PackPreviewHead", "Will inline {inline} files · {skipped} items cannot be exported", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
("PackPreviewSkippedTitle", "Not in the pack (listed one by one)", "没进包(逐条列出)"),