导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 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 后按新形状写一次)。
239 lines
8.3 KiB
Rust
239 lines
8.3 KiB
Rust
//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。
|
||
//!
|
||
//! 从 `pages/content/model.rs` 拆出(那个文件已贴 400 行硬上限)。纯数据类型与纯函数,
|
||
//! 不认识 iced:`model/tests.rs` 直接钉它们的判定。
|
||
|
||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSource, InstanceSummary, LoaderKind};
|
||
|
||
use unlucky_i18n::{Catalog, Key};
|
||
|
||
/// 页面自留的选择(检索条件与结果在 presenter 的 `AppState::content`)。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct PageState {
|
||
/// 当前内容类型
|
||
pub kind: ContentKind,
|
||
/// 选中的项目 id(`None` = 没选 → 详情面板空态)
|
||
pub selected: Option<String>,
|
||
/// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明)
|
||
pub target: Option<String>,
|
||
/// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`)
|
||
pub draft: String,
|
||
/// 导入/导出区的页内选择(M113:路径草稿 / 目标实例 / 格式 / 展开的那条冲突)
|
||
pub pack: super::super::packui::PackPageState,
|
||
}
|
||
|
||
impl Default for PageState {
|
||
fn default() -> Self {
|
||
Self {
|
||
kind: ContentKind::Mod,
|
||
selected: None,
|
||
target: None,
|
||
draft: String::new(),
|
||
pack: super::super::packui::PackPageState::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 页面此刻画哪一态(四态 + 分页尾)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Status {
|
||
/// 加载中(首屏 / 换条件 / 换页)
|
||
Loading,
|
||
/// 有命中项
|
||
Ready,
|
||
/// 拉到了但一条都没有
|
||
Empty,
|
||
/// 失败(带一句人读原因)
|
||
Failed(String),
|
||
}
|
||
|
||
impl Status {
|
||
/// 失败了吗(视图用它决定画错误面板还是列表)。
|
||
pub const fn failed(&self) -> bool {
|
||
matches!(self, Self::Failed(_))
|
||
}
|
||
}
|
||
|
||
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Hit {
|
||
/// 项目 id(选中 / 装入指令的载荷)
|
||
pub id: String,
|
||
/// 标题
|
||
pub title: String,
|
||
/// 一行说明
|
||
pub summary: String,
|
||
/// 来源 + 作者(`Modrinth · jelly`)
|
||
pub supporting: String,
|
||
/// 下载量(已加千分位)
|
||
pub downloads: String,
|
||
/// 更新日期的人读串
|
||
pub updated: String,
|
||
/// 支持的游戏版本(详情面板用;空 = 站点没给)
|
||
pub versions: Vec<String>,
|
||
/// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比)
|
||
pub loaders: Vec<String>,
|
||
/// 来源(行上标来源徽标)
|
||
pub source: ContentSource,
|
||
}
|
||
|
||
impl Hit {
|
||
/// 命中项 DTO → 展示态(**演示数据也走这条**,于是 fixture 与产品路径逐字段同源)。
|
||
pub(crate) fn of(hit: &ContentHit, catalog: Catalog) -> Self {
|
||
let source = match hit.source {
|
||
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
|
||
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
|
||
};
|
||
let supporting = if hit.author.is_empty() {
|
||
source.to_string()
|
||
} else {
|
||
format!("{source} · {}", hit.author)
|
||
};
|
||
Self {
|
||
id: hit.id.clone(),
|
||
title: hit.title.clone(),
|
||
summary: hit.summary.clone(),
|
||
supporting,
|
||
downloads: catalog.render(
|
||
Key::ContentDetailDownloads,
|
||
&[("count", &thousands(hit.downloads))],
|
||
),
|
||
updated: catalog.render(Key::ContentDetailUpdated, &[("date", &hit.updated)]),
|
||
versions: hit.game_versions.clone(),
|
||
loaders: hit.loaders.clone(),
|
||
source: hit.source,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 目标实例(一键装入的对象)的展示态。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Target {
|
||
/// 实例 id
|
||
pub id: String,
|
||
/// 显示名
|
||
pub name: String,
|
||
/// 游戏版本
|
||
pub mc_version: String,
|
||
/// 加载器
|
||
pub loader: LoaderKind,
|
||
}
|
||
|
||
impl Target {
|
||
pub(super) fn of(instance: &InstanceSummary) -> Self {
|
||
Self {
|
||
id: instance.id.clone(),
|
||
name: instance.name.clone(),
|
||
mc_version: instance.mc_version.clone(),
|
||
loader: instance.loader.kind,
|
||
}
|
||
}
|
||
|
||
/// 详情里那一行(`Survival · 1.20.4 · Fabric`)。
|
||
pub(crate) fn label(&self) -> String {
|
||
format!(
|
||
"{} · {} · {}",
|
||
self.name,
|
||
self.mc_version,
|
||
loader_name(self.loader)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 下载量加千分位(`1200000` → `1,200,000`):raw 数字在详情面板上读不出量级。
|
||
pub(crate) fn thousands(value: u64) -> String {
|
||
let digits = value.to_string();
|
||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||
for (index, ch) in digits.chars().enumerate() {
|
||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
||
out.push(',');
|
||
}
|
||
out.push(ch);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 首字母大写(`fabric` → `Fabric`;空串原样)。
|
||
///
|
||
/// **只在画的时候用**:`Hit::loaders` 与兼容性判定都保持站点原样的小写 facet,
|
||
/// 否则「Fabric」与 facet「fabric」一比就成了不兼容(本轮踩过)。
|
||
pub(crate) fn capitalize(raw: &str) -> String {
|
||
let mut chars = raw.chars();
|
||
match chars.next() {
|
||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
|
||
/// 加载器的**界面名**(ASCII)。
|
||
///
|
||
/// 不复用 `LoaderKind::label()`:它对原版返回「原版」——中文业务串,而本仓的界面文案在 i18n
|
||
/// 之前是英文基线(干净 CI 上没有中文字体,见 `pages/instances/data.rs` 的同一条理由)。
|
||
pub const fn loader_name(loader: LoaderKind) -> &'static str {
|
||
match loader {
|
||
LoaderKind::Vanilla => "Vanilla",
|
||
LoaderKind::Fabric => "Fabric",
|
||
LoaderKind::Quilt => "Quilt",
|
||
LoaderKind::Forge => "Forge",
|
||
LoaderKind::NeoForge => "NeoForge",
|
||
LoaderKind::OptiFine => "OptiFine",
|
||
LoaderKind::LiteLoader => "LiteLoader",
|
||
}
|
||
}
|
||
|
||
/// 选中项的**判定事实**(版本 / 加载器 / 来源)。
|
||
///
|
||
/// 为什么另起一个结构:兼容性判据随着「宿主接通了什么」在长(M109 加了来源),
|
||
/// 每次加一条都改一次 `blockers_of` 的形参列表,而调用点有两处(产品路径与演示数据)。
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub(crate) struct HitFacts<'a> {
|
||
/// 支持的游戏版本(空 = 站点没给)
|
||
pub versions: &'a [String],
|
||
/// 支持的加载器(空 = 站点没给;**站点原样的小写 facet**)
|
||
pub loaders: &'a [String],
|
||
/// 来源(决定「文件端点通没通」)
|
||
pub source: ContentSource,
|
||
}
|
||
|
||
/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Blocker {
|
||
/// 整包类内容不走「单文件进目录」这条路(导入管线还没落地)
|
||
NeedsPipeline,
|
||
/// 这个来源的**文件端点**还没接通(检索通了、拿到下载地址那一跳没通)
|
||
SourceNotWired,
|
||
/// 项目不支持目标实例的游戏版本
|
||
Version {
|
||
/// 项目支持的游戏版本(人读,最多取三个)
|
||
needs: String,
|
||
/// 目标实例的版本
|
||
actual: String,
|
||
},
|
||
/// 项目不支持目标实例的加载器
|
||
Loader {
|
||
/// 目标实例的加载器
|
||
loader: LoaderKind,
|
||
},
|
||
/// 没有目标实例
|
||
NoTarget,
|
||
}
|
||
|
||
impl Blocker {
|
||
/// 一句话原因(i18n)。
|
||
pub(super) fn reason(&self, catalog: Catalog) -> String {
|
||
match self {
|
||
Self::NeedsPipeline => catalog.text(Key::ContentModpackNotReady).to_string(),
|
||
Self::Version { needs, actual } => catalog.render(
|
||
Key::ContentIncompatibleVersion,
|
||
&[("version", needs), ("actual", actual)],
|
||
),
|
||
Self::Loader { loader } => catalog.render(
|
||
Key::ContentIncompatibleLoader,
|
||
&[("loader", loader_name(*loader))],
|
||
),
|
||
Self::SourceNotWired => catalog.text(Key::ContentSourceNotWired).to_string(),
|
||
Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(),
|
||
}
|
||
}
|
||
}
|