diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 2aab75f..806492a 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -21,13 +21,12 @@ use unlucky_presenter::dto::ContentSort; use unlucky_presenter::router::Route; use crate::app::components::buttons::{self, Button}; -use crate::app::components::feedback::{empty_state, EmptyState}; use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::app::components::selection::{chip, Chip}; use crate::theme::Ui; use super::layout::Rails; -use super::model::{Content, Hit, Status}; +use super::model::{Content, Hit}; mod detail; mod filters; @@ -209,111 +208,6 @@ pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element ) } -pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { - centered( - column![ - frame::text_role_centered( - ui, - TypeRole::TitleMedium, - true, - catalog.text(Key::ContentLoading), - ui.color(Role::OnSurface) - ), - frame::text_role_centered( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::ContentLoadingHint), - ui.color(Role::OnSurfaceVariant) - ), - buttons::text( - ui, - Button::new( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry) - ) - ), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_x(Alignment::Center), - ) -} - -/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。 -pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { - centered(empty_state( - ui, - EmptyState::new( - IconKind::Search, - catalog.text(Key::ContentEmptyTitle), - catalog.text(Key::ContentEmptyBody), - ) - .action( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry), - ), - )) -} - -/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。 -pub(super) fn failed<'a>( - ui: &'a Ui, - catalog: Catalog, - reason: &'a str, -) -> Element<'a, PageMessage> { - centered( - column![ - row![ - frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), - frame::text_role( - ui, - TypeRole::TitleSmall, - true, - catalog.text(Key::ContentFailedTitle), - ui.color(Role::Error) - ), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - reason, - ui.color(Role::OnSurfaceVariant) - ), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::ContentFailedBody), - ui.color(Role::OnSurfaceVariant) - ), - buttons::filled( - ui, - Button::new( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry) - ) - .leading(IconKind::Refresh) - ), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_x(Alignment::Center), - ) -} - -fn centered<'a>(content: impl Into>) -> Element<'a, PageMessage> { - container(content) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .into() -} - /// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标, /// 已在报告里登记为偏离)。 pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind { @@ -341,70 +235,6 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str { } } -/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。 -pub(super) fn offline<'a>( - ui: &'a Ui, - catalog: Catalog, - reason: &'a str, -) -> Element<'a, PageMessage> { - centered( - column![ - row![ - frame::glyph( - IconKind::Warn, - ui.color(Role::OnSurfaceVariant), - frame::ICON_PX - ), - frame::text_role( - ui, - TypeRole::TitleSmall, - true, - catalog.text(Key::ContentOfflineTitle), - ui.color(Role::OnSurface) - ), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::ContentOfflineBody), - ui.color(Role::OnSurfaceVariant) - ), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - reason, - ui.color(Role::OnSurfaceVariant) - ), - buttons::outlined( - ui, - Button::new( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry) - ) - .leading(IconKind::Refresh), - ), - ] - .spacing(ui.space(Space::X2)) - .align_x(Alignment::Center), - ) -} +mod status; -/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。 -pub(super) fn status_body<'a>( - ui: &'a Ui, - catalog: Catalog, - model: &'a Content, -) -> Option> { - match &model.status { - Status::Loading => Some(loading(ui, catalog)), - Status::Empty => Some(empty(ui, catalog)), - Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())), - // M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同) - Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())), - Status::Ready => None, - } -} +pub(crate) use status::status_body; diff --git a/crates/app-iced/src/pages/content/parts/status.rs b/crates/app-iced/src/pages/content/parts/status.rs new file mode 100644 index 0000000..79bad48 --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/status.rs @@ -0,0 +1,200 @@ +//! 内容库页的**状态层**:加载 / 空 / 失败 / 未联网 / 状态分派。 +//! +//! 从 `parts.rs` 拆出(M161 合入后那份越过了 400 行;状态层本来就是「一屏只有一个」的 +//! 独立东西,拆开后 `parts.rs` 只剩筛选条与列表)。四态各一支,判据在 +//! `model::Status`,这里只负责画。 +//! +//! **未联网(M162)与失败是两屏**:前者说「要么联网、要么真的没有」,后者说「拉取失败了」。 + +use iced::widget::{column, container, 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::feedback::{empty_state, EmptyState}; +use crate::theme::Ui; + +use super::super::model::{Content, Status}; +use super::intent; +use crate::pages::content::Message as ContentMessage; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +pub(crate) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered( + column![ + frame::text_role_centered( + ui, + TypeRole::TitleMedium, + true, + catalog.text(Key::ContentLoading), + ui.color(Role::OnSurface) + ), + frame::text_role_centered( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentLoadingHint), + ui.color(Role::OnSurfaceVariant) + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。 +pub(crate) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered(empty_state( + ui, + EmptyState::new( + IconKind::Search, + catalog.text(Key::ContentEmptyTitle), + catalog.text(Key::ContentEmptyBody), + ) + .action( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry), + ), + )) +} + +/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。 +pub(crate) fn failed<'a>( + ui: &'a Ui, + catalog: Catalog, + reason: &'a str, +) -> Element<'a, PageMessage> { + centered( + column![ + row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::ContentFailedTitle), + ui.color(Role::Error) + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentFailedBody), + ui.color(Role::OnSurfaceVariant) + ), + buttons::filled( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + .leading(IconKind::Refresh) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +fn centered<'a>(content: impl Into>) -> Element<'a, PageMessage> { + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} + +/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。 +pub(crate) fn offline<'a>( + ui: &'a Ui, + catalog: Catalog, + reason: &'a str, +) -> Element<'a, PageMessage> { + centered( + column![ + row![ + frame::glyph( + IconKind::Warn, + ui.color(Role::OnSurfaceVariant), + frame::ICON_PX + ), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::ContentOfflineTitle), + ui.color(Role::OnSurface) + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentOfflineBody), + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant) + ), + buttons::outlined( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + .leading(IconKind::Refresh), + ), + ] + .spacing(ui.space(Space::X2)) + .align_x(Alignment::Center), + ) +} + +/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。 +pub(crate) fn status_body<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Option> { + match &model.status { + Status::Loading => Some(loading(ui, catalog)), + Status::Empty => Some(empty(ui, catalog)), + Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())), + // M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同) + Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())), + Status::Ready => None, + } +} diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 819d645..d611b2e 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -139,10 +139,7 @@ pub enum CoreEvent { ContentResults(Box), /// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画 ContentFailed(Box), - /// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道。 - /// - /// `project` 是**请求时选中的那个 id**:迟到的结果要靠它判「还算不算当前这次」 - /// (用户可能已经切到别的项目了)。 + /// 内容库项目详情 + 版本列表(M162):`project` 是**请求时**选中的 id,迟到结果靠它判。 ContentDetail { /// 请求时选中的项目 id project: String, diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index cc4b897..d0187ed 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -296,15 +296,11 @@ impl AppState { // M113: import/export CoreEvent::Pack(event) => self.pack.apply(*event), CoreEvent::ContentResults(results) => self.apply_content(*results), - CoreEvent::ContentFailed(failure) => { - self.content.pending = false; - self.content.failed = Some(*failure); + CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), + // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) + CoreEvent::ContentDetail { project, result } => { + self.apply_content_detail(&project, *result); } - // M162:详情/版本列表(选中项才拉;迟到结果按项目 id 丢) - CoreEvent::ContentDetail { project, result } => match *result { - Ok(detail) => self.content.apply_detail(&project, detail), - Err(failure) => self.content.fail_detail(&project, failure), - }, } } diff --git a/crates/presenter/src/state/content.rs b/crates/presenter/src/state/content.rs index d4ca6c7..8fb6522 100644 --- a/crates/presenter/src/state/content.rs +++ b/crates/presenter/src/state/content.rs @@ -3,7 +3,7 @@ //! 从 `state.rs` 拆出(那个文件重新合流 M105 之后贴着 400 行上限)。放在 `impl AppState` 的 //! **另一个 impl 块**里,路径与可见性都不变:`apply_core` 直接调 `self.apply_content(..)`。 -use crate::dto::{ContentQuery, ContentResults}; +use crate::dto::{ContentDetail, ContentFailure, ContentQuery, ContentResults}; use super::AppState; @@ -31,6 +31,25 @@ impl AppState { } } + /// 检索失败落地(M162 起带大类):清掉「在飞」并记下失败。 + pub(super) fn apply_content_failure(&mut self, failure: ContentFailure) { + self.content.pending = false; + self.content.failed = Some(failure); + } + + /// 详情事件落地(M162):`Ok` 走 `apply_detail`、`Err` 走 `fail_detail`,两者都按 + /// **请求时的项目 id** 判迟到(用户可能已经切到别的项目了)。 + pub(super) fn apply_content_detail( + &mut self, + project: &str, + result: Result, + ) { + match result { + Ok(detail) => self.content.apply_detail(project, detail), + Err(failure) => self.content.fail_detail(project, failure), + } + } + /// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。 pub fn set_content_query(&mut self, query: ContentQuery) { self.content.pending = true;