From fbeff94e552c937f55641ac1825e971e740bcb5e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 23:34:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(packui):=20M113=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=89=B9=20=E2=80=94=E2=80=94=20=E5=86=85=E5=AE=B9=E5=BA=93?= =?UTF-8?q?=E9=A1=B5=E7=9A=84=E5=AF=BC=E5=85=A5/=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8C=BA=EF=BC=88=E7=95=8C=E9=9D=A2=20+=20DTO=20+=20i18n=20+?= =?UTF-8?q?=20=E6=8B=96=E6=8B=BD=E8=90=BD=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**, 数据面镜像 M111/M112 的管线报告(presenter DTO),进度整块复用下载页的安装面板。 ## 落了什么 - pages/content/packui*:入口 / 进度块 / 结果块 / 导出预检块(每文件 ≤400) - 拖拽区(hover 变主色描边)+ 路径输入框 + 目标实例 chips - 进度:一行「步骤 n/5 · 解包覆盖内容」+ **下载页那块安装面板**(不另画进度条) - 结果:三态(Success/Partial/Failed)+ 缺文件逐条(Warn/Tertiary)+ 冲突逐条 (成因 + 本次处置 + 可展开三方 SHA-1)+ 致命块(Error/ErrorContainer) - 导出:mrpack / CF 二选一 + 包名/版本/路径 + 预检「将内联 N 个文件 · 无法导出 M 项」 + 逐条无法导出项 + 已知损失 + 确认写盘 - presenter/src/dto/pack{,/format,/import,/conflict,/export}.rs:镜像 core 报告的 DTO (三态、五阶段、三分类、十一条原因,全部 from_slug 与 core 稳定码逐字对齐) - i18n:81 条 Pack* 键(content_entries! 尾部追加;golden.rs 同序同文) - app/update.rs:FileDropped / FileHovered / FilesHoveredLeft → 内容库页(本版唯一的选文件通道) - M109 的四个文件各一处插入:mod 声明 + Pack(..) 变体 + pack 字段 + 正列插入点 ## 边界(如实) - 没有系统文件选择框:依赖树里无对话框依赖,加它要动根 Cargo.toml + 许可门禁 + Linux 后端选型(影响单文件产物)。入口是拖拽 + 路径输入,原因写在界面上。 - 冲突没有逐文件决策:core 的 ConflictPolicy 是整次导入一个全局档,ConflictFile 是事后账。 本批给「逐文件看 + 整体翻档(以包为准重新导入)」,文案明写「两者都留(改名)暂不支持」。 默认一律 KeepUser——绝不默认覆盖用户改过的文件。 - 本批按钮半通:拖拽/路径/格式/实例/展开都真生效;「点导入真的开跑」「预检/确认写盘」 要等第二批的 AppCommand 与事件通道(M109 rebase 到新 main 后按新形状写一次)。 --- crates/app-iced/src/app/update.rs | 31 +- crates/app-iced/src/app/update/content.rs | 101 ++++++ crates/app-iced/src/pages/content.rs | 4 + crates/app-iced/src/pages/content/demo.rs | 5 + crates/app-iced/src/pages/content/message.rs | 2 + crates/app-iced/src/pages/content/model.rs | 14 + .../app-iced/src/pages/content/model/items.rs | 3 + .../app-iced/src/pages/content/model/tests.rs | 1 + crates/app-iced/src/pages/content/packui.rs | 57 +++ .../app-iced/src/pages/content/packui/demo.rs | 224 ++++++++++++ .../src/pages/content/packui/message.rs | 57 +++ .../src/pages/content/packui/model.rs | 219 ++++++++++++ .../src/pages/content/packui/model/export.rs | 228 ++++++++++++ .../pages/content/packui/model/progress.rs | 114 ++++++ .../src/pages/content/packui/model/report.rs | 242 +++++++++++++ .../src/pages/content/packui/model/tests.rs | 296 +++++++++++++++ .../src/pages/content/packui/tests.rs | 104 ++++++ .../app-iced/src/pages/content/packui/view.rs | 233 ++++++++++++ .../src/pages/content/packui/view/export.rs | 274 ++++++++++++++ .../src/pages/content/packui/view/parts.rs | 99 +++++ .../src/pages/content/packui/view/progress.rs | 95 +++++ .../src/pages/content/packui/view/report.rs | 268 ++++++++++++++ crates/app-iced/src/pages/content/view.rs | 15 +- crates/i18n/src/catalog/content.rs | 154 ++++++++ crates/i18n/src/catalog/golden.rs | 81 +++++ crates/presenter/src/dto/mod.rs | 10 + crates/presenter/src/dto/pack.rs | 49 +++ crates/presenter/src/dto/pack/conflict.rs | 71 ++++ crates/presenter/src/dto/pack/export.rs | 219 ++++++++++++ crates/presenter/src/dto/pack/format.rs | 47 +++ crates/presenter/src/dto/pack/import.rs | 338 ++++++++++++++++++ 31 files changed, 3646 insertions(+), 9 deletions(-) create mode 100644 crates/app-iced/src/pages/content/packui.rs create mode 100644 crates/app-iced/src/pages/content/packui/demo.rs create mode 100644 crates/app-iced/src/pages/content/packui/message.rs create mode 100644 crates/app-iced/src/pages/content/packui/model.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/export.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/progress.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/report.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/tests.rs create mode 100644 crates/app-iced/src/pages/content/packui/tests.rs create mode 100644 crates/app-iced/src/pages/content/packui/view.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/export.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/parts.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/progress.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/report.rs create mode 100644 crates/presenter/src/dto/pack.rs create mode 100644 crates/presenter/src/dto/pack/conflict.rs create mode 100644 crates/presenter/src/dto/pack/export.rs create mode 100644 crates/presenter/src/dto/pack/format.rs create mode 100644 crates/presenter/src/dto/pack/import.rs diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index a2f1bd0..e46b7f7 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -92,12 +92,31 @@ impl App { Message::Frame => self.on_frame(), Message::Heartbeat => self.on_heartbeat(), Message::Window(event) => { - if let window::Event::Resized(size) = event { - self.width = size.width; - // 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 - self.height = size.height; - self.view_dirty = true; - self.refresh_view(); + match event { + window::Event::Resized(size) => { + self.width = size.width; + // 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 + self.height = size.height; + 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 时炸) + _ => {} } Task::none() } diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 910501a..e23eefe 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -95,10 +95,111 @@ impl App { self.dispatch(AppCommand::InstallContent(Box::new(request))); } } + // M113: import/export —— 导入/导出区(细节在 `on_pack`) + pages::content::Message::Pack(message) => self.on_pack(message), } Task::none() } + /// 导入/导出区的消息(M113)。 + /// + /// 两条口径: + /// + /// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)—— + /// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理); + /// 2. **意图派指令**(导入 / 取消 / 预检 / 确认写盘)—— 本批只到「改本地状态」这一步, + /// presenter 侧的 `AppCommand::{ImportPack, ExportPack}` 随事件通道一起在下一批接上 + /// (见 mission M113 的两批时序:枚举顺序要按 M109 rebase 后的新 main 写一次,不写两遍)。 + pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) { + use pages::content::PackMessage; + match message { + PackMessage::Dropped(path) => { + // 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上 + self.content.kind = unlucky_presenter::dto::ContentKind::Modpack; + self.content.pack.draft = path.display().to_string(); + self.content.pack.hover = false; + self.view_dirty = true; + } + PackMessage::Hover(hover) => { + self.content.pack.hover = hover; + self.view_dirty = true; + } + PackMessage::PathChanged(path) => { + self.content.pack.draft = path; + self.view_dirty = true; + } + PackMessage::SetInstance(instance) => { + self.content.pack.instance = instance; + self.view_dirty = true; + } + PackMessage::SetInstanceName(name) => { + self.content.pack.instance_name = name; + self.view_dirty = true; + } + PackMessage::SetFormat(format) => { + self.content.pack.format = format; + self.view_dirty = true; + } + PackMessage::SetExportName(name) => { + self.content.pack.export_name = name; + self.view_dirty = true; + } + PackMessage::SetExportVersion(version) => { + self.content.pack.export_version = version; + self.view_dirty = true; + } + PackMessage::SetOutput(path) => { + self.content.pack.output = path; + self.view_dirty = true; + } + PackMessage::ToggleConflict(path) => { + self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path) + { + None + } else { + Some(path) + }; + self.view_dirty = true; + } + PackMessage::OpenExport => { + self.content.pack.export_open = true; + // 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘) + if self.content.pack.export_name.trim().is_empty() { + self.content.pack.export_name = + self.content.pack.instance.clone().unwrap_or_default(); + } + self.view_dirty = true; + } + PackMessage::AbortExport => { + self.content.pack.export_open = false; + self.view_dirty = true; + } + PackMessage::Dismiss => { + // 报告与探测诊断都归 presenter(下一批清);这里先收起页内的展开状态 + self.content.pack.expanded = None; + self.view_dirty = true; + } + PackMessage::Import + | PackMessage::Cancel + | PackMessage::Overwrite + | PackMessage::Precheck + | PackMessage::Confirm => {} + } + } + + /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 + pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) { + // 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿 + self.state + .navigate(unlucky_presenter::router::Route::Content); + self.on_pack(pages::content::PackMessage::Dropped(path)); + } + + /// 拖拽悬停状态(有文件在窗口上方时高亮入口)。 + pub(super) fn on_pack_hover(&mut self, hover: bool) { + self.on_pack(pages::content::PackMessage::Hover(hover)); + } + /// 改一个筛选字段并**立刻重查**(第一页)。 fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) { let mut query = self.state.content.query.clone(); diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index 27bec5b..71e4c80 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -52,6 +52,8 @@ mod demo; mod layout; mod message; mod model; +// M113: import/export —— 导入/导出区的界面(新代码全在 `packui*`) +mod packui; mod parts; mod view; @@ -60,6 +62,8 @@ 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}; use iced::Element; diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index 2bbbb44..9259019 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -177,6 +177,10 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { .find(|item| Some(&item.id) == target.as_ref()) .map(|item| item.label()); + // M113: import/export —— 导入导出区自己那一态由 `ULL_CONTENT_PACK` 选 + // (`importing` / `partial` / `conflict` / `export`;不设 = 只有入口) + let pack = super::packui::demo_pack(lang).0; + Content { kind, query: ContentQueryView { @@ -212,5 +216,6 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { } else { String::new() }, + pack, } } diff --git a/crates/app-iced/src/pages/content/message.rs b/crates/app-iced/src/pages/content/message.rs index 0cead41..a5e9286 100644 --- a/crates/app-iced/src/pages/content/message.rs +++ b/crates/app-iced/src/pages/content/message.rs @@ -37,4 +37,6 @@ pub enum Message { Retry, /// 装进目标实例 Install, + /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) + Pack(super::packui::PackMessage), } diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index 0cb05b8..ab76fa3 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -73,6 +73,8 @@ pub struct Content { pub versions: Vec, /// 关键词草稿 pub draft: String, + /// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检 + pub pack: super::packui::Pack, } /// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。 @@ -144,6 +146,17 @@ impl Content { target.as_deref(), ); + // M113: import/export —— 进度与报告在 presenter(`AppState.import` / `.export`), + // 页内选择在 `page.pack`。两批落地:本批先接页内选择(入口可用),事件回流在下一批。 + // `targets` 下面会被移动进结构体,所以这里先借用它建视图模型。 + let pack = super::packui::Pack::of( + &unlucky_presenter::dto::ImportState::default(), + &unlucky_presenter::dto::ExportState::default(), + &page.pack, + &targets, + catalog, + ); + Self { kind: page.kind, query: ContentQueryView { @@ -170,6 +183,7 @@ impl Content { panel: content_install(state, catalog), versions, draft: page.draft.clone(), + pack, } } diff --git a/crates/app-iced/src/pages/content/model/items.rs b/crates/app-iced/src/pages/content/model/items.rs index 0eef4bf..7e4be4e 100644 --- a/crates/app-iced/src/pages/content/model/items.rs +++ b/crates/app-iced/src/pages/content/model/items.rs @@ -18,6 +18,8 @@ pub struct PageState { pub target: Option, /// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`) pub draft: String, + /// 导入/导出区的页内选择(M113:路径草稿 / 目标实例 / 格式 / 展开的那条冲突) + pub pack: super::super::packui::PackPageState, } impl Default for PageState { @@ -27,6 +29,7 @@ impl Default for PageState { selected: None, target: None, draft: String::new(), + pack: super::super::packui::PackPageState::default(), } } } diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index c82d84f..b20074b 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -230,6 +230,7 @@ fn can_install_needs_a_selection_and_no_blockers() { selected: None, target: None, draft: String::new(), + pack: super::super::packui::PackPageState::default(), }; // 没选中项目 ⇒ 不能装(也不该说「成功」) diff --git a/crates/app-iced/src/pages/content/packui.rs b/crates/app-iced/src/pages/content/packui.rs new file mode 100644 index 0000000..5dcb9e0 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui.rs @@ -0,0 +1,57 @@ +//! 整合包**导入 / 导出**区(M113 `PACKUI`):内容库页里「把我的包装进来」与「把我的实例打出去」。 +//! +//! ## 为什么长在内容库页上 +//! +//! 用户令:「下载页只负责下载整合包和Mod/光影」+「支持当前主流的整合包格式导入导出的功能」。 +//! 下载页(+内容库)是「拿内容进来」的地方,所以: +//! +//! - **导入**(本地包 → 实例):入口在这里,和「从站点拿整合包」摆在同一页; +//! - **导出**(实例 → 包):同一块,选目标实例 + 格式,预检之后再写盘。 +//! +//! ## 三种格式都是 core 的既有能力,本模块只做界面 +//! +//! | 格式 | 导入 | 导出 | 界面上的如实说明 | +//! |---|---|---|---| +//! | CurseForge `manifest.json` | 是(`files[]` 远程补全**未接通**) | 是 | 缺 CF 客户端 ⇒ 那些条目只能记成「没搞到」,逐条列出,**绝不猜 URL** | +//! | Modrinth `.mrpack` | 是 | 是 | —— | +//! | MultiMC / Prism 实例目录 | 是 | **否**(core 有意省略) | 文案明写「只能导入、不能导出」 | +//! +//! `.disabled` 是**路径后缀**(不是 `env.client=optional`)——这条在导出预检的逐条原因里出现, +//! 界面上不解释「可选依赖」,因为那不是同一件事。 +//! +//! ## 文件分工 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`message`] | 本区的意图(挂在 `content::Message::Pack` 下) | +//! | [`model`] | 视图模型(快照 → 要画的东西;纯决策可单测) | +//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) | +//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) | +//! +//! ## 本版明确的边界(报告里同样登记) +//! +//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、 +//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是 +//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。 +//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 / +//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」, +//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。 + +mod demo; +mod message; +mod model; +mod view; + +#[cfg(test)] +mod tests; + +pub use message::Message as PackMessage; +pub use model::{Pack, PackPageState}; + +/// 区入口(内容库页的正列调它)。 +pub(crate) use view::section; + +/// 出图夹具:按 `ULL_CONTENT_PACK` 选态(`importing` / `partial` / `conflict` / `export`)。 +pub(crate) fn demo_pack(lang: unlucky_i18n::Lang) -> (Pack, PackPageState) { + demo::pack(lang) +} diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs new file mode 100644 index 0000000..e3aaf5c --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -0,0 +1,224 @@ +//! 出图夹具(D30):`ULL_CONTENT_PACK` 选「同一页的哪一态」。 +//! +//! ## 为什么是环境变量 +//! +//! `pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;而 +//! `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。所以沿用本仓既有的取证开关范式 +//! (`ULL_START_ROUTE` / `ULL_CONTENT_KIND` / `ULL_CONTENT_STATE`): +//! +//! | 变量 | 取值 | 作用 | +//! |---|---|---| +//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `export` | 选导入导出区的哪一态(不设 = 只有入口) | +//! +//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`), +//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行, +//! 而不是「为了出图另写一套」。 + +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, +}; + +use crate::pages::content::Target; + +use super::model::{Pack, PackPageState}; + +/// 取证开关名。 +const ENV_PACK: &str = "ULL_CONTENT_PACK"; + +/// 两个实例(与内容库页的演示环境同一批,避免同一张图上出现两套实例名)。 +fn targets() -> Vec { + vec![ + Target { + id: "survival".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + }, + Target { + id: "modded".to_string(), + name: "Modded".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderKind::Forge, + }, + ] +} + +/// 按环境变量造一态(不设 = 只有入口,没有进度也没有报告)。 +pub(super) fn pack(lang: Lang) -> (Pack, PackPageState) { + let state = std::env::var(ENV_PACK).unwrap_or_default(); + pack_in(&state, lang) +} + +/// 按名字造一态(单测直接调它,不碰环境变量:并行测试里改 env 是竞态)。 +pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) { + let catalog = lang.catalog(); + let instance: Option = Some("survival".to_string()); + let mut page = PackPageState { + instance, + ..PackPageState::default() + }; + let mut import = ImportState::default(); + let mut export = ExportState::default(); + + match state { + "importing" => import.progress = Some(progress()), + "partial" => import.report = Some(report(ImportStatusView::Partial, false)), + "conflict" => { + import.report = Some(report(ImportStatusView::Partial, true)); + page.expanded = Some("config/sodium-options.json".to_string()); + } + "export" => { + page.export_open = true; + page.export_name = "Survival 1.20.1".to_string(); + export.preview = Some(preview()); + } + _ => {} + } + + let model = Pack::of(&import, &export, &page, &targets(), catalog); + (model, page) +} + +/// 导入中:第 2 阶段(解包覆盖内容),计数已知。 +fn progress() -> ImportProgressView { + ImportProgressView { + stage: ImportStageView::Extract, + done: 418, + total: 640, + current: Some("overrides/config/sodium-options.json".to_string()), + pack: "Better MC 1.2.0".to_string(), + } +} + +/// 一条落地文件。 +fn file( + path: &str, + action: FileActionView, + bytes: u64, +) -> unlucky_presenter::dto::ImportedFileView { + unlucky_presenter::dto::ImportedFileView { + path: path.to_string(), + action, + bytes, + } +} + +/// 报告(`conflicts` 决定造不造冲突那一块)。 +fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView { + let mut report = ImportReportView { + status, + format: Some(PackFormatView::Curseforge), + pack_name: Some("Better MC".to_string()), + pack_version: Some("1.2.0".to_string()), + game_version: Some("1.20.1".to_string()), + loader: Some("fabric-loader 0.15.11".to_string()), + version_id: Some("1.20.1-fabric0.15.11".to_string()), + 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("config/xaeroplus.cfg", FileActionView::KeptUser, 4_200), + ], + conflicts: Vec::new(), + missing: vec![ + MissingFileView { + path: Some("mods/create-1.2.3.jar".to_string()), + display: "mods/create-1.2.3.jar".to_string(), + reason: MissingReasonView::FetchFailed, + detail: "HTTP 404".to_string(), + }, + MissingFileView { + path: None, + display: "123456:789012".to_string(), + reason: MissingReasonView::NoSource, + detail: "CurseForge 文件端点未接通".to_string(), + }, + MissingFileView { + path: Some("mods/jei-1.20.1.jar".to_string()), + display: "mods/jei-1.20.1.jar".to_string(), + reason: MissingReasonView::Offline, + detail: String::new(), + }, + ], + rejected: vec!["org.lwjgl3:3.3.1".to_string()], + warnings: vec!["包内 `server-overrides/` 未解压(客户端导入)".to_string()], + error: None, + registered: Some("Survival".to_string()), + bytes_written: 3_400_000, + rolled_back: true, + }; + if conflicts { + report.conflicts = vec![ + conflict( + "config/sodium-options.json", + ConflictReasonView::UserModified, + ), + conflict("mods/xaeroplus.jar", ConflictReasonView::UserDeleted), + conflict("options.txt", ConflictReasonView::PreExisting), + ]; + } + report +} + +fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView { + ConflictFileView { + path: path.to_string(), + reason, + // 默认档永远是「保留我的」——夹具也一样,截图不许示范成默认覆盖 + choice: ConflictChoiceView::KeepUser, + pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(), + base_sha1: Some("3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()), + disk_sha1: Some("c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string()), + pack_size: 4_200, + } +} + +/// 导出预检:12 内联 / 1 跳过(包不完整)。 +fn preview() -> ExportPreviewView { + let mut files = vec![ExportFileView { + path: "mods/create-1.2.3.jar".to_string(), + class: ExportClassView::Inline, + reason: ExportReasonView::NoRemoteMatch, + bytes: 2_100_000, + disabled: false, + }]; + for index in 0..11 { + files.push(ExportFileView { + path: format!("mods/generated-{index:02}.jar"), + class: ExportClassView::Inline, + reason: ExportReasonView::NoRemoteMatch, + bytes: 512_000, + disabled: false, + }); + } + files.push(ExportFileView { + path: "mods/.cache/backup.jar".to_string(), + class: ExportClassView::Skipped, + reason: ExportReasonView::SymbolicLink, + bytes: 0, + disabled: false, + }); + + ExportPreviewView { + format: PackFormatView::Modrinth, + instance: "survival".to_string(), + name: "Survival 1.20.1".to_string(), + version: "1.0.0".to_string(), + target: PathBuf::from("C:/ULL/downloads/Survival-1.0.0.mrpack"), + files, + notes: vec![ + "CF 表达不了禁用位:`.disabled` 文件已内联".to_string(), + "离线目录:所有文件都证明不了可被远程替代,全部内联".to_string(), + ], + } +} diff --git a/crates/app-iced/src/pages/content/packui/message.rs b/crates/app-iced/src/pages/content/packui/message.rs new file mode 100644 index 0000000..62b8983 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/message.rs @@ -0,0 +1,57 @@ +//! 整合包导入/导出区的**自有**消息(M113 `PACKUI`)。 +//! +//! 与内容库页同一个范式(`pages/content/message.rs`):这一支挂在 +//! [`super::Message::Pack`] 下面,`pages::Message` 只留 `Content(..)` 一个投递口, +//! 于是「加一条导入/导出消息」不碰共享枚举的正文,也不碰 `app/update/page_messages.rs` +//! 那条**不许 `_ =>`** 的 match。 +//! +//! **一条纪律**(与内容库页同一句):这里的每个变体都是**意图**,不是状态变更。 +//! 拖入文件 ≠ 已经导入(证据是 `ImportProgress` / `ImportReport` 事件回流); +//! 点「导出」≠ 已经写了盘(证据是预检与 `ExportDone` 事件)。 + +use std::path::PathBuf; + +use unlucky_presenter::dto::{InstanceId, PackFormatView}; + +/// 导入/导出区的消息。 +#[derive(Debug, Clone)] +pub enum Message { + /// 拖入了一个文件或目录(由 `app/update.rs` 的窗口事件转发) + /// + /// 只**落成草稿并立刻探测**,不直接开跑:用户要能看到「这个包看起来像 X」再决定。 + Dropped(PathBuf), + /// 拖拽悬停状态变了(`true` = 有文件在窗口上方) + Hover(bool), + /// 路径输入框变了(**只改草稿**,不派指令) + PathChanged(String), + /// 提交导入(用当前草稿路径) + Import, + /// 取消正在跑的导入(阶段边界生效) + Cancel, + /// 关掉结果面板(报告 / 致命错误都走这一条) + Dismiss, + /// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**) + Overwrite, + /// 展开/收起某一条冲突的三方明细 + ToggleConflict(String), + /// 换目标实例(`None` = 按包名新建) + SetInstance(Option), + /// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字) + SetInstanceName(String), + /// 换导出格式 + SetFormat(PackFormatView), + /// 换导出包名 + SetExportName(String), + /// 换导出包版本 + SetExportVersion(String), + /// 换落盘路径(留空 = 让 host 给缺省) + SetOutput(String), + /// 跑预检(**不写盘**) + Precheck, + /// 确认写盘(预检已经有结果才可达) + Confirm, + /// 放弃这次导出(关掉预检面板,不写盘) + AbortExport, + /// 打开导出面板 + OpenExport, +} diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs new file mode 100644 index 0000000..8e1e4cb --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -0,0 +1,219 @@ +//! 导入/导出区的视图模型:**presenter 快照 + 页面选择 → 要画的东西**。 +//! +//! ## 分层(与内容库页、下载页同一条) +//! +//! | 数据源 | 谁转 | 用途 | +//! |---|---|---| +//! | `AppState::import` / `AppState::export` | [`Pack::of`] | 产品运行时的真页面 | +//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 | +//! +//! 页面自己**不留**业务状态:进度、报告、预检都在 presenter(`ImportState` / `ExportState`)。 +//! [`PackPageState`] 里只有「用户在这一屏上点了什么」——路径草稿、目标实例、格式、包名、 +//! 展开的那条冲突。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | 本文件 | 整块 [`Pack`] + 页内选择 [`PackPageState`] + 探测诊断 [`Probe`] | +//! | [`progress`] | 阶段 / 进度 → 下载页面板的入参(**唯一**的进度形状来源) | +//! | [`report`] | 导入结果:三态 + 容忍域逐条 + 致命块 | +//! | [`export`] | 导出:格式选择 + 预检(不写盘) | +//! +//! ## 容忍 / 致命在模型层就分开 +//! +//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`] +//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。 + +mod export; +mod progress; +mod report; + +pub use export::{format_label, Export, Preview}; +pub use progress::Running; +pub use report::{ConflictRow, Report, Row}; + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView}; + +use crate::pages::content::Target; + +/// 页面自留的选择(所有者在 `App::pack`,重建快照不该把它们冲掉)。 +#[derive(Debug, Clone, PartialEq)] +pub struct PackPageState { + /// 路径草稿(拖入或手输;提交后才进请求) + pub draft: String, + /// 有文件悬在窗口上方(拖拽高亮) + pub hover: bool, + /// 目标实例(`None` = 按包名新建) + pub instance: Option, + /// 新实例名 + pub instance_name: String, + /// 导出格式 + pub format: PackFormatView, + /// 导出包名 + pub export_name: String, + /// 导出包版本 + pub export_version: String, + /// 落盘路径草稿(空 = 让 host 给缺省) + pub output: String, + /// 导出面板开着吗 + pub export_open: bool, + /// 展开的那条冲突路径 + pub expanded: Option, +} + +impl Default for PackPageState { + fn default() -> Self { + Self { + draft: String::new(), + hover: false, + instance: None, + instance_name: String::new(), + // 导出的首选是 Modrinth:CF 的 `files[]` 只能写能证明 `projectID`/`fileID` 的项, + // 而离线目录证明不了任何一条 ⇒ 全内联 + 一堆「表达不了」的注记。mrpack 才是主路。 + format: PackFormatView::Modrinth, + export_name: String::new(), + export_version: "1.0.0".to_string(), + output: String::new(), + export_open: false, + expanded: None, + } + } +} + +/// 一个实例候选(导入目标与导出源共用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackTarget { + /// 实例 id + pub id: InstanceId, + /// 一行显示名(`Survival · 1.20.1 · Fabric`) + pub label: String, +} + +/// 探测失败时的具名诊断块。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Probe { + /// 最像哪个格式(`None` = 三个都不像) + pub looks_like: Option, + /// 「看起来像 X,但缺 Y」(或「没有任何已知格式认领」) + pub lead: String, + /// 包里实际看到的标记(host 已截断,只给代表性几条) + pub saw: Vec, + /// 兜底说明(core 原文:试过哪些格式) + pub detail: String, +} + +/// 整块导入/导出区要画的东西。 +#[derive(Debug, Clone, PartialEq)] +pub struct Pack { + /// 拖拽高亮 + pub hover: bool, + /// 路径草稿 + pub draft: String, + /// 入口说明(拖什么) + pub drop_hint: &'static str, + /// 没有系统文件框的原因(如实说,不许装作有) + pub no_dialog_note: &'static str, + /// 目标实例候选 + pub targets: Vec, + /// 选中的目标实例 + pub instance: Option, + /// 新实例名 + pub instance_name: String, + /// 正在导入(有它就画那块面板) + pub running: Option, + /// 探测失败诊断 + pub probe: Option, + /// 导入结果 + pub report: Option, + /// 导出块 + pub export: Export, +} + +impl Pack { + /// presenter 快照 + 页面选择 → 视图模型(**唯一的真数据入口**)。 + pub fn of( + import: &ImportState, + export: &ExportState, + page: &PackPageState, + targets: &[Target], + catalog: Catalog, + ) -> Self { + let candidates: Vec = targets + .iter() + .map(|target| PackTarget { + id: target.id.clone(), + label: format!( + "{} · {} · {}", + target.name, + target.mc_version, + crate::pages::content::loader_name(target.loader) + ), + }) + .collect(); + let instance = page + .instance + .clone() + .or_else(|| candidates.first().map(|target| target.id.clone())); + + Self { + hover: page.hover, + draft: page.draft.clone(), + drop_hint: catalog.text(Key::PackDropHint), + no_dialog_note: catalog.text(Key::PackNoDialog), + targets: candidates.clone(), + instance: instance.clone(), + instance_name: page.instance_name.clone(), + running: import + .progress + .as_ref() + .map(|progress| progress::running_of(progress, catalog)), + probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)), + report: import + .report + .as_ref() + .map(|report| report::report_of(report, page, catalog)), + export: export::export_of(export, page, candidates, catalog), + } + } + + /// 有没有东西可画(没有就整块收起来,不占版面)。 + pub fn is_idle(&self) -> bool { + self.running.is_none() + && self.probe.is_none() + && self.report.is_none() + && !self.export.open + && self.draft.is_empty() + } +} + +/// 探测诊断:把「缺哪个标记」摊开。 +/// +/// `saw` 为空时补一句「没有可识别的东西」——空列表直接画出来是一行空白,用户会以为是渲染坏了。 +fn probe_of(probe: &ProbeView, catalog: Catalog) -> Probe { + let lead = match probe.looks_like { + Some(format) => catalog.render( + Key::PackProbeLooksLike, + &[ + ("format", export::format_label(format, catalog)), + ("missing", &probe.missing.join(" · ")), + ], + ), + None => catalog.text(Key::PackProbeUnknown).to_owned(), + }; + let saw = if probe.saw.is_empty() { + vec![catalog.text(Key::PackProbeNothing).to_owned()] + } else { + probe.saw.clone() + }; + Probe { + looks_like: probe.looks_like, + lead, + saw, + detail: probe.detail.clone(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/content/packui/model/export.rs b/crates/app-iced/src/pages/content/packui/model/export.rs new file mode 100644 index 0000000..0131db3 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/export.rs @@ -0,0 +1,228 @@ +//! 导出:格式选择 + **预检**(不写盘)。 +//! +//! ## 预检与写盘是两次调用,同一套判据 +//! +//! [`Preview`] 只扫描分类、不写盘;确认之后才是真导出。任务书要的 +//! 「将内联 N 个文件 / 无法导出 M 项」正是 [`Preview::head`]。 +//! +//! 三分类的口径(与 core 一致,**没有第四类**): +//! +//! | 类 | 含义 | 预检里怎么呈现 | +//! |---|---|---| +//! | `Remote` | 能证明可被远程替代 | 一行计数 | +//! | `Inline` | 证明不了 ⇒ 原字节进 `overrides/` | 计数 + 字节 + 前几条样例 | +//! | `Skipped` | 压根没进包 | **逐条列出**(包不完整,不许合并成一句「已导出」) | +//! +//! `Skipped` 非零时 [`Preview::complete`] 为假,界面上会明确写「有文件不会进包」—— +//! 这是 M112 那句「不认识的 mod 不能丢、不能编」的用户侧落点:丢了就要看见丢在哪。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportState, + PackFormatView, +}; + +use super::{PackPageState, PackTarget, Row}; + +/// 预检里「内联文件样例」最多列几条(其余用一句「还有 N 个」收口)。 +const INLINE_SAMPLE: usize = 6; + +/// 预检块(**还没写盘**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preview { + /// 一句话主结论:`将内联 12 个文件 · 无法导出 0 项` + pub head: String, + /// 远程引用行 + pub remote_line: String, + /// 内联字节行 + pub inline_line: String, + /// 落盘路径行 + pub target_line: String, + /// 包会完整吗(`false` = 有文件压根没进包) + pub complete: bool, + /// 完整性那句话 + pub complete_line: String, + /// 无法导出的项(逐条) + pub skipped: Vec, + /// 内联文件样例 + pub inline_sample: Vec, + /// `还有 N 个文件…` + pub more_line: Option, + /// core 的 `notes`(已知损失) + pub notes: Vec, +} + +/// 导出块。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Export { + /// 面板开着吗 + pub open: bool, + /// 当前格式 + pub format: PackFormatView, + /// 格式人读名 + pub format_label: &'static str, + /// 可选的格式(只有两个:mrpack / CF) + pub formats: Vec<(PackFormatView, &'static str)>, + /// 目标实例候选 + pub targets: Vec, + /// 选中的实例 + pub instance: Option, + /// 包名 + pub name: String, + /// 版本 + pub version: String, + /// 落盘路径草稿(空 = 缺省) + pub output: String, + /// MultiMC/Prism 只导入不导出(如实写) + pub format_note: &'static str, + /// 预检在飞 + pub pending: bool, + /// 预检的进行中说明 + pub pending_line: &'static str, + /// 预检结果 + pub preview: Option, + /// 写盘完成行 + pub done: Option, + /// 失败原因 + pub failed: Option, +} + +/// 导出一块。 +pub(super) fn export_of( + state: &ExportState, + page: &PackPageState, + targets: Vec, + catalog: Catalog, +) -> Export { + let instance = page + .instance + .clone() + .or_else(|| targets.first().map(|target| target.id.clone())); + let formats = PackFormatView::EXPORTABLE + .iter() + .map(|format| (*format, format_label(*format, catalog))) + .collect(); + + Export { + open: page.export_open, + format: page.format, + format_label: format_label(page.format, catalog), + formats, + targets, + instance, + name: page.export_name.clone(), + version: page.export_version.clone(), + output: page.output.clone(), + format_note: catalog.text(Key::PackExportMultimcNote), + pending: state.pending, + pending_line: catalog.text(Key::PackExportPending), + preview: state + .preview + .as_ref() + .map(|preview| preview_of(preview, catalog)), + done: state.done.as_ref().map(|done| { + catalog.render( + Key::PackExportDone, + &[ + ("path", &done.path.display().to_string()), + ("bytes", &human_bytes(done.bytes)), + ], + ) + }), + failed: state.failed.clone(), + } +} + +pub(super) fn preview_of(preview: &ExportPreviewView, catalog: Catalog) -> Preview { + let skipped: Vec = preview + .files + .iter() + .filter(|file| file.class == ExportClassView::Skipped) + .map(|file| skipped_row(file, catalog)) + .collect(); + let inline: Vec<&ExportFileView> = preview + .files + .iter() + .filter(|file| file.class == ExportClassView::Inline) + .collect(); + let inline_sample = inline + .iter() + .take(INLINE_SAMPLE) + .map(|file| file.path.clone()) + .collect::>(); + let more_line = (inline.len() > inline_sample.len()).then(|| { + catalog.render( + Key::PackPreviewMore, + &[("count", &(inline.len() - inline_sample.len()).to_string())], + ) + }); + + Preview { + head: catalog.render( + Key::PackPreviewHead, + &[ + ("inline", &preview.inline_count().to_string()), + ("skipped", &preview.skipped_count().to_string()), + ], + ), + remote_line: catalog.render( + Key::PackPreviewRemote, + &[("count", &preview.remote_count().to_string())], + ), + inline_line: catalog.render( + Key::PackPreviewInlineBytes, + &[("bytes", &human_bytes(preview.inline_bytes()))], + ), + target_line: catalog.render( + Key::PackPreviewTarget, + &[("path", &preview.target.display().to_string())], + ), + complete: preview.is_complete(), + complete_line: catalog + .text(if preview.is_complete() { + Key::PackPreviewComplete + } else { + Key::PackPreviewIncomplete + }) + .to_owned(), + skipped, + inline_sample, + more_line, + notes: preview.notes.clone(), + } +} + +fn skipped_row(file: &ExportFileView, catalog: Catalog) -> Row { + Row { + path: file.path.clone(), + note: reason_label(file.reason, catalog).to_owned(), + } +} + +/// 导出归类原因 → 人读(每条都写得能自己解释,不靠图标猜)。 +#[must_use] +pub fn reason_label(reason: ExportReasonView, catalog: Catalog) -> &'static str { + match reason { + ExportReasonView::ModrinthSha1 => catalog.text(Key::PackReasonModrinthSha1), + ExportReasonView::CurseForgeFingerprint => catalog.text(Key::PackReasonCurseFingerprint), + ExportReasonView::NoRemoteMatch => catalog.text(Key::PackReasonNoRemoteMatch), + ExportReasonView::CurseOnlyMatch => catalog.text(Key::PackReasonCurseOnly), + ExportReasonView::ModrinthOnlyMatch => catalog.text(Key::PackReasonModrinthOnly), + ExportReasonView::MissingSha512 => catalog.text(Key::PackReasonMissingSha512), + ExportReasonView::MissingCurseIds => catalog.text(Key::PackReasonMissingCurseIds), + ExportReasonView::DisabledNotExpressible => catalog.text(Key::PackReasonDisabled), + ExportReasonView::SymbolicLink => catalog.text(Key::PackReasonSymlink), + ExportReasonView::UnsafePath => catalog.text(Key::PackReasonUnsafePath), + ExportReasonView::Unreadable => catalog.text(Key::PackReasonUnreadable), + } +} + +/// 格式人读名。 +#[must_use] +pub fn format_label(format: PackFormatView, catalog: Catalog) -> &'static str { + match format { + PackFormatView::Curseforge => catalog.text(Key::PackFormatCurseforge), + PackFormatView::Modrinth => catalog.text(Key::PackFormatModrinth), + PackFormatView::Multimc => catalog.text(Key::PackFormatMultimc), + } +} diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs new file mode 100644 index 0000000..5b9ebaa --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -0,0 +1,114 @@ +//! 阶段与进度:**把导入进度映射成下载页那块面板的入参**(不另画一套进度 UI)。 +//! +//! ## 任务书第 5 条怎么落 +//! +//! 「沿用既有安装面板组件与状态机,禁止再写第二套进度 UI」—— +//! +//! 1. 导入的 `ImportStageView` / 计数 / 当前路径先映射成 [`InstallProgressView`](本文件 +//! [`panel_progress`]); +//! 2. 画法交给 `pages::download::install_panel`(M109 已经打过薄包装,同一个函数、同一套几何 +//! 与文案); +//! 3. 面板只认四个粗阶段(规划 / 下载 / 安装 / 登记),任务书要的「解包 / overrides / 补文件」 +//! 三档由**一行步骤说明**([`stage_line`])承载。 +//! +//! 第 3 点不是第二套进度 UI:多出来的只是一句文字,进度条、计数行、取消按钮仍然只有那一份。 +//! 为什么不再造一条按 `ImportStageView` 五档划分的进度条:那正是「第二套」的定义,而且两块条 +//! 同时出现时用户不知道该信哪一条。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + ImportProgressView, ImportStageView, InstallProgressView, InstallStage, +}; + +use crate::pages::download::Download; + +/// 抛进面板的进度行。 +#[derive(Debug, Clone, PartialEq)] +pub struct Running { + /// 包名(面板标题那一行) + pub pack: String, + /// 细档位说明(`步骤 2/5 · 解包覆盖内容`) + pub stage_line: String, + /// 交给下载页那块面板的进度(**唯一**的进度形状来源) + pub panel: Download, +} + +/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。 +#[must_use] +pub fn stage_line(stage: ImportStageView, catalog: Catalog) -> String { + let total = ImportStageView::ALL.len(); + let index = ImportStageView::ALL + .iter() + .position(|item| *item == stage) + .map_or(1, |at| at + 1); + catalog.render( + Key::PackStageLine, + &[ + ("index", &index.to_string()), + ("total", &total.to_string()), + ("stage", catalog.text(stage_key(stage))), + ], + ) +} + +/// 阶段 → 文案 key。 +#[must_use] +pub const fn stage_key(stage: ImportStageView) -> Key { + match stage { + ImportStageView::Probe => Key::PackStageProbe, + ImportStageView::Extract => Key::PackStageExtract, + ImportStageView::Fetch => Key::PackStageFetch, + ImportStageView::Commit => Key::PackStageCommit, + ImportStageView::Register => Key::PackStageRegister, + } +} + +/// 导入阶段 → 安装面板的粗阶段(**唯一映射处**)。 +/// +/// `Extract` / `Commit` 都落到 `Installing`:对那块面板而言它们都是「在写盘」,区别由 +/// [`stage_line`] 说给用户。 +#[must_use] +pub const fn panel_stage(stage: ImportStageView) -> InstallStage { + match stage { + ImportStageView::Probe => InstallStage::Planning, + ImportStageView::Extract | ImportStageView::Commit => InstallStage::Installing, + ImportStageView::Fetch => InstallStage::Downloading, + ImportStageView::Register => InstallStage::Registering, + } +} + +/// 导入进度 → 下载页面板的入参。 +/// +/// `version` / `instance` 都填包名:面板第一行显示的就是「这次在装什么」, +/// 而导入的对象是包,不是某个版本 id。 +#[must_use] +pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { + InstallProgressView { + version: progress.pack.clone(), + instance: progress.pack.clone(), + loader: String::new(), + stage: panel_stage(progress.stage), + files_done: u64::from(progress.done), + files_total: u64::from(progress.total), + bytes_done: 0, + bytes_total: 0, + speed_bps: 0, + eta_secs: 0, + message: progress.current.clone().unwrap_or_default(), + } +} + +/// 进度事件 → 面板要的视图模型。 +pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running { + 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, + )), + ..Download::loading() + }, + } +} diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs new file mode 100644 index 0000000..493fec2 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -0,0 +1,242 @@ +//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。 +//! +//! ## 为什么容忍域与致命域连字段类型都不同 +//! +//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。 +//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以: +//! +//! | 域 | 类型 | 画法 | +//! |---|---|---| +//! | 容忍(缺文件 / 被拒 component / 注记) | [`Row`](路径 + 原因) | 中性容器 + `Info` 图标 + 逐条列表 | +//! | 致命(已回滚) | [`Fatal`](大类 + 原文 + 建议 + 稳定码) | `ErrorContainer` + `Error` 图标 + 标题换措辞 | +//! +//! 一个 `Option` 与一个 `Vec` 之间没有「误合并」的写法,这比任何样式约定都硬。 + +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, + /// 磁盘 SHA-1(`None` = 文件不在) + pub disk_sha1: Option, + /// 展开中(展开才画三方哈希那行) + 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, +} + +/// 导入结果块。 +#[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, + /// 缺文件标题 + pub missing_title: Option, + /// 缺文件逐条(容忍域) + pub missing: Vec, + /// 冲突标题 + pub conflicts_title: Option, + /// 冲突逐条 + pub conflicts: Vec, + /// 被拒 component 逐条 + pub rejected: Vec, + /// 注记逐条 + pub warnings: Vec, + /// 能不能给「以包为准重新导入」(有冲突、且实例可用时才有意义) + pub can_overwrite: bool, + /// 「两者都留改名」为何不支持(有冲突时如实写) + pub overwrite_note: Option, +} + +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: report + .rejected + .iter() + .map(|component| Row { + path: component.clone(), + note: catalog.text(Key::PackReportRejectedNote).to_owned(), + }) + .collect(), + warnings: report.warnings.clone(), + can_overwrite: !report.conflicts.is_empty() && report.status == ImportStatusView::Partial, + 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 = 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() +} diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs new file mode 100644 index 0000000..74edfd9 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -0,0 +1,296 @@ +//! 导入/导出区视图模型的守门(M113):阶段映射、预检计数、三方哈希、容忍/致命分野。 +//! +//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、 +//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 + +use std::path::PathBuf; + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, + ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView, + ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView, +}; + +use super::progress::{panel_progress, panel_stage, stage_line}; +use super::report::report_of; +use super::report::short_sha; +use super::{Pack, PackPageState}; + +fn catalog() -> unlucky_i18n::Catalog { + Lang::En.catalog() +} + +#[test] +fn stage_line_counts_the_five_steps_in_order() { + let expected = [ + (ImportStageView::Probe, "1/5"), + (ImportStageView::Extract, "2/5"), + (ImportStageView::Fetch, "3/5"), + (ImportStageView::Commit, "4/5"), + (ImportStageView::Register, "5/5"), + ]; + for (stage, position) in expected { + let line = stage_line(stage, catalog()); + assert!(line.contains(position), "{stage:?} 的步号不对:{line}"); + } + // 阶段名要真的是「那一档」,不能五个阶段都显示同一句 + let lines: Vec = ImportStageView::ALL + .iter() + .map(|stage| stage_line(*stage, catalog())) + .collect(); + for (index, line) in lines.iter().enumerate() { + for other in lines.iter().skip(index + 1) { + assert_ne!(line, other, "两个阶段的说明撞了:{line}"); + } + } +} + +#[test] +fn no_progress_stage_maps_to_a_terminal_install_stage() { + // 进度面板只该画「在跑」的形态:映射到 Done/Failed/Cancelled 会让面板提前画成收尾/失败 + for stage in ImportStageView::ALL { + let mapped = panel_stage(stage); + assert!( + !mapped.is_terminal(), + "{stage:?} 落到了终态 {mapped:?}(面板会画成收尾)" + ); + } +} + +#[test] +fn panel_progress_carries_the_counts_and_the_pack_name() { + let progress = ImportProgressView { + stage: ImportStageView::Fetch, + done: 41, + total: 90, + current: Some("mods/sodium.jar".to_string()), + pack: "Better MC".to_string(), + }; + let panel = panel_progress(&progress); + assert_eq!(panel.files_done, 41); + assert_eq!(panel.files_total, 90); + assert_eq!(panel.stage, InstallStage::Downloading); + // 面板第一行显示的就是包名(导入的对象是包,不是某个版本 id) + assert_eq!(panel.version, "Better MC"); +} + +#[test] +fn unknown_counts_keep_the_bar_unknown() { + // total == 0 时不允许画确定态进度条(那是「静止的假进度」的来源) + let progress = ImportProgressView { + stage: ImportStageView::Probe, + done: 0, + total: 0, + current: None, + pack: "Pack".to_string(), + }; + let panel = panel_progress(&progress); + assert_eq!(panel.files_total, 0); + assert_eq!(panel.speed_bps, 0, "导入没有字节速率,不许编"); +} + +#[test] +fn sha_is_shortened_to_eight_chars() { + assert_eq!( + short_sha("9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5"), + "9f2c41ab" + ); + assert_eq!(short_sha("abc"), "abc"); + assert_eq!(short_sha(""), ""); +} + +fn preview(files: Vec) -> ExportPreviewView { + ExportPreviewView { + format: PackFormatView::Modrinth, + instance: "survival".to_string(), + name: "Survival".to_string(), + version: "1.0.0".to_string(), + target: PathBuf::from("C:/tmp/out.mrpack"), + files, + notes: Vec::new(), + } +} + +fn export_file( + path: &str, + class: ExportClassView, + reason: unlucky_presenter::dto::ExportReasonView, +) -> ExportFileView { + ExportFileView { + path: path.to_string(), + class, + reason, + bytes: 1000, + disabled: false, + } +} + +#[test] +fn preview_counts_match_the_three_classes() { + use unlucky_presenter::dto::ExportReasonView; + let view = preview(vec![ + export_file( + "a.jar", + ExportClassView::Remote, + ExportReasonView::ModrinthSha1, + ), + export_file( + "b.jar", + ExportClassView::Inline, + ExportReasonView::NoRemoteMatch, + ), + export_file( + "c.jar", + ExportClassView::Inline, + ExportReasonView::MissingSha512, + ), + export_file( + "d.jar", + ExportClassView::Skipped, + ExportReasonView::SymbolicLink, + ), + ]); + assert_eq!(view.remote_count(), 1); + assert_eq!(view.inline_count(), 2); + assert_eq!(view.skipped_count(), 1); + assert_eq!(view.inline_bytes(), 2000); + assert!(!view.is_complete(), "有跳过项 ⇒ 包不完整"); +} + +#[test] +fn the_head_line_carries_the_two_numbers_the_task_asks_for() { + use unlucky_presenter::dto::ExportReasonView; + let view = preview(vec![ + export_file( + "b.jar", + ExportClassView::Inline, + ExportReasonView::NoRemoteMatch, + ), + export_file( + "c.jar", + ExportClassView::Inline, + ExportReasonView::MissingSha512, + ), + export_file( + "d.jar", + ExportClassView::Skipped, + ExportReasonView::UnsafePath, + ), + ]); + let block = super::export::preview_of(&view, catalog()); + // 「将内联 N 个文件 · 无法导出 M 项」两个数字必须来自同一份 files,不许另算 + assert!( + block.head.contains('2'), + "内联数没进 headline:{}", + block.head + ); + assert!( + block.head.contains('1'), + "跳过数没进 headline:{}", + block.head + ); + assert_eq!(block.skipped.len(), 1); + assert!(!block.complete); +} + +#[test] +fn report_keeps_tolerable_and_fatal_apart() { + let report = ImportReportView { + status: ImportStatusView::Failed, + format: Some(PackFormatView::Curseforge), + pack_name: Some("Broken".to_string()), + pack_version: None, + game_version: None, + loader: None, + version_id: None, + target: PathBuf::from("C:/tmp"), + files: Vec::new(), + conflicts: Vec::new(), + missing: vec![MissingFileView { + path: Some("mods/a.jar".to_string()), + display: "mods/a.jar".to_string(), + reason: MissingReasonView::Offline, + detail: String::new(), + }], + rejected: Vec::new(), + warnings: Vec::new(), + error: Some(ImportErrorView { + code: "pack_traversal".to_string(), + label: "路径穿越".to_string(), + message: "../etc/passwd".to_string(), + hint: Some("换一个来源".to_string()), + }), + registered: None, + bytes_written: 0, + rolled_back: true, + }; + let block = report_of(&report, &PackPageState::default(), catalog()); + assert!(block.fatal.is_some(), "致命块必须单独存在"); + assert_eq!(block.missing.len(), 1, "容忍域仍要逐条列出"); + assert!(!block.installed(), "Failed 不是「装上了」"); + assert!(!block.can_overwrite, "没有冲突时不给翻档动作"); +} + +#[test] +fn conflicts_default_to_keeping_the_user_copy() { + let report = 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: PathBuf::from("C:/tmp"), + files: Vec::new(), + conflicts: vec![ConflictFileView { + path: "config/x.json".to_string(), + reason: ConflictReasonView::UserModified, + choice: ConflictChoiceView::KeepUser, + pack_sha1: "fixture".to_string(), + base_sha1: None, + disk_sha1: Some("disk".to_string()), + pack_size: 12, + }], + missing: Vec::new(), + rejected: Vec::new(), + warnings: Vec::new(), + error: None, + registered: None, + bytes_written: 0, + rolled_back: true, + }; + let page = PackPageState::default(); + let block = report_of(&report, &page, catalog()); + assert_eq!(block.conflicts.len(), 1); + assert!(!block.conflicts[0].expanded, "默认收起"); + assert_eq!( + block.conflicts[0].choice, + catalog().text(unlucky_i18n::Key::PackChoiceKeepUser), + "默认档必须是「保留我的」" + ); + assert!(block.can_overwrite, "有冲突要能整体翻档"); + assert!(block.overwrite_note.is_some(), "不支持「两者都留」要如实写"); + + // 展开的那一条才画三方哈希 + let expanded = PackPageState { + expanded: Some("config/x.json".to_string()), + ..PackPageState::default() + }; + let block = report_of(&report, &expanded, catalog()); + assert!(block.conflicts[0].expanded); +} + +#[test] +fn an_idle_pack_has_nothing_to_draw_but_the_entry() { + let import = unlucky_presenter::dto::ImportState::default(); + let export = unlucky_presenter::dto::ExportState::default(); + let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog()); + assert!(model.is_idle()); + assert!(model.running.is_none()); + assert!(model.report.is_none()); + // 入口说明永远在(用户得知道可以拖什么、为什么没有「选择文件」按钮) + assert!(!model.drop_hint.is_empty()); + assert!(!model.no_dialog_note.is_empty()); +} diff --git a/crates/app-iced/src/pages/content/packui/tests.rs b/crates/app-iced/src/pages/content/packui/tests.rs new file mode 100644 index 0000000..0cfbb94 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/tests.rs @@ -0,0 +1,104 @@ +//! 导入/导出区的守门(M113):四种出图态都装得满、入口文案都在。 +//! +//! 出图(D30)靠这几条夹具:任何一态装不满,截图就会是一块空容器,而不是「有话说」的界面。 +//! 视图模型的判定在 `model/tests.rs`,这里只管**夹具本身**。 + +use unlucky_i18n::Lang; + +use super::demo::pack_in; +use super::model::Pack; + +/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。 +const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"]; + +#[test] +fn every_screenshot_state_fills_something() { + for state in STATES { + let (model, _page) = pack_in(state, Lang::En); + assert!( + !model.is_idle(), + "{state} 这一态什么都没画(截图会是一块空容器)" + ); + assert_eq!(model.targets.len(), 2, "{state} 的实例候选不足"); + assert!(!model.drop_hint.is_empty()); + assert!( + !model.no_dialog_note.is_empty(), + "没有系统文件框这件事必须写在界面上" + ); + } +} + +#[test] +fn importing_shows_the_shared_install_panel() { + let (model, _page) = pack_in("importing", Lang::En); + let running = model.running.expect("导入中要有进度"); + // 进度形状来自下载页那块面板(`Download.install`),不是本区另造的 + assert!( + running.panel.install.is_some(), + "进度必须走下载页的安装面板视图模型" + ); + assert!( + running.stage_line.contains("2/5"), + "解包阶段是第 2/5 步:{}", + running.stage_line + ); + assert!(model.report.is_none(), "导入中不该同时有结果"); +} + +#[test] +fn partial_keeps_the_tolerable_rows_one_by_one() { + let (model, _page) = pack_in("partial", Lang::En); + let report = model.report.expect("部分成功要有报告"); + assert!(report.installed(), "Partial 仍是「装上了」"); + assert!(report.fatal.is_none(), "部分成功不是致命失败"); + assert_eq!(report.missing.len(), 3, "缺文件要逐条列出"); + assert!(report.missing_title.is_some()); + assert!(!report.rejected.is_empty(), "被拒 component 也要逐条"); +} + +#[test] +fn conflict_state_defaults_to_keeping_the_user_copy_and_expands_one_row() { + let (model, page) = pack_in("conflict", Lang::En); + let report = model.report.expect("冲突态要有报告"); + assert_eq!(report.conflicts.len(), 3); + assert!( + report.conflicts.iter().all(|row| !row.expanded) + || report.conflicts.iter().filter(|row| row.expanded).count() == 1, + "只展开用户点过的那一条" + ); + assert_eq!( + report.conflicts.iter().filter(|row| row.expanded).count(), + 1, + "夹具要展开一条,截图才看得见三方哈希" + ); + assert!(page.expanded.is_some()); + assert!(report.can_overwrite); + assert!(report.overwrite_note.is_some()); +} + +#[test] +fn export_state_has_a_preview_but_has_not_written_anything() { + let (model, _page) = pack_in("export", Lang::En); + assert!(model.export.open); + let preview = model.export.preview.expect("预检要有结果"); + assert!( + preview.head.contains('2'), + "内联数要进主结论:{}", + preview.head + ); + assert!(!preview.skipped.is_empty(), "无法导出的项要逐条"); + assert!(!preview.complete, "有跳过项 ⇒ 包不完整"); + assert!(!preview.notes.is_empty(), "已知损失要列出来"); + assert!( + model.export.done.is_none(), + "预检阶段不许出现「已写出」(那是写盘之后的事)" + ); +} + +#[test] +fn default_state_offers_the_entry_with_nothing_else() { + let (model, page): (Pack, _) = pack_in("", Lang::En); + assert!(model.is_idle()); + assert!(!model.export.open); + assert!(page.draft.is_empty()); +} diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs new file mode 100644 index 0000000..2db4570 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -0,0 +1,233 @@ +//! 导入/导出区的画法:入口(拖拽 + 路径)→ 进度(**下载页那块面板**)→ 结果 → 导出预检。 +//! +//! ## 版面 +//! +//! ```text +//! ┌ 导入整合包 ──────────────────────────────────┐ +//! │ ┌ 拖拽区(hover 时变主色描边) ─────────────┐ │ +//! │ └ 路径输入框 ──────────┘ [导入] │ │ +//! │ [装到实例:chips] [实例名] │ │ +//! ├ 进度:步骤 2/5 · 解包覆盖内容 │ +//! │ <下载页的安装面板:进度条 + 计数 + 取消> │ +//! ├ 探测失败:看起来像 X,但缺 Y │ +//! ├ 结果:三态标题 + 缺文件逐条 + 冲突逐条 + 致命块 │ +//! └ 导出:格式 chips + 预检(将内联 N / 无法导出 M)│ +//! ``` +//! +//! ## 三处「视觉可区分」不是靠颜色一种通道 +//! +//! | 域 | 图标 | 颜色角色 | 容器 | 措辞 | +//! |---|---|---|---|---| +//! | 容忍(缺文件 / 被拒 component) | `Warn` | `Tertiary` | 无底色 | 「可容忍」 | +//! | 致命(已回滚) | `Error` | `Error` | `ErrorContainer` | 「已回滚 —— 零残留」 | +//! | 成功 | `Check` | `Primary` | 无底色 | 「已导入」 | +//! +//! 关掉颜色也还认得出(图标 + 措辞),这是任务书「必须视觉可区分」的落点。 +//! +//! ## 进度只有一份 +//! +//! 进行中的进度**整块交给** `pages::download::install_panel`(同一个函数、同一套几何与文案); +//! 本区只额外画一行 [`Running::stage_line`] 补足「解包 / overrides / 补文件」三档的粒度。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | 本文件 | 区入口 + 拖拽区 + 路径输入 + 目标实例 | +//! | [`parts`] | 三个共用小件(底色容器 / 容忍标题 / 逐条列表) | +//! | [`progress`] | 进度块(复用面板)与探测失败块 | +//! | [`report`] | 结果块:三态 + 冲突 + 致命 | +//! | [`export`] | 导出块与预检块 | + +mod export; +mod parts; +mod progress; +mod report; + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::divider; +use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use self::export::export_block; +use self::progress::{probe_block, running_block}; +use self::report::report_block; +use super::model::Pack; +use super::PackMessage; + +/// 内容库意图 → 页面消息(唯一的一层包装)。 +pub(super) fn intent(message: PackMessage) -> PageMessage { + PageMessage::Content(super::super::Message::Pack(message)) +} + +/// 整块(导入入口 + 进度 + 结果 + 导出)。 +pub(crate) fn section<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Pack, +) -> Element<'a, PageMessage> { + let mut block = column![entry(ui, catalog, model)] + .spacing(ui.space(Space::X3)) + .width(Length::Fill); + + 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)); + block = block.push(report_block(ui, catalog, report)); + } + block = block.push(divider::full(ui)); + block = block.push(export_block(ui, catalog, &model.export)); + + container(block).width(Length::Fill).into() +} + +/// 入口:拖拽区 + 路径输入 + 目标实例。 +fn entry<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> { + let zone = container( + column![ + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + if model.hover { + catalog.text(Key::PackDropActive) + } else { + model.drop_hint + }, + ui.color(if model.hover { + Role::Primary + } else { + Role::OnSurface + }), + ), + // 没有系统文件框这件事**写在界面上**:不能让用户点了半天「选择文件」找不到按钮 + frame::text_role( + ui, + TypeRole::BodySmall, + false, + model.no_dialog_note, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ) + .width(Length::Fill) + .padding(ui.space(Space::X4)) + .style(move |_theme| container::Style { + border: Border { + color: ui.color(if model.hover { + Role::Primary + } else { + Role::Outline + }), + width: 1.0, + radius: ui.radius_dp(ShapeScale::Large), + }, + background: model + .hover + .then(|| Background::Color(ui.color(Role::PrimaryContainer))), + ..container::Style::default() + }); + + let field = text_field( + ui, + Field::new(model.draft.as_str()) + .label(catalog.text(Key::PackPathLabel)) + .placeholder(catalog.text(Key::PackPathPlaceholder)) + .leading(IconKind::Folder), + move |text| intent(PackMessage::PathChanged(text)), + ); + let submit = buttons::filled( + ui, + Button::new( + catalog.text(Key::PackImportAction), + intent(PackMessage::Import), + ) + .leading(IconKind::Folder), + ); + + let mut block = column![ + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackImportTitle), + ui.color(Role::OnSurface), + ), + zone, + row![field, submit] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::End) + .width(Length::Fill), + targets(ui, catalog, model), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + if model.instance.is_none() { + block = block.push(text_field( + ui, + Field::new(model.instance_name.as_str()).label(catalog.text(Key::PackInstanceName)), + move |text| intent(PackMessage::SetInstanceName(text)), + )); + } + + block.into() +} + +/// 目标实例那一行(chips + 「新建实例」)。 +fn targets<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> { + let mut chips: Vec> = Vec::new(); + for target in &model.targets { + chips.push(chip( + ui, + Chip::filter( + target.label.as_str(), + model.instance.as_deref() == Some(target.id.as_str()), + intent(PackMessage::SetInstance(Some(target.id.clone()))), + ), + )); + } + chips.push(chip( + ui, + Chip::filter( + catalog.text(Key::PackTargetNew), + model.instance.is_none(), + intent(PackMessage::SetInstance(None)), + ), + )); + + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackTargetLabel), + ui.color(Role::OnSurfaceVariant), + ), + row(chips).spacing(ui.space(Space::X2)).wrap(), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs new file mode 100644 index 0000000..7c14a3e --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -0,0 +1,274 @@ +//! 导出块与预检块:格式选择 → **预检**(不写盘)→ 确认写盘。 + +use iced::widget::{column, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::{Export, Preview}; +use super::super::PackMessage; +use super::intent; +use super::parts::{rows_block, tolerable_title}; + +/// 导出块。 +pub(super) fn export_block<'a>( + ui: &'a Ui, + catalog: Catalog, + export: &'a Export, +) -> Element<'a, PageMessage> { + let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + if !export.open { + block = block.push( + row![ + buttons::outlined( + ui, + Button::new( + catalog.text(Key::PackExportOpen), + intent(PackMessage::OpenExport) + ) + .leading(IconKind::Folder), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.format_note, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ); + } else { + block = block.push(frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackExportTitle), + ui.color(Role::OnSurface), + )); + block = block.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackExportFormat), + ui.color(Role::OnSurfaceVariant), + )); + block = block.push( + row(export + .formats + .iter() + .map(|(format, label)| { + chip( + ui, + Chip::filter( + label, + export.format == *format, + intent(PackMessage::SetFormat(*format)), + ), + ) + }) + .collect::>()) + .spacing(ui.space(Space::X2)), + ); + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.format_note, + ui.color(Role::OnSurfaceVariant), + )); + block = block.push(text_field( + ui, + Field::new(export.name.as_str()).label(catalog.text(Key::PackExportName)), + move |text| intent(PackMessage::SetExportName(text)), + )); + block = block.push(text_field( + ui, + Field::new(export.version.as_str()).label(catalog.text(Key::PackExportVersion)), + move |text| intent(PackMessage::SetExportVersion(text)), + )); + block = block.push(text_field( + ui, + Field::new(export.output.as_str()).label(catalog.text(Key::PackExportOutput)), + move |text| intent(PackMessage::SetOutput(text)), + )); + block = block.push( + row![ + buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportPrecheck), + intent(PackMessage::Precheck), + ) + .leading(IconKind::Search), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::PackExportAbort), + intent(PackMessage::AbortExport) + ), + ), + ] + .spacing(ui.space(Space::X2)), + ); + if export.pending { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.pending_line, + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(preview) = &export.preview { + block = block.push(preview_block(ui, catalog, preview)); + block = block.push(buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportConfirm), + intent(PackMessage::Confirm), + ), + )); + } + } + + if let Some(done) = &export.done { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + done.as_str(), + ui.color(Role::Primary), + )); + } + if let Some(failed) = &export.failed { + block = block.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(Key::PackExportFailed, &[("reason", failed)]), + ui.color(Role::Error), + )); + } + + block.into() +} + +/// 预检块:`将内联 N / 无法导出 M` + 逐条跳过项 + 已知损失。 +pub(super) fn preview_block<'a>( + ui: &'a Ui, + catalog: Catalog, + preview: &'a Preview, +) -> Element<'a, PageMessage> { + let mut block = column![ + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + preview.head.as_str(), + ui.color(if preview.complete { + Role::Primary + } else { + Role::Tertiary + }), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.complete_line.as_str(), + ui.color(if preview.complete { + Role::OnSurfaceVariant + } else { + Role::Tertiary + }), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.remote_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.inline_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.target_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if !preview.skipped.is_empty() { + block = block.push(tolerable_title( + ui, + catalog, + catalog.text(Key::PackPreviewSkippedTitle), + )); + block = block.push(rows_block(ui, catalog, &preview.skipped)); + } + + for path in &preview.inline_sample { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + path.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(more) = &preview.more_line { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + more.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + if !preview.notes.is_empty() { + block = block.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackPreviewNotes), + ui.color(Role::OnSurfaceVariant), + )); + for note in &preview.notes { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + } + + block.into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/parts.rs b/crates/app-iced/src/pages/content/packui/view/parts.rs new file mode 100644 index 0000000..bfd6ba2 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/parts.rs @@ -0,0 +1,99 @@ +//! 共用小件:带底色的容器、容忍域的标题、逐条列表。 +//! +//! 三个都在两个以上地方用到,所以单独一个文件——不是为了行数好看,是因为 +//! 「容忍域长什么样」只有一处定义,改颜色/图标才不用找第二个副本。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::Catalog; + +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::Row; + +/// 带底色的容器(探测块与致命块共用;颜色由调用方给)。 +pub(super) fn marker_box<'a>( + ui: &'a Ui, + role: Role, + content: impl Into>, +) -> Element<'a, PageMessage> { + let role = ui.color(role); + container(content.into()) + .width(Length::Fill) + .padding(ui.space(Space::X3)) + .style(move |_theme| container::Style { + background: Some(Background::Color(role)), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +pub(super) fn tolerable_title<'a>( + ui: &'a Ui, + catalog: Catalog, + title: &'a str, +) -> Element<'a, PageMessage> { + let _ = catalog; + row![ + frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::LabelLarge, + true, + title, + ui.color(Role::Tertiary), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +pub(super) fn rows_block<'a>( + ui: &'a Ui, + catalog: Catalog, + rows: &'a [Row], +) -> Element<'a, PageMessage> { + let _ = catalog; + column( + rows.iter() + .map(|row| { + row![ + frame::text_role( + ui, + TypeRole::BodySmall, + false, + row.path.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + row.note.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into() + }) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/progress.rs b/crates/app-iced/src/pages/content/packui/view/progress.rs new file mode 100644 index 0000000..c326ce4 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/progress.rs @@ -0,0 +1,95 @@ +//! 进度块(**整块复用下载页的安装面板**)与探测失败块。 + +use iced::widget::{column, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::download; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::{Probe, Running}; +use super::super::PackMessage; +use super::intent; +use super::parts::marker_box; + +/// 进行中:一行步骤说明 + **下载页那块面板**。 +pub(super) fn running_block<'a>( + ui: &'a Ui, + catalog: Catalog, + running: &'a Running, +) -> Element<'a, PageMessage> { + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + running.stage_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + // 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。 + download::install_panel(ui, catalog, &running.panel), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 探测失败:把「缺哪个标记」摊开。 +pub(super) fn probe_block<'a>( + ui: &'a Ui, + catalog: Catalog, + probe: &'a Probe, +) -> Element<'a, PageMessage> { + let mut block = column![ + row![ + frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackProbeTitle), + ui.color(Role::Tertiary), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + probe.lead.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(Key::PackProbeSaw, &[("saw", &probe.saw.join(" · "))]), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + probe.detail.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + block = block.push(buttons::text( + ui, + Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)), + )); + marker_box(ui, Role::TertiaryContainer, block) +} diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs new file mode 100644 index 0000000..cfdf65a --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -0,0 +1,268 @@ +//! 结果块:三态标题 + 容忍域逐条 + 冲突逐条 + 致命块。 + +use iced::widget::{column, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +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}; + +/// 结果块。 +pub(super) fn report_block<'a>( + ui: &'a Ui, + catalog: Catalog, + report: &'a Report, +) -> Element<'a, PageMessage> { + let mut block = column![ + row![ + frame::glyph( + status_icon(report), + ui.color(status_role(report)), + frame::ICON_PX + ), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + report.title.as_str(), + ui.color(status_role(report)), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + report.subtitle.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + report.written_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if let Some(fatal) = &report.fatal { + block = block.push(marker_box( + ui, + Role::ErrorContainer, + column![ + frame::text_role( + ui, + TypeRole::LabelLarge, + true, + fatal.title.as_str(), + ui.color(Role::OnErrorContainer), + ), + frame::text_role_owned( + ui, + TypeRole::BodyMedium, + false, + format!("{} · {}", fatal.label, fatal.code), + ui.color(Role::OnErrorContainer), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + fatal.message.as_str(), + ui.color(Role::OnErrorContainer), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + )); + if let Some(hint) = &fatal.hint { + block = block.push( + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackFatalHint), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + hint.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)), + ); + } + } + + // 容忍域(缺文件 / 被拒 component):中性容器 + `Warn` 图标 + 逐条 + if let Some(title) = &report.missing_title { + block = block.push( + column![ + tolerable_title(ui, catalog, title.as_str()), + rows_block(ui, catalog, &report.missing), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + if !report.rejected.is_empty() { + block = block.push(rows_block(ui, catalog, &report.rejected)); + } + + if let Some(title) = &report.conflicts_title { + block = block.push( + column![ + tolerable_title(ui, catalog, title.as_str()), + column( + report + .conflicts + .iter() + .map(|conflict| conflict_row(ui, catalog, conflict)) + .collect::>() + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + + for warning in &report.warnings { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + warning.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + if let Some(note) = &report.overwrite_note { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + let mut actions = row![] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + if report.can_overwrite { + actions = actions.push(buttons::outlined( + ui, + Button::new( + catalog.text(Key::PackOverwriteAction), + intent(PackMessage::Overwrite), + ) + .leading(IconKind::Refresh), + )); + } + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)), + )); + block = block.push(actions); + + block.into() +} + +/// 一条冲突:路径 + 成因 + 本次处置(点一行展开三方哈希)。 +pub(super) fn conflict_row<'a>( + ui: &'a Ui, + catalog: Catalog, + conflict: &'a ConflictRow, +) -> Element<'a, PageMessage> { + let head = row![ + frame::text_role( + ui, + TypeRole::BodySmall, + false, + conflict.path.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + conflict.reason.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + conflict.choice.as_str(), + ui.color(Role::Tertiary), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::PackConflictDetail), + intent(PackMessage::ToggleConflict(conflict.path.clone())), + ) + .leading(IconKind::ChevronDown), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill); + + let mut block = column![head].width(Length::Fill); + if conflict.expanded { + block = block.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render( + Key::PackConflictHashes, + &[ + ("pack", conflict.pack_sha1.as_str()), + ("base", conflict.base_sha1.as_deref().unwrap_or("—")), + ("disk", conflict.disk_sha1.as_deref().unwrap_or("—")), + ], + ), + ui.color(Role::OnSurfaceVariant), + )); + } + block.into() +} + +pub(super) fn status_icon(report: &Report) -> IconKind { + match report.status { + unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check, + unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn, + unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error, + } +} + +pub(super) fn status_role(report: &Report) -> Role { + match report.status { + unlucky_presenter::dto::ImportStatusView::Success => Role::Primary, + unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary, + unlucky_presenter::dto::ImportStatusView::Failed => Role::Error, + } +} diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs index c529b01..bf9d9fd 100644 --- a/crates/app-iced/src/pages/content/view.rs +++ b/crates/app-iced/src/pages/content/view.rs @@ -191,11 +191,20 @@ fn main_column<'a>( list.push(parts::more(ui, catalog, model)).into() }; - column![head, body] + let mut column = column![] .spacing(ui.space(Space::X2)) .width(Length::Fixed(rails.list)) - .height(Length::Fill) - .into() + .height(Length::Fill); + + // M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。 + // 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。 + if model.kind == ContentKind::Modpack { + column = column + .push(super::packui::section(ui, catalog, &model.pack)) + .push(divider::full(ui)); + } + + column.push(head).push(body).into() } /// 详情列(只在 `Wide` / `Narrow` 存在)。 diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 105062b..df91d40 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -83,6 +83,160 @@ macro_rules! content_entries { "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放" ), + // --- M113: pack import / export(PACKUI)--- + PackTitle => ("Modpack import & export", "整合包导入 / 导出"), + PackImportTitle => ("Import a modpack", "导入整合包"), + PackDropHint => ( + "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", + "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里" + ), + 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"), + PackImportAction => ("Import", "导入"), + PackCancel => ("Cancel import", "取消导入"), + PackDismiss => ("Dismiss", "关闭"), + PackTargetLabel => ("Install into", "装到实例"), + PackTargetNew => ("New instance", "新建实例"), + PackInstanceName => ("Instance name", "实例名"), + PackStageLine => ("Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), + PackStageProbe => ("Reading the manifest", "读取清单"), + PackStageExtract => ("Unpacking overrides", "解包覆盖内容"), + PackStageFetch => ("Fetching missing files", "补下载文件"), + PackStageCommit => ("Writing files", "落地写盘"), + PackStageRegister => ("Registering the instance", "登记实例"), + PackProbeTitle => ( + "This pack is almost a known format", + "这个包差一点就是已知格式" + ), + PackProbeLooksLike => ( + "Looks like {format}, but missing: {missing}", + "看起来像 {format},但缺:{missing}" + ), + PackProbeUnknown => ( + "No known format claims this pack", + "没有任何已知格式认领这个包" + ), + PackProbeSaw => ("Found in the pack: {saw}", "包里看到:{saw}"), + PackProbeNothing => ("nothing recognisable", "没有可识别的东西"), + 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} 个文件你改过(默认保留你的)" + ), + 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.", + "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。" + ), + PackMissingOffline => ("offline import", "离线导入"), + PackMissingNoSource => ( + "no download source (CurseForge file API not wired)", + "拿不到下载地址(CF 文件端点未接通)" + ), + PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"), + PackFatalTitle => ( + "Rolled back — nothing was left behind", + "已回滚 —— 目标目录零残留" + ), + PackFatalHint => ("What to try next", "下一步可以试"), + PackConflictUserModified => ("you changed it", "你改过"), + PackConflictUserDeleted => ("you deleted it", "你删过"), + PackConflictPreExisting => ("was already there", "本来就在"), + PackChoiceKeepUser => ("kept yours", "保留我的"), + PackChoiceOverwrite => ("pack version used", "用包里的"), + PackConflictHashes => ( + "pack {pack} · base {base} · disk {disk}", + "包内 {pack} · 基线 {base} · 磁盘 {disk}" + ), + PackConflictDetail => ("Hashes", "三方哈希"), + PackFormatModrinth => ("Modrinth .mrpack", "Modrinth .mrpack"), + PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"), + PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"), + PackExportTitle => ("Export this instance", "导出这个实例"), + PackExportOpen => ("Export…", "导出…"), + PackExportFormat => ("Format", "格式"), + PackExportName => ("Pack name", "包名"), + PackExportVersion => ("Version", "版本"), + PackExportOutput => ("Save to (empty = suggested)", "保存到(留空 = 自动)"), + PackExportPrecheck => ("Dry run", "预检"), + PackExportConfirm => ("Write the pack", "确认写盘"), + PackExportAbort => ("Back", "返回"), + PackExportMultimcNote => ( + "MultiMC/Prism can only be imported, not exported (by design).", + "MultiMC·Prism 只能导入、不能导出(有意省略)。" + ), + 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} 项" + ), + PackPreviewRemote => ( + "{count} files can be referenced remotely", + "{count} 个文件可走远程引用" + ), + PackPreviewInlineBytes => ( + "{bytes} copied into overrides/", + "{bytes} 会原样拷进 overrides/" + ), + PackPreviewSkippedTitle => ( + "Not in the pack (listed one by one)", + "没进包(逐条列出)" + ), + PackPreviewNotes => ("Known losses", "已知损失"), + PackPreviewTarget => ("Will be written to {path}", "将写到 {path}"), + PackPreviewMore => ("…and {count} more files", "…还有 {count} 个文件"), + PackPreviewComplete => ("Nothing is left out", "没有遗漏任何文件"), + PackPreviewIncomplete => ( + "Some files will not be in the pack", + "有文件不会进包" + ), + PackReasonModrinthSha1 => ("Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), + PackReasonCurseFingerprint => ( + "CurseForge match (fingerprint)", + "CurseForge 命中(文件指纹)" + ), + PackReasonNoRemoteMatch => ( + "unknown to both catalogs, inlined", + "两个目录都不认识,已内联" + ), + PackReasonCurseOnly => ( + "only CurseForge knows it, this format cannot use it, inlined", + "只有 CF 认识,目标格式用不上,已内联" + ), + PackReasonModrinthOnly => ( + "only Modrinth knows it, the CF manifest cannot use it, inlined", + "只有 Modrinth 认识,CF 清单用不上,已内联" + ), + PackReasonMissingSha512 => ("match has no sha512, inlined", "命中缺 sha512,已内联"), + PackReasonMissingCurseIds => ( + "match has no projectID/fileID, inlined", + "命中缺 projectID/fileID,已内联" + ), + PackReasonDisabled => ( + "disabled file, this format cannot express it (.disabled kept)", + "禁用文件,目标格式表达不了(保留 .disabled)" + ), + PackReasonSymlink => ("symbolic link, skipped", "符号链接,已跳过"), + PackReasonUnsafePath => ( + "path fails the pack path rules, skipped", + "路径过不了包内规整,已跳过" + ), + PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index b95f360..d7ddcd6 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -219,4 +219,85 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("ContentModpackNotReady", "Installing modpacks is not available yet", "整合包安装尚未开放"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), + ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), + ("PackImportTitle", "Import a modpack", "导入整合包"), + ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), + ("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"), + ("PackImportAction", "Import", "导入"), + ("PackCancel", "Cancel import", "取消导入"), + ("PackDismiss", "Dismiss", "关闭"), + ("PackTargetLabel", "Install into", "装到实例"), + ("PackTargetNew", "New instance", "新建实例"), + ("PackInstanceName", "Instance name", "实例名"), + ("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), + ("PackStageProbe", "Reading the manifest", "读取清单"), + ("PackStageExtract", "Unpacking overrides", "解包覆盖内容"), + ("PackStageFetch", "Fetching missing files", "补下载文件"), + ("PackStageCommit", "Writing files", "落地写盘"), + ("PackStageRegister", "Registering the instance", "登记实例"), + ("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"), + ("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"), + ("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"), + ("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"), + ("PackProbeNothing", "nothing recognisable", "没有可识别的东西"), + ("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} 个文件你改过(默认保留你的)"), + ("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.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), + ("PackMissingOffline", "offline import", "离线导入"), + ("PackMissingNoSource", "no download source (CurseForge file API not wired)", "拿不到下载地址(CF 文件端点未接通)"), + ("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"), + ("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"), + ("PackFatalHint", "What to try next", "下一步可以试"), + ("PackConflictUserModified", "you changed it", "你改过"), + ("PackConflictUserDeleted", "you deleted it", "你删过"), + ("PackConflictPreExisting", "was already there", "本来就在"), + ("PackChoiceKeepUser", "kept yours", "保留我的"), + ("PackChoiceOverwrite", "pack version used", "用包里的"), + ("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"), + ("PackConflictDetail", "Hashes", "三方哈希"), + ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth .mrpack"), + ("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"), + ("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"), + ("PackExportTitle", "Export this instance", "导出这个实例"), + ("PackExportOpen", "Export…", "导出…"), + ("PackExportFormat", "Format", "格式"), + ("PackExportName", "Pack name", "包名"), + ("PackExportVersion", "Version", "版本"), + ("PackExportOutput", "Save to (empty = suggested)", "保存到(留空 = 自动)"), + ("PackExportPrecheck", "Dry run", "预检"), + ("PackExportConfirm", "Write the pack", "确认写盘"), + ("PackExportAbort", "Back", "返回"), + ("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"), + ("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} 项"), + ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), + ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), + ("PackPreviewSkippedTitle", "Not in the pack (listed one by one)", "没进包(逐条列出)"), + ("PackPreviewNotes", "Known losses", "已知损失"), + ("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"), + ("PackPreviewMore", "…and {count} more files", "…还有 {count} 个文件"), + ("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"), + ("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"), + ("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), + ("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"), + ("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"), + ("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"), + ("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"), + ("PackReasonMissingSha512", "match has no sha512, inlined", "命中缺 sha512,已内联"), + ("PackReasonMissingCurseIds", "match has no projectID/fileID, inlined", "命中缺 projectID/fileID,已内联"), + ("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"), + ("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"), + ("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"), + ("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"), ]; diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 810e0ca..9cab44e 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -23,6 +23,8 @@ mod instances; mod java; mod logs; mod notices; +// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面) +mod pack; mod settings; mod versions; @@ -41,5 +43,13 @@ pub use instances::{ pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; pub use notices::{NoticeLevel, NoticeView}; +// M113: import/export +pub use pack::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView, + ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, + FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, + ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, + MissingReasonView, PackFormatView, ProbeView, +}; pub use settings::{SettingControl, SettingGroupView, SettingRowView}; pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs new file mode 100644 index 0000000..e3e584d --- /dev/null +++ b/crates/presenter/src/dto/pack.rs @@ -0,0 +1,49 @@ +//! 整合包导入/导出 DTO(M113 `PACKUI`):把 M111/M112 的管线结果搬过 IPC 边界。 +//! +//! ## 为什么这里几乎是 core 报告的逐字段镜像 +//! +//! `crate::dto` 的规矩(见 `dto/mod.rs`):**不得出现 `unlucky_core::*` 类型**,跨边界类型必须 +//! `Serialize + Deserialize`。所以这里一面不引 core,一面又不许「按 UI 方便重新发明一套语义」—— +//! 三态、阶段、分类、原因全部**逐条对应** `core::addon::import::report` 与 +//! `core::addon::export::report`,只把 `&'static str` 的稳定码换成人读串、把 `PathBuf` 落成 +//! 页面能显示的形态。翻译(core 类型 → 本模块)发生在 `host::addon`,页面侧不认识 core。 +//! +//! 三条不许在翻译时丢的东西: +//! +//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功, +//! 用户会在启动后才发现缺依赖; +//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用), +//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**; +//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进 +//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`format`] | [`PackFormatView`](三个格式 + slug 对齐) | +//! | [`import`] | 请求 / 进度 / 三态 / 逐项原因 / 探测诊断 | +//! | [`conflict`] | 冲突成因、处置、三方哈希 | +//! | [`export`] | 三分类 / 逐条原因 / 预检 | +//! +//! ## 冲突:默认一侧永远是用户那一侧 +//! +//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线: +//! **绝不默认覆盖用户改过的文件**。 + +mod conflict; +mod export; +mod format; +mod import; + +pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView}; +pub use export::{ + ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView, + ExportRequestView, ExportState, +}; +pub use format::PackFormatView; +pub use import::{ + FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, + ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, + MissingReasonView, ProbeView, +}; diff --git a/crates/presenter/src/dto/pack/conflict.rs b/crates/presenter/src/dto/pack/conflict.rs new file mode 100644 index 0000000..e5c07f1 --- /dev/null +++ b/crates/presenter/src/dto/pack/conflict.rs @@ -0,0 +1,71 @@ +//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。 +//! +//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的), +//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。 +//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。 + +use serde::{Deserialize, Serialize}; + +/// 冲突成因(core 的三条判据)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictReasonView { + /// 基线里记过,磁盘内容与基线不同 ⇒ 用户改过。 + UserModified, + /// 基线里记过,磁盘上却没了 ⇒ 用户删过。 + UserDeleted, + /// 没有基线可判,磁盘上本来就有同名文件。 + PreExisting, +} + +impl ConflictReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "user_modified" => Some(Self::UserModified), + "user_deleted" => Some(Self::UserDeleted), + "pre_existing" => Some(Self::PreExisting), + _ => None, + } + } +} + +/// 冲突本次实际采用的处置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictChoiceView { + /// 保留用户侧(**默认**)。 + KeepUser, + /// 用包内内容覆盖(用户显式要求「整合包说了算」)。 + Overwrite, +} + +impl ConflictChoiceView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "keep_user" => Some(Self::KeepUser), + "overwrite" => Some(Self::Overwrite), + _ => None, + } + } +} + +/// 一条冲突(**事后账**:本次实际怎么处置的)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConflictFileView { + /// 实例内相对路径。 + pub path: String, + /// 成因。 + pub reason: ConflictReasonView, + /// 本次实际采用的处置。 + pub choice: ConflictChoiceView, + /// 包内声明的 SHA-1。 + pub pack_sha1: String, + /// 上次导入写下的 SHA-1(没有基线时 `None`)。 + pub base_sha1: Option, + /// 磁盘现状的 SHA-1(文件不存在时 `None`)。 + pub disk_sha1: Option, + /// 包内声明的字节数。 + pub pack_size: u64, +} diff --git a/crates/presenter/src/dto/pack/export.rs b/crates/presenter/src/dto/pack/export.rs new file mode 100644 index 0000000..8f9426f --- /dev/null +++ b/crates/presenter/src/dto/pack/export.rs @@ -0,0 +1,219 @@ +//! 导出侧 DTO:三分类 + 逐条原因 + **预检**(不写盘)。 +//! +//! `ExportReasonView::class()` 是「原因 → 归类」的**唯一**映射处(与 core 同一条判据); +//! 预检([`ExportPreviewView`])与写盘结果用同一套计数方法,于是「将内联 N / 无法导出 M」 +//! 与最终报告的 N/M 同源。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::dto::InstanceId; + +use super::format::PackFormatView; + +/// 导出时一条文件的归类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportClassView { + /// 写进清单 `files[]`(靠远端引用代替本地字节)。 + Remote, + /// 写进 `overrides/`(原字节照搬)。 + Inline, + /// 没进包(读不了 / 符号链接 / 路径不安全)。 + Skipped, +} + +impl ExportClassView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "remote" => Some(Self::Remote), + "inline" => Some(Self::Inline), + "skipped" => Some(Self::Skipped), + _ => None, + } + } +} + +/// 一条文件为什么归到那个类里(与 core 的 `FileReason` 一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportReasonView { + /// 远程引用:Modrinth SHA-1 命中。 + ModrinthSha1, + /// 远程引用:CurseForge 指纹命中。 + CurseForgeFingerprint, + /// 内联:两个目录都不认识它。 + NoRemoteMatch, + /// 内联:只有 CF 认识,目标格式用不上。 + CurseOnlyMatch, + /// 内联:只有 Modrinth 认识,CF 清单用不上。 + ModrinthOnlyMatch, + /// 内联:命中缺 `sha512`。 + MissingSha512, + /// 内联:命中缺 `projectID`/`fileID`。 + MissingCurseIds, + /// 内联:禁用文件,目标格式表达不了禁用位。 + DisabledNotExpressible, + /// 跳过:符号链接。 + SymbolicLink, + /// 跳过:路径过不了包内规整。 + UnsafePath, + /// 跳过:读盘或算摘要失败。 + Unreadable, +} + +impl ExportReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "modrinth_sha1" => Some(Self::ModrinthSha1), + "curseforge_fingerprint" => Some(Self::CurseForgeFingerprint), + "no_remote_match" => Some(Self::NoRemoteMatch), + "curse_only_match" => Some(Self::CurseOnlyMatch), + "modrinth_only_match" => Some(Self::ModrinthOnlyMatch), + "missing_sha512" => Some(Self::MissingSha512), + "missing_curse_ids" => Some(Self::MissingCurseIds), + "disabled_not_expressible" => Some(Self::DisabledNotExpressible), + "symbolic_link" => Some(Self::SymbolicLink), + "unsafe_path" => Some(Self::UnsafePath), + "unreadable" => Some(Self::Unreadable), + _ => None, + } + } + + /// 这个原因属于哪一类(**唯一映射处**,与 core 的 `FileReason::class` 同一条判据)。 + #[must_use] + pub const fn class(self) -> ExportClassView { + match self { + Self::ModrinthSha1 | Self::CurseForgeFingerprint => ExportClassView::Remote, + Self::NoRemoteMatch + | Self::CurseOnlyMatch + | Self::ModrinthOnlyMatch + | Self::MissingSha512 + | Self::MissingCurseIds + | Self::DisabledNotExpressible => ExportClassView::Inline, + Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => ExportClassView::Skipped, + } + } +} + +/// 一条文件的导出预检结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportFileView { + /// 实例内相对路径(含 `.disabled` 后缀)。 + pub path: String, + /// 归类。 + pub class: ExportClassView, + /// 原因。 + pub reason: ExportReasonView, + /// 字节数。 + pub bytes: u64, + /// 是否禁用态(`.disabled`)。 + pub disabled: bool, +} + +/// **预检**:还没有写盘,先把「会怎样」摊开给用户看。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportPreviewView { + /// 目标格式。 + pub format: PackFormatView, + /// 源实例。 + pub instance: InstanceId, + /// 包名。 + pub name: String, + /// 包版本。 + pub version: String, + /// 预定的落盘路径(确认之后写这里)。 + pub target: PathBuf, + /// 逐文件结果(按路径排序)。 + pub files: Vec, + /// 已知的表达不了的差异 / 取舍(core 的 `notes`)。 + pub notes: Vec, +} + +impl ExportPreviewView { + /// 某一类的条数。 + #[must_use] + pub fn count(&self, class: ExportClassView) -> usize { + self.files.iter().filter(|file| file.class == class).count() + } + + /// 远程引用条数。 + #[must_use] + pub fn remote_count(&self) -> usize { + self.count(ExportClassView::Remote) + } + + /// 内联条数(「将内联 N 个文件」的那个 N)。 + #[must_use] + pub fn inline_count(&self) -> usize { + self.count(ExportClassView::Inline) + } + + /// 跳过条数(「无法导出 M 项」的那个 M)。 + #[must_use] + pub fn skipped_count(&self) -> usize { + self.count(ExportClassView::Skipped) + } + + /// 内联文件总字节数。 + #[must_use] + pub fn inline_bytes(&self) -> u64 { + self.files + .iter() + .filter(|file| file.class == ExportClassView::Inline) + .map(|file| file.bytes) + .sum() + } + + /// 包会完整吗(`false` = 有文件压根没进包,必须单独列给用户)。 + #[must_use] + pub fn is_complete(&self) -> bool { + self.skipped_count() == 0 + } +} + +/// 导出格式选择 + 一次导出请求。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportRequestView { + /// 源实例。 + pub instance: InstanceId, + /// 目标格式(只能是 [`PackFormatView::EXPORTABLE`] 里的两个)。 + pub format: PackFormatView, + /// 包名(写进清单 `name`)。 + pub name: String, + /// 包版本(写进清单 `version` / `versionId`)。 + pub version: String, + /// 落盘路径(`None` = host 按「下载目录 + 包名 + 扩展名」给一个缺省)。 + pub target: Option, +} + +/// 导出完成(写盘之后的账)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportDoneView { + /// 产出的包路径。 + pub path: PathBuf, + /// 包的字节数(写完量的,不是估算)。 + pub bytes: u64, + /// 远程引用条数。 + pub remote: u32, + /// 内联条数。 + pub inline: u32, + /// 跳过条数。 + pub skipped: u32, +} + +/// 导出侧的宿主状态(`AppState::export` 的全部内容)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ExportState { + /// 预检结果(`None` = 还没预检)。 + pub preview: Option, + /// 预检在飞。 + pub pending: bool, + /// 最近一次写盘的结果。 + pub done: Option, + /// 失败原因(预检或写盘)。 + pub failed: Option, +} diff --git a/crates/presenter/src/dto/pack/format.rs b/crates/presenter/src/dto/pack/format.rs new file mode 100644 index 0000000..98583e2 --- /dev/null +++ b/crates/presenter/src/dto/pack/format.rs @@ -0,0 +1,47 @@ +//! 整合包格式:探测结果与导出目标共用一个枚举。 +//! +//! `slug()` 与 core 的格式 id **逐字一致**(`curseforge` / `modrinth` / `multimc`), +//! `from_slug` 是反向唯一入口——翻译发生在 `host::addon`,页面侧只认这两个函数。 + +use serde::{Deserialize, Serialize}; + +/// 整合包格式(探测结果与导出目标共用一个枚举)。 +/// +/// MultiMC / Prism 只**导入**不导出(core 的有意省略),所以导出侧两个入口是 +/// [`Self::Modrinth`] / [`Self::Curseforge`]。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PackFormatView { + /// CurseForge:`manifest.json` + `overrides/`。 + Curseforge, + /// Modrinth:`.mrpack`(`modrinth.index.json` + `overrides/`)。 + Modrinth, + /// MultiMC / Prism:`mmc-pack.json` + `.minecraft/`(**只读**)。 + Multimc, +} + +impl PackFormatView { + /// 导出菜单里可选的两个(顺序 = 画出来的顺序)。 + pub const EXPORTABLE: [PackFormatView; 2] = + [PackFormatView::Modrinth, PackFormatView::Curseforge]; + + /// 稳定 slug(与 core 的格式 id 逐字一致,**不要改名**)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Curseforge => "curseforge", + Self::Modrinth => "modrinth", + Self::Multimc => "multimc", + } + } + + /// 从 core 的格式 id 反解(`None` = 不认识的 id,调用方按「未知」处理,别猜)。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "curseforge" => Some(Self::Curseforge), + "modrinth" => Some(Self::Modrinth), + "multimc" => Some(Self::Multimc), + _ => None, + } + } +} diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs new file mode 100644 index 0000000..59c128a --- /dev/null +++ b/crates/presenter/src/dto/pack/import.rs @@ -0,0 +1,338 @@ +//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。 +//! +//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**; +//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::dto::InstanceId; + +use super::conflict::ConflictFileView; +use super::format::PackFormatView; + +/// 导入阶段(与 core 的 `ImportStage` 一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ImportStageView { + /// 读取清单(开包 + 安全四检 + 探测)。 + Probe, + /// 解压 / 三方合并覆盖内容。 + Extract, + /// 补全并下载远端文件。 + Fetch, + /// 落地。 + Commit, + /// 登记实例。 + Register, +} + +impl ImportStageView { + /// 五个阶段的声明顺序(= 进度条上的先后)。 + pub const ALL: [ImportStageView; 5] = [ + ImportStageView::Probe, + ImportStageView::Extract, + ImportStageView::Fetch, + ImportStageView::Commit, + ImportStageView::Register, + ]; + + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::Extract => "extract", + Self::Fetch => "fetch", + Self::Commit => "commit", + Self::Register => "register", + } + } + + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + Self::ALL.into_iter().find(|stage| stage.slug() == slug) + } + + /// 这一阶段有没有「逐文件」的进度(`Probe` 只看清单,没条目可数)。 + #[must_use] + pub const fn counts_files(self) -> bool { + !matches!(self, Self::Probe) + } +} + +/// 导入三态(`Partial` **不是** `Success`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportStatusView { + /// 全部落地、无冲突。 + Success, + /// 有缺文件或有冲突(内容不完整,但实例可用)。 + Partial, + /// 致命失败,**已整包回滚**。 + Failed, +} + +impl ImportStatusView { + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Success => "success", + Self::Partial => "partial", + Self::Failed => "failed", + } + } + + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "success" => Some(Self::Success), + "partial" => Some(Self::Partial), + "failed" => Some(Self::Failed), + _ => None, + } + } + + /// 实例可用吗(`Success` / `Partial` 都是「装上了」)。 + #[must_use] + pub const fn installed(self) -> bool { + !matches!(self, Self::Failed) + } +} + +/// 一条落地文件本次被怎么处理。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileActionView { + /// 新写入。 + Installed, + /// 覆盖写入(用户没改过)。 + Replaced, + /// 与包内一致,没动。 + SkippedIdentical, + /// 用户改过 / 删过,按策略保留了用户那一侧。 + KeptUser, +} + +impl FileActionView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "installed" => Some(Self::Installed), + "replaced" => Some(Self::Replaced), + "skipped_identical" => Some(Self::SkippedIdentical), + "kept_user" => Some(Self::KeptUser), + _ => None, + } + } + + /// 是否真的写了盘。 + #[must_use] + pub const fn wrote(self) -> bool { + matches!(self, Self::Installed | Self::Replaced) + } +} + +/// 一次导入的请求。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportRequestView { + /// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。 + pub pack: PathBuf, + /// 目标实例(`None` = 按包名新建一个)。 + pub instance: Option, + /// 新实例的展示名(`None` = 用包名)。 + pub instance_name: Option, + /// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。 + /// + /// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。 + pub overwrite: bool, +} + +/// 一条导入进度。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportProgressView { + /// 阶段。 + pub stage: ImportStageView, + /// 阶段内已完成条目数。 + pub done: u32, + /// 阶段内总条目数(`0` = 未知)。 + pub total: u32, + /// 当前条目的实例内路径(阶段刚开始时为 `None`)。 + pub current: Option, + /// 包名(面板标题那一行)。 + pub pack: String, +} + +impl ImportProgressView { + /// 空进度(刚派发,后端还没回第一条)。 + #[must_use] + pub fn new(pack: impl Into) -> Self { + Self { + stage: ImportStageView::Probe, + done: 0, + total: 0, + current: None, + pack: pack.into(), + } + } + + /// 计数已知吗(`false` = 不画确定态进度条,避免「静止的假进度」)。 + #[must_use] + pub const fn is_known(&self) -> bool { + self.total > 0 + } + + /// 阶段内完成比例 0.0~1.0(未知时 `0.0`)。 + #[must_use] + pub fn ratio(&self) -> f32 { + if self.total == 0 { + return 0.0; + } + (self.done as f32 / self.total as f32).clamp(0.0, 1.0) + } +} + +/// 一条落地文件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportedFileView { + /// 实例内相对路径。 + pub path: String, + /// 本次处置。 + pub action: FileActionView, + /// 字节数。 + pub bytes: u64, +} + +/// 一个文件「搞不到」的成因(**容忍**域)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MissingReasonView { + /// 调用方没给 fetcher(离线导入)。 + Offline, + /// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。 + NoSource, + /// 尝试过但失败(404 / 网络)。 + FetchFailed, +} + +impl MissingReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "offline" => Some(Self::Offline), + "no_source" => Some(Self::NoSource), + "fetch_failed" => Some(Self::FetchFailed), + _ => None, + } + } +} + +/// 一个「搞不到」的文件(容忍,但逐条列出)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MissingFileView { + /// 实例内路径(CF 补全失败时没有)。 + pub path: Option, + /// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。 + pub display: String, + /// 为什么搞不到。 + pub reason: MissingReasonView, + /// 细节(错误原文)。 + pub detail: String, +} + +/// 一次**已整包回滚**的致命失败。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportErrorView { + /// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。 + pub code: String, + /// 人读大类名。 + pub label: String, + /// 具体是什么。 + pub message: String, + /// 补救建议(core 有就给)。 + pub hint: Option, +} + +/// 一次导入的完整结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportReportView { + /// 三态。 + pub status: ImportStatusView, + /// 命中的格式(`None` = 探测都没过)。 + pub format: Option, + /// 包名。 + pub pack_name: Option, + /// 包版本。 + pub pack_version: Option, + /// 游戏版本。 + pub game_version: Option, + /// 加载器(人读串,如 `fabric-loader 0.15.11`)。 + pub loader: Option, + /// 实例绑定的版本 id。 + pub version_id: Option, + /// 目标实例目录。 + pub target: PathBuf, + /// 落地的文件。 + pub files: Vec, + /// 冲突清单。 + pub conflicts: Vec, + /// 缺文件清单(容忍)。 + pub missing: Vec, + /// MultiMC 不支持的 component(逐项)。 + pub rejected: Vec, + /// 注记。 + pub warnings: Vec, + /// 致命错误(`status == Failed` 时必有)。 + pub error: Option, + /// 登记的实例名。 + pub registered: Option, + /// 本次写入的字节数。 + pub bytes_written: u64, + /// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。 + pub rolled_back: bool, +} + +impl ImportReportView { + /// 真正写盘的文件条数。 + #[must_use] + pub fn written(&self) -> usize { + self.files.iter().filter(|file| file.action.wrote()).count() + } + + /// 要用户看一眼的东西条数(冲突 + 缺文件 + 被拒 component)。 + #[must_use] + pub fn issue_count(&self) -> usize { + self.conflicts.len() + self.missing.len() + self.rejected.len() + } +} + +/// 探测失败的**具名**诊断:「看起来像 X,但缺 Y」。 +/// +/// 为什么不由 core 给:core 的 `detect` 只回答「谁认领」,一个都不认领时只回一句 +/// `pack_unsupported` + 试过哪些格式。而用户手里那个包往往**差一点点**就是某个已知格式 +/// (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。 +/// 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProbeView { + /// 最像哪个格式(`None` = 三个都不像)。 + pub looks_like: Option, + /// 该格式**缺**的标记文件(逐条,按判据顺序)。 + pub missing: Vec, + /// 包里实际看到的标记(逐条)。 + pub saw: Vec, + /// 一句兜底说明(也是 core 的原文:试过哪些格式)。 + pub detail: String, +} + +/// 导入侧的宿主状态(`AppState::import` 的全部内容)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ImportState { + /// 正在跑的进度(`None` = 没在跑)。 + pub progress: Option, + /// 最近一次的结果(`None` = 还没导过)。 + pub report: Option, + /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 + pub probe: Option, +}