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:
1 parent
0367073d32
commit
fbeff94e55
31 files changed
+3646
-9
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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` 存在)。
|
||||
|
||||
Reference in new issue
Block a user