fix(app-iced): M109 r1 —— CF 来源装入闸门、has_more 读最近一页、末行切半与文案/图标
复审 p1-2items 的两条 P1 与三条 P2,逐条收口。 `blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接 返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。 - 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英); - `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option<HitFacts>`(版本 / 加载器 / **来源**),两处调用点(产品路径、演示数据)一起改; - 判据同构于整合包的 `NeedsPipeline`:**点之前**禁用并给一条原因;CF 文件端点接通后删掉这条。 DTO 的 `has_more()` 判「本页装满」是对的,但 reducer 在 `offset>0` 时先 extend 再判, 被判定的是**累计条数** ⇒ 首页满 20 条之后恒真:「加载更多」永不消失、「没有更多了」在 真实翻页路径上不可达,且每点一次白发一次网络请求。 - `ContentState` 新增 `last_page_filled`(**先**按这一页记一位,再并进累计列表); - 页面的 `has_more` 读它(`&& !pending`);换条件(offset 归零)时清位; - 新增 `crates/presenter/src/state/content_tests.rs` 四条用例(state.rs 贴 400 行上限,拆文件): `a_short_last_page_clears_has_more` / `an_empty_last_page_keeps_has_more_false` / `changing_the_query_resets_the_page_flag` / `a_stale_batch_does_not_touch_the_page_flag`; - 变异自证:把判据改回「读累计」后前两条红(`末页不满 ⇒ 不许说还有更多`、`空页 ⇒ 没有更多`), 改回绿。 - **末行被 footer 切半**(复审定位的真因):`view.rs` 的 body 列漏了 `.spacing(..)`,而列表 纵向内边距是 0 ⇒ 视口底边正好切在半行上。补 `Space::X2`,并说明为什么必须给。 - **文案去掉开发者术语与内部编号**:`ContentSourceUnavailable` 不再写 `ULL_CF_API_KEY` (改「CurseForge 需要在设置里配置密钥才能搜索」);`ContentModpackNotReady` 不再写 mission 编号(改「整合包安装尚未开放」);host 侧那条 `InstallProgress` 失败文案同步去编号。 - **图标**:四类改为 `Folder` / `Plus` / `Restore` / `Expand`——不借用导航五枚(同屏撞形, 复审 P2-3)、不用填充字形(`no_hardcoded_tokens` 门禁);偏离表按**实际取值**重写,并补一条 「CF 的装入也是禁用态」的登记。 - 演示数据加 `ULL_CONTENT_STATE=cf` 一屏,出图证明 P1-1 的禁用态(`12-cf-not-wired-light.png`)。 门禁(逐条落盘退出码,不走管道):fmt 0 / clippy `-D warnings`(app-iced+presenter+i18n)0 / `cargo test -p unlucky-app-iced --lib` 0(451 passed)/ `cargo test -p unlucky-presenter` 0 (174 passed)/ `cargo test --workspace --exclude unlucky-app` 0。改动文件最大 397 行。 取证:`DevDocs/artifacts/m109/`(12 张,全部重出)
This commit is contained in:
1 parent
6b161227d8
commit
42099d4336
13 files changed
+246
-40
No files matched your search
@@ -42,9 +42,10 @@
|
||||
//!
|
||||
//! | # | 偏离 | 为什么 | 何时收口 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
|
||||
//! | 1 | 四种内容类型借用现有字形(`Folder` / `Plus` / `Restore` / `Expand`,见 `parts::kind_icon`) | 设计令牌的 28 枚图标里没有这四类内容,且**不许**借用导航那五枚(同屏撞形)、不许用填充字形(门禁) | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
|
||||
//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 |
|
||||
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 |
|
||||
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
|
||||
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
|
||||
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
|
||||
|
||||
mod demo;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, Status, Target};
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
@@ -155,11 +155,19 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
hits(kind, catalog, state != "incompatible")
|
||||
};
|
||||
// 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric
|
||||
let selected_index = if state == "incompatible" { 2 } else { 0 };
|
||||
// 「CF 未接通」那一屏:选中的那条来自 CurseForge(文件端点还没接,按钮必须禁用)
|
||||
let selected_index = match state.as_str() {
|
||||
"incompatible" => 2,
|
||||
"cf" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
let selected = list.get(selected_index).map(|hit| hit.id.clone());
|
||||
let blockers = blockers_of(
|
||||
list.get(selected_index)
|
||||
.map(|hit| (hit.versions.as_slice(), hit.loaders.as_slice())),
|
||||
list.get(selected_index).map(|hit| HitFacts {
|
||||
versions: hit.versions.as_slice(),
|
||||
loaders: hit.loaders.as_slice(),
|
||||
source: hit.source,
|
||||
}),
|
||||
kind,
|
||||
&targets,
|
||||
target.as_deref(),
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! 它们的所有者是 `app/state.rs` 的 `App::content`,本文件只借用。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
loader_facet, ContentKind, ContentResults, ContentSort, ContentSource, ContentState, LoaderKind,
|
||||
loader_facet, ContentKind, ContentSort, ContentSource, ContentState, LoaderKind,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
@@ -33,6 +33,7 @@ use crate::pages::download::Download;
|
||||
|
||||
mod items;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
|
||||
|
||||
@@ -133,7 +134,11 @@ impl Content {
|
||||
.find(|hit| hit.id == id)
|
||||
});
|
||||
let blockers = blockers_of(
|
||||
selected_hit.map(|hit| (hit.game_versions.as_slice(), hit.loaders.as_slice())),
|
||||
selected_hit.map(|hit| HitFacts {
|
||||
versions: hit.game_versions.as_slice(),
|
||||
loaders: hit.loaders.as_slice(),
|
||||
source: hit.source,
|
||||
}),
|
||||
page.kind,
|
||||
&targets,
|
||||
target.as_deref(),
|
||||
@@ -152,11 +157,8 @@ impl Content {
|
||||
selected: page.selected.clone(),
|
||||
status,
|
||||
total: content.results.as_ref().map_or(0, |results| results.total),
|
||||
has_more: content
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(ContentResults::has_more)
|
||||
&& !content.pending,
|
||||
// 读「最近一页是否装满」(不是累计条数):见 `ContentState::last_page_filled` 的文档
|
||||
has_more: content.last_page_filled && !content.pending,
|
||||
curseforge: content
|
||||
.results
|
||||
.as_ref()
|
||||
@@ -227,7 +229,7 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
|
||||
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
|
||||
/// **空列表 ≠ 不兼容**:站点没给数据时按「未知」处理,不许把「不知道」画成「不支持」。
|
||||
pub(super) fn blockers_of(
|
||||
hit: Option<(&[String], &[String])>,
|
||||
hit: Option<HitFacts<'_>>,
|
||||
kind: ContentKind,
|
||||
targets: &[Target],
|
||||
target: Option<&str>,
|
||||
@@ -239,13 +241,18 @@ pub(super) fn blockers_of(
|
||||
return vec![Blocker::NoTarget];
|
||||
};
|
||||
let mut blockers = Vec::new();
|
||||
// 整合包:不是「兼容不兼容」的问题,是这条路根本没有(M111)
|
||||
// 整合包:不是「兼容不兼容」的问题,是这条路根本没有(导入管线未落地)
|
||||
if kind.dir().is_none() {
|
||||
blockers.push(Blocker::NeedsPipeline);
|
||||
}
|
||||
let Some((versions, loaders)) = hit else {
|
||||
let Some(facts) = hit else {
|
||||
return blockers;
|
||||
};
|
||||
let (versions, loaders) = (facts.versions, facts.loaders);
|
||||
// 来源的文件端点没通:**必须在点之前**说清楚。放行等于「点完必然失败」(复审 P1-1)。
|
||||
if facts.source == ContentSource::Curseforge {
|
||||
blockers.push(Blocker::SourceNotWired);
|
||||
}
|
||||
if !versions.is_empty() && !versions.iter().any(|item| item == &target.mc_version) {
|
||||
blockers.push(Blocker::Version {
|
||||
needs: join_or_unknown(versions),
|
||||
|
||||
@@ -178,11 +178,27 @@ pub const fn loader_name(loader: LoaderKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中项的**判定事实**(版本 / 加载器 / 来源)。
|
||||
///
|
||||
/// 为什么另起一个结构:兼容性判据随着「宿主接通了什么」在长(M109 加了来源),
|
||||
/// 每次加一条都改一次 `blockers_of` 的形参列表,而调用点有两处(产品路径与演示数据)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct HitFacts<'a> {
|
||||
/// 支持的游戏版本(空 = 站点没给)
|
||||
pub versions: &'a [String],
|
||||
/// 支持的加载器(空 = 站点没给;**站点原样的小写 facet**)
|
||||
pub loaders: &'a [String],
|
||||
/// 来源(决定「文件端点通没通」)
|
||||
pub source: ContentSource,
|
||||
}
|
||||
|
||||
/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Blocker {
|
||||
/// 整包类内容不走「单文件进目录」这条路(M111 才有导入管线)
|
||||
/// 整包类内容不走「单文件进目录」这条路(导入管线还没落地)
|
||||
NeedsPipeline,
|
||||
/// 这个来源的**文件端点**还没接通(检索通了、拿到下载地址那一跳没通)
|
||||
SourceNotWired,
|
||||
/// 项目不支持目标实例的游戏版本
|
||||
Version {
|
||||
/// 项目支持的游戏版本(人读,最多取三个)
|
||||
@@ -212,6 +228,7 @@ impl Blocker {
|
||||
Key::ContentIncompatibleLoader,
|
||||
&[("loader", loader_name(*loader))],
|
||||
),
|
||||
Self::SourceNotWired => catalog.text(Key::ContentSourceNotWired).to_string(),
|
||||
Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ fn targets() -> Vec<Target> {
|
||||
|
||||
fn blockers_of_hit(hit: &ContentHit, kind: ContentKind, target: Option<&str>) -> Vec<Blocker> {
|
||||
blockers_of(
|
||||
Some((hit.game_versions.as_slice(), hit.loaders.as_slice())),
|
||||
Some(HitFacts {
|
||||
versions: hit.game_versions.as_slice(),
|
||||
loaders: hit.loaders.as_slice(),
|
||||
source: hit.source,
|
||||
}),
|
||||
kind,
|
||||
&targets(),
|
||||
target,
|
||||
@@ -89,6 +93,21 @@ fn site_data_that_is_missing_never_blocks() {
|
||||
assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curseforge_project_is_blocked_because_its_file_endpoint_is_not_wired() {
|
||||
// 复审 P1-1:检索通了、文件端点没通 ⇒ 必须在**点之前**禁用并说明,
|
||||
// 否则「切到 CF 源后每条都必然失败,UI 一点提示都没有」。
|
||||
let mut cf = hit(&["1.20.1"], &["fabric"]);
|
||||
cf.source = ContentSource::Curseforge;
|
||||
assert_eq!(
|
||||
blockers_of_hit(&cf, ContentKind::Mod, Some("fabric-1.20.1")),
|
||||
vec![Blocker::SourceNotWired]
|
||||
);
|
||||
// 同样的版本/加载器,Modrinth 那条就没有这条原因(否则等于把整页禁死)
|
||||
let modrinth = hit(&["1.20.1"], &["fabric"]);
|
||||
assert!(blockers_of_hit(&modrinth, ContentKind::Mod, Some("fabric-1.20.1")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modpacks_are_blocked_by_the_missing_pipeline_not_by_compatibility() {
|
||||
let hit = hit(&["1.20.1"], &["fabric"]);
|
||||
|
||||
@@ -299,12 +299,17 @@ fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, Pag
|
||||
/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标,
|
||||
/// 已在报告里登记为偏离)。
|
||||
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
|
||||
// 两条硬约束(都来自门禁与复审):
|
||||
// ① `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(`tests/no_hardcoded_tokens`);
|
||||
// ② **不许与全局导航撞形**:`Instances`/`Download`/`Logs`/`Settings`/`Ai` 五枚在同一个屏上
|
||||
// 就在左边那条轨里,页内四类再借用它们会让「同形不同义」(复审 P2-3)。
|
||||
//
|
||||
// 剩下的描边字形里挑语义最近的:
|
||||
// `Folder`(包=文件夹)/ `Plus`(加装内容=加号)/ `Restore`(双层方框≈贴图分层)/ `Expand`(四散光线)。
|
||||
match kind {
|
||||
unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder,
|
||||
unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances,
|
||||
unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download,
|
||||
// `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens):
|
||||
// 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇
|
||||
unlucky_presenter::dto::ContentKind::Mod => IconKind::Plus,
|
||||
unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Restore,
|
||||
unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,18 +176,19 @@ fn main_column<'a>(
|
||||
{
|
||||
state
|
||||
} else {
|
||||
{
|
||||
let mut list = column![parts::list(ui, catalog, model, rails)]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||
if !rails.tier.has_detail() {
|
||||
list = list
|
||||
.push(divider::full(ui))
|
||||
.push(parts::detail(ui, catalog, model));
|
||||
}
|
||||
list.push(parts::more(ui, catalog, model)).into()
|
||||
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
|
||||
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
|
||||
let mut list = column![parts::list(ui, catalog, model, rails)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||
if !rails.tier.has_detail() {
|
||||
list = list
|
||||
.push(divider::full(ui))
|
||||
.push(parts::detail(ui, catalog, model));
|
||||
}
|
||||
list.push(parts::more(ui, catalog, model)).into()
|
||||
};
|
||||
|
||||
column![head, body]
|
||||
|
||||
Reference in new issue
Block a user