Files
Unlucky-Launcher/crates/app-iced/src/pages/content/model/items.rs
T
AskaEth fbeff94e55 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 后按新形状写一次)。
2026-11-05 04:06:37 +08:00

239 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。
//!
//! 从 `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(),
}
}
}