Files
Unlucky-Launcher/crates/app-iced/src/pages/content/demo.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

222 lines
8.7 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_demo` 出图与单测用;产品路径永远走 `Content::from_snapshot`)。
//!
//! ## 为什么要环境变量选「哪一屏」
//!
//! D30 的验收矩阵要求「四类内容 × 浅/深」加「搜索无结果」「加载器不兼容禁用」两种状态,
//! 共 10 张。`pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;
//! 而 `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。
//!
//! 所以沿用本仓既有的取证开关范式(`ULL_START_ROUTE` / `ULL_APPEARANCE_*` / `ULL_TICK_TRACE`):
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
//!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
use crate::pages::download::Download;
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
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,
},
]
}
/// 四条命中项:**先造 `ContentHit` 再走 `Hit::of`**,保证演示数据与产品路径逐字段同源
/// (自己拼 `Hit` 的那一版会把「1,200,000 下载量」「Fabric 首字母大写」这类格式化漂开)。
fn hits(kind: ContentKind, catalog: Catalog, compatible_only: bool) -> Vec<Hit> {
let rows: &[(&str, &str, &str)] = match kind {
ContentKind::Modpack => &[
(
"Fabulously Optimized",
"robotkoer",
"Performance-focused vanilla+ pack",
),
("Adrenaline", "Someone", "Lightweight kitchen-sink pack"),
("Better MC", "Luna", "Adventure pack with quests"),
("All the Mods 9", "ATMTeam", "Kitchen sink, 400+ mods"),
],
ContentKind::Mod => &[
("Sodium", "jellysquid3", "Modern rendering engine"),
("Lithium", "jellysquid3", "Game logic optimizations"),
("Iris Shaders", "coderbot", "Shader support for Fabric"),
("JEI", "mezz", "Item and recipe viewer"),
],
ContentKind::Resourcepack => &[
(
"Faithful 32x",
"Faithful",
"Double resolution vanilla textures",
),
("Stay True", "Fred", "Warm, hand-drawn look"),
("Classic 3D", "Someone", "3D blocks and items"),
("Bare Bones", "Someone", "Flat, minimalist textures"),
],
ContentKind::Shader => &[
("Complementary Reimagined", "Emin", "Balanced, high quality"),
("BSL Shaders", "capttatsu", "Soft colors, strong weather"),
("Photon", "SixthSurge", "Path-traced look"),
("MakeUp Ultra Fast", "Someone", "Lightweight shader"),
],
};
rows.iter()
.enumerate()
.map(|(index, (title, author, summary))| {
// 前两条只支持 1.20.1 + Fabric(能装进 Survival);后两条是 1.21.1 + Forge
let compatible = compatible_only || index < 2;
Hit::of(
&ContentHit {
id: format!("{}-{index}", kind.slug()),
slug: format!("{}-{index}", kind.slug()),
title: (*title).to_string(),
summary: (*summary).to_string(),
author: (*author).to_string(),
downloads: 1_200_000u64.saturating_sub(index as u64 * 111_111),
updated: format!("2026-0{}-1{index}", index + 1),
source: if index % 2 == 0 {
ContentSource::Modrinth
} else {
ContentSource::Curseforge
},
game_versions: if compatible {
vec!["1.20.1".to_string()]
} else {
vec!["1.21.1".to_string()]
},
loaders: if compatible {
vec!["fabric".to_string()]
} else {
vec!["forge".to_string()]
},
icon: None,
},
catalog,
)
})
.collect()
}
/// 装入中的面板(**复用下载页那块**:`pages::download::install_panel_model` + `install_panel`)。
fn install_panel(catalog: Catalog) -> Download {
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
let progress = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
loader: "Fabric".to_string(),
stage: InstallStage::Downloading,
files_done: 0,
files_total: 1,
message: "sodium-fabric-0.6.0.jar".to_string(),
..InstallProgressView::new()
};
crate::pages::download::install_panel_model(&progress, catalog)
.expect("演示数据的进度是「下载中」,面板必然存在")
}
/// 内建模型(默认 `ok` + 从环境变量读类型/状态)。
pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
let catalog = lang.catalog();
let kind = std::env::var("ULL_CONTENT_KIND")
.ok()
.and_then(|value| {
ContentKind::ALL
.into_iter()
.find(|item| item.slug() == value.trim())
})
.unwrap_or(fallback_kind);
let state = std::env::var("ULL_CONTENT_STATE").unwrap_or_else(|_| "ok".to_string());
let targets = targets();
let target = Some("survival".to_string());
let empty = state == "empty";
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
let installing = state == "install";
let list = if empty {
Vec::new()
} else {
hits(kind, catalog, state != "incompatible")
};
// 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric
// 「CF 未接通」那一屏:选中的那条来自 CurseForge(文件端点还没接,按钮必须禁用)
let selected_index = match state.as_str() {
"incompatible" => 2,
"cf" => 1,
_ => 0,
};
let selected = list.get(selected_index).map(|hit| hit.id.clone());
let blockers = blockers_of(
list.get(selected_index).map(|hit| HitFacts {
versions: hit.versions.as_slice(),
loaders: hit.loaders.as_slice(),
source: hit.source,
}),
kind,
&targets,
target.as_deref(),
);
let target_label = targets
.iter()
.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 {
source: ContentSource::Modrinth,
sort: ContentSort::Relevance,
version: Some("1.20.1".to_string()),
loader: Some(LoaderKind::Fabric),
text: if empty {
"quarkonium".to_string()
} else {
String::new()
},
},
hits: list,
selected,
status: if empty { Status::Empty } else { Status::Ready },
total: if empty { 0 } else { 1_284 },
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
has_more: !empty,
curseforge: false,
targets,
target: target.clone(),
target_label,
blockers,
panel: if installing {
Some(install_panel(catalog))
} else {
None
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
draft: if empty {
"quarkonium".to_string()
} else {
String::new()
},
pack,
}
}