feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停

用户实跑反馈两条,本轮各自定位到根因并修掉:

1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
   `AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
   安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
   Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
   `AppCommand::CreateInstance`。
   - 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
     `AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
     缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
   - 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
     1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
   - 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
     模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。

2. 「点取消没反应」= 真 bug,两跳各断一半:
   - 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
     刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
     修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
     `InstallDone` 带代次回来,代次不符一律丢。
   - 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
     新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
     CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
     drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
     返回新的 `InstallErrorKind::Cancelled`。
   - 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
     「哪些留着、哪些一定没发生」说清(D21)。

实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。

文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent d690d09b84
commit 30b1ff6f48
39 files changed
+2550 -518

No files matched your search

+32 -4
View File
@@ -31,6 +31,8 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
use super::options::{InstallDraft, OptionsView};
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
@@ -265,8 +267,10 @@ pub struct Download {
pub rows: Vec<VersionRow>,
/// 当前分类(从快照反推)
pub category: Category,
/// 选中的版本 id(`None` = 没选中 → 安装按钮禁用)
/// 选中的版本 id(`None` = 没选中 → 面板只给一句提示)
pub selected: Option<String>,
/// 安装选项区(M105):选中了版本、又没在装的时候才有
pub options: Option<OptionsView>,
/// 正在跑(或刚跑完)的安装
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
@@ -290,7 +294,17 @@ impl Download {
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
///
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self {
///
/// `draft` 是**用户还没确认的安装输入**(选中版本 + 加载器 + 加载器版本 + 实例名)。
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
/// 就不该还留着一个点不动的「安装」。
pub fn from_snapshot(
state: &AppState,
category: Category,
draft: &InstallDraft,
catalog: Catalog,
) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
let rows = state
.versions
@@ -313,8 +327,21 @@ impl Download {
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
// 那正是 P2-4 要它一眼可辨的理由。
.filter(|view| !view.finished());
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
let selected = install.as_ref().map(|view| view.version.clone());
// 选中项:安装中就是正在装的那个;否则是用户点的那个(草稿),且必须还在当前列表里。
let selected = install
.as_ref()
.map(|view| view.version.clone())
.or_else(|| draft.selected.clone())
.filter(|id| rows.iter().any(|row| &row.id == id));
// 选项区与安装面板**互斥**:在装的时候面板讲进度,讲不了选项。
let options = match &install {
Some(_) => None,
None => selected.as_ref().and_then(|id| {
rows.iter()
.find(|row| &row.id == id)
.map(|row| OptionsView::of(row, state, draft, catalog))
}),
};
Self {
rows,
@@ -322,6 +349,7 @@ impl Download {
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
category,
selected,
options,
install,
loaded: !state.versions.is_empty(),
}