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
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user