feat(packui): M113 第一批 —— 内容库页的导入/导出区(界面 + DTO + i18n + 拖拽落点)

导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 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 后按新形状写一次)。
This commit is contained in:
AskaEth committed 2026-11-05 04:06:37 +08:00
1 parent 0367073d32
commit fbeff94e55
31 files changed
+3646 -9

No files matched your search

+25 -6
View File
@@ -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()
}
+101
View File
@@ -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();
+4
View File
@@ -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;
@@ -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,
}
}
@@ -37,4 +37,6 @@ pub enum Message {
Retry,
/// 装进目标实例
Install,
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
Pack(super::packui::PackMessage),
}
@@ -73,6 +73,8 @@ pub struct Content {
pub versions: Vec<String>,
/// 关键词草稿
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,
}
}
@@ -18,6 +18,8 @@ pub struct PageState {
pub target: Option<String>,
/// 关键词输入框里的草稿(**未提交**;提交后才进 `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(),
}
}
}
@@ -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(),
};
// 没选中项目 ⇒ 不能装(也不该说「成功」)
@@ -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)
}
@@ -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<Target> {
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<InstanceId> = 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(),
],
}
}
@@ -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<InstanceId>),
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
SetInstanceName(String),
/// 换导出格式
SetFormat(PackFormatView),
/// 换导出包名
SetExportName(String),
/// 换导出包版本
SetExportVersion(String),
/// 换落盘路径(留空 = 让 host 给缺省)
SetOutput(String),
/// 跑预检(**不写盘**)
Precheck,
/// 确认写盘(预检已经有结果才可达)
Confirm,
/// 放弃这次导出(关掉预检面板,不写盘)
AbortExport,
/// 打开导出面板
OpenExport,
}
@@ -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<InstanceId>,
/// 新实例名
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<String>,
}
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<PackFormatView>,
/// 「看起来像 X,但缺 Y」(或「没有任何已知格式认领」)
pub lead: String,
/// 包里实际看到的标记(host 已截断,只给代表性几条)
pub saw: Vec<String>,
/// 兜底说明(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<PackTarget>,
/// 选中的目标实例
pub instance: Option<InstanceId>,
/// 新实例名
pub instance_name: String,
/// 正在导入(有它就画那块面板)
pub running: Option<Running>,
/// 探测失败诊断
pub probe: Option<Probe>,
/// 导入结果
pub report: Option<Report>,
/// 导出块
pub export: Export,
}
impl Pack {
/// presenter 快照 + 页面选择 → 视图模型(**唯一的真数据入口**)。
pub fn of(
import: &ImportState,
export: &ExportState,
page: &PackPageState,
targets: &[Target],
catalog: Catalog,
) -> Self {
let candidates: Vec<PackTarget> = 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;
@@ -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<Row>,
/// 内联文件样例
pub inline_sample: Vec<String>,
/// `还有 N 个文件…`
pub more_line: Option<String>,
/// core 的 `notes`(已知损失)
pub notes: Vec<String>,
}
/// 导出块。
#[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<PackTarget>,
/// 选中的实例
pub instance: Option<unlucky_presenter::dto::InstanceId>,
/// 包名
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<Preview>,
/// 写盘完成行
pub done: Option<String>,
/// 失败原因
pub failed: Option<String>,
}
/// 导出一块。
pub(super) fn export_of(
state: &ExportState,
page: &PackPageState,
targets: Vec<PackTarget>,
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<Row> = 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::<Vec<_>>();
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),
}
}
@@ -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()
},
}
}
@@ -0,0 +1,242 @@
//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。
//!
//! ## 为什么容忍域与致命域连字段类型都不同
//!
//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。
//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以:
//!
//! | 域 | 类型 | 画法 |
//! |---|---|---|
//! | 容忍(缺文件 / 被拒 component / 注记) | [`Row`](路径 + 原因) | 中性容器 + `Info` 图标 + 逐条列表 |
//! | 致命(已回滚) | [`Fatal`](大类 + 原文 + 建议 + 稳定码) | `ErrorContainer` + `Error` 图标 + 标题换措辞 |
//!
//! 一个 `Option<Fatal>` 与一个 `Vec<Row>` 之间没有「误合并」的写法,这比任何样式约定都硬。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
ImportStatusView, MissingFileView, MissingReasonView,
};
use super::PackPageState;
/// 报告里的一行(容忍域:缺文件 / 被拒 component)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 左列(路径 / 组件名)
pub path: String,
/// 右列(原因的人读文案)
pub note: String,
}
/// 一条冲突(三方哈希 + 本次处置)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictRow {
/// 实例内路径
pub path: String,
/// 成因(`你改过` / `你删过` / `本来就在`)
pub reason: String,
/// 本次实际处置(`保留我的` / `用包里的`)
pub choice: String,
/// 包内 SHA-1(截短)
pub pack_sha1: String,
/// 基线 SHA-1(`None` = 没有基线)
pub base_sha1: Option<String>,
/// 磁盘 SHA-1(`None` = 文件不在)
pub disk_sha1: Option<String>,
/// 展开中(展开才画三方哈希那行)
pub expanded: bool,
}
/// 致命失败块(**已整包回滚**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fatal {
/// 「已回滚 —— 目标目录零残留」
pub title: String,
/// 大类名(`路径穿越` / `清单损坏` …)
pub label: String,
/// 具体是什么
pub message: String,
/// 稳定码
pub code: String,
/// 补救建议
pub hint: Option<String>,
}
/// 导入结果块。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
/// 三态
pub status: ImportStatusView,
/// 标题(已导入 / 有缺口 / 已回滚)
pub title: String,
/// 副标题(`包名 · 格式 · 游戏版本 · 加载器`)
pub subtitle: String,
/// 写盘行(`12 个文件已写盘 · 3.4 MB`)
pub written_line: String,
/// 致命失败(有它就**没有**下面的缺口区块)
pub fatal: Option<Fatal>,
/// 缺文件标题
pub missing_title: Option<String>,
/// 缺文件逐条(容忍域)
pub missing: Vec<Row>,
/// 冲突标题
pub conflicts_title: Option<String>,
/// 冲突逐条
pub conflicts: Vec<ConflictRow>,
/// 被拒 component 逐条
pub rejected: Vec<Row>,
/// 注记逐条
pub warnings: Vec<String>,
/// 能不能给「以包为准重新导入」(有冲突、且实例可用时才有意义)
pub can_overwrite: bool,
/// 「两者都留改名」为何不支持(有冲突时如实写)
pub overwrite_note: Option<String>,
}
impl Report {
/// 实例可用吗(`Success` / `Partial` 都是装上了)。
pub const fn installed(&self) -> bool {
self.status.installed()
}
}
/// 一次导入结果 → 视图模型。
pub(super) fn report_of(
report: &ImportReportView,
page: &PackPageState,
catalog: Catalog,
) -> Report {
let missing_title = (!report.missing.is_empty()).then(|| {
catalog.render(
Key::PackReportMissing,
&[("count", &report.missing.len().to_string())],
)
});
let conflicts_title = (!report.conflicts.is_empty()).then(|| {
catalog.render(
Key::PackReportConflicts,
&[("count", &report.conflicts.len().to_string())],
)
});
Report {
status: report.status,
title: status_title(report.status, catalog).to_owned(),
subtitle: subtitle(report, catalog),
written_line: catalog.render(
Key::PackReportWritten,
&[
("count", &report.written().to_string()),
("bytes", &human(report)),
],
),
fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)),
missing_title,
missing: report
.missing
.iter()
.map(|file| missing_row(file, catalog))
.collect(),
conflicts_title,
conflicts: report
.conflicts
.iter()
.map(|file| conflict_row(file, page.expanded.as_deref(), catalog))
.collect(),
rejected: 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<String> = Vec::new();
if let Some(format) = report.format {
parts.push(super::format_label(format, catalog).to_owned());
}
if let Some(version) = &report.game_version {
parts.push(version.clone());
}
if let Some(loader) = &report.loader {
parts.push(loader.clone());
}
if let Some(name) = &report.pack_name {
parts.insert(0, name.clone());
}
parts.join(" · ")
}
fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal {
Fatal {
title: catalog.text(Key::PackFatalTitle).to_owned(),
label: error.label.clone(),
message: error.message.clone(),
code: error.code.clone(),
hint: error.hint.clone(),
}
}
fn missing_row(file: &MissingFileView, catalog: Catalog) -> Row {
Row {
path: file.path.clone().unwrap_or_else(|| file.display.clone()),
note: match file.reason {
MissingReasonView::Offline => catalog.text(Key::PackMissingOffline).to_owned(),
MissingReasonView::NoSource => catalog.text(Key::PackMissingNoSource).to_owned(),
MissingReasonView::FetchFailed => catalog.render(
Key::PackMissingFetchFailed,
&[("detail", file.detail.as_str())],
),
},
}
}
fn conflict_row(file: &ConflictFileView, expanded: Option<&str>, catalog: Catalog) -> ConflictRow {
ConflictRow {
path: file.path.clone(),
reason: match file.reason {
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
}
.to_owned(),
choice: match file.choice {
ConflictChoiceView::KeepUser => catalog.text(Key::PackChoiceKeepUser),
ConflictChoiceView::Overwrite => catalog.text(Key::PackChoiceOverwrite),
}
.to_owned(),
pack_sha1: short_sha(&file.pack_sha1),
base_sha1: file.base_sha1.as_deref().map(short_sha),
disk_sha1: file.disk_sha1.as_deref().map(short_sha),
expanded: expanded == Some(file.path.as_str()),
}
}
/// SHA-1 截短(前 8 位够人眼区分;全 40 位会把一行撑爆)。
#[must_use]
pub fn short_sha(sha: &str) -> String {
sha.chars().take(8).collect()
}
@@ -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<String> = 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<ExportFileView>) -> 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());
}
@@ -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());
}
@@ -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<Element<'a, PageMessage>> = 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()
}
@@ -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::<Vec<_>>())
.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()
}
@@ -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>>,
) -> 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::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
@@ -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)
}
@@ -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::<Vec<_>>()
)
.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,
}
}
+12 -3
View File
@@ -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` 存在)。
+154
View File
@@ -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", "读不出来,已跳过"),
} }
};
}
+81
View File
@@ -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", "读不出来,已跳过"),
];
+10
View File
@@ -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};
+49
View File
@@ -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,
};
+71
View File
@@ -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<Self> {
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<Self> {
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<String>,
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
pub disk_sha1: Option<String>,
/// 包内声明的字节数。
pub pack_size: u64,
}
+219
View File
@@ -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<Self> {
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<Self> {
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<ExportFileView>,
/// 已知的表达不了的差异 / 取舍(core 的 `notes`)。
pub notes: Vec<String>,
}
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<PathBuf>,
}
/// 导出完成(写盘之后的账)。
#[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<ExportPreviewView>,
/// 预检在飞。
pub pending: bool,
/// 最近一次写盘的结果。
pub done: Option<ExportDoneView>,
/// 失败原因(预检或写盘)。
pub failed: Option<String>,
}
+47
View File
@@ -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<Self> {
match slug {
"curseforge" => Some(Self::Curseforge),
"modrinth" => Some(Self::Modrinth),
"multimc" => Some(Self::Multimc),
_ => None,
}
}
}
+338
View File
@@ -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> {
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<Self> {
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<Self> {
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<InstanceId>,
/// 新实例的展示名(`None` = 用包名)。
pub instance_name: Option<String>,
/// 冲突翻档:`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<String>,
/// 包名(面板标题那一行)。
pub pack: String,
}
impl ImportProgressView {
/// 空进度(刚派发,后端还没回第一条)。
#[must_use]
pub fn new(pack: impl Into<String>) -> 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<Self> {
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<String>,
/// 人读标识(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<String>,
}
/// 一次导入的完整结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportReportView {
/// 三态。
pub status: ImportStatusView,
/// 命中的格式(`None` = 探测都没过)。
pub format: Option<PackFormatView>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器(人读串,如 `fabric-loader 0.15.11`)。
pub loader: Option<String>,
/// 实例绑定的版本 id。
pub version_id: Option<String>,
/// 目标实例目录。
pub target: PathBuf,
/// 落地的文件。
pub files: Vec<ImportedFileView>,
/// 冲突清单。
pub conflicts: Vec<ConflictFileView>,
/// 缺文件清单(容忍)。
pub missing: Vec<MissingFileView>,
/// MultiMC 不支持的 component(逐项)。
pub rejected: Vec<String>,
/// 注记。
pub warnings: Vec<String>,
/// 致命错误(`status == Failed` 时必有)。
pub error: Option<ImportErrorView>,
/// 登记的实例名。
pub registered: Option<String>,
/// 本次写入的字节数。
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<PackFormatView>,
/// 该格式**缺**的标记文件(逐条,按判据顺序)。
pub missing: Vec<String>,
/// 包里实际看到的标记(逐条)。
pub saw: Vec<String>,
/// 一句兜底说明(也是 core 的原文:试过哪些格式)。
pub detail: String,
}
/// 导入侧的宿主状态(`AppState::import` 的全部内容)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ImportState {
/// 正在跑的进度(`None` = 没在跑)。
pub progress: Option<ImportProgressView>,
/// 最近一次的结果(`None` = 还没导过)。
pub report: Option<ImportReportView>,
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
pub probe: Option<ProbeView>,
}