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

+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>,
}