feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态
core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。
core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。
core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。
presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。
app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。
测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
This commit is contained in:
1 parent
0241f793b2
commit
81278f55af
50 files changed
+3794
-222
No files matched your search
@@ -17,7 +17,8 @@
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
@@ -76,7 +77,12 @@ impl App {
|
||||
self.with_query(|query| query.loader = loader)
|
||||
}
|
||||
pages::content::Message::Select(id) => {
|
||||
self.content.selected = Some(id);
|
||||
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
|
||||
self.content.selected = Some(id.clone());
|
||||
self.state.content.request_detail(&id);
|
||||
if let Some(request) = self.content_detail_request() {
|
||||
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::SetTarget(id) => {
|
||||
@@ -167,6 +173,23 @@ impl App {
|
||||
///
|
||||
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
|
||||
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
|
||||
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
|
||||
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
let hit = model.selected_hit()?;
|
||||
let target = model
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| Some(&target.id) == model.target.as_ref());
|
||||
Some(ContentDetailRequest {
|
||||
project: hit.id.clone(),
|
||||
source: hit.source,
|
||||
game_version: target.map(|target| target.mc_version.clone()),
|
||||
loader: target.map(|target| target.loader),
|
||||
})
|
||||
}
|
||||
|
||||
fn content_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
|
||||
//!
|
||||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
|
||||
use super::model::{
|
||||
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
|
||||
};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
@@ -147,9 +149,45 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
let targets = targets();
|
||||
let target = Some("survival".to_string());
|
||||
let empty = state == "empty";
|
||||
// M162:「未联网」那一屏(与空态分开画)
|
||||
let offline = state == "offline";
|
||||
// M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`)
|
||||
let demo_versions = if empty || offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.11".to_string(),
|
||||
kind: "release".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.11.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.11"),
|
||||
("kind", "Release"),
|
||||
("date", "2025-06-01"),
|
||||
],
|
||||
),
|
||||
},
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.10".to_string(),
|
||||
kind: "beta".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.10.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.10"),
|
||||
("kind", "Beta"),
|
||||
("date", "2025-05-20"),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
};
|
||||
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
|
||||
let installing = state == "install";
|
||||
let list = if empty {
|
||||
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络)
|
||||
let list = if empty || offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
hits(kind, catalog, state != "incompatible")
|
||||
@@ -196,7 +234,13 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
},
|
||||
hits: list,
|
||||
selected,
|
||||
status: if empty { Status::Empty } else { Status::Ready },
|
||||
status: if offline {
|
||||
Status::Offline("connect timed out after 10s".to_string())
|
||||
} else if empty {
|
||||
Status::Empty
|
||||
} else {
|
||||
Status::Ready
|
||||
},
|
||||
total: if empty { 0 } else { 1_284 },
|
||||
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
|
||||
has_more: !empty,
|
||||
@@ -211,6 +255,8 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
None
|
||||
},
|
||||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||||
available_versions: demo_versions,
|
||||
versions_note: None,
|
||||
draft: if empty {
|
||||
"quarkonium".to_string()
|
||||
} else {
|
||||
|
||||
@@ -27,7 +27,7 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::pages::download::Download;
|
||||
|
||||
@@ -35,7 +35,7 @@ mod items;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -75,6 +75,10 @@ pub struct Content {
|
||||
pub draft: String,
|
||||
/// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检
|
||||
pub pack: super::packui::Pack,
|
||||
/// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给)
|
||||
pub available_versions: Vec<VersionRow>,
|
||||
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
|
||||
pub versions_note: Option<String>,
|
||||
}
|
||||
|
||||
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
|
||||
@@ -156,6 +160,9 @@ impl Content {
|
||||
catalog,
|
||||
);
|
||||
|
||||
let (available_versions, versions_note) =
|
||||
detail_view(content, page.selected.as_deref(), catalog);
|
||||
|
||||
Self {
|
||||
kind: page.kind,
|
||||
query: ContentQueryView {
|
||||
@@ -183,6 +190,8 @@ impl Content {
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
pack,
|
||||
available_versions,
|
||||
versions_note,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +222,13 @@ impl Content {
|
||||
|
||||
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
|
||||
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
|
||||
if let Some(reason) = &content.failed {
|
||||
return Status::Failed(reason.clone());
|
||||
if let Some(failure) = &content.failed {
|
||||
// M162:没联网单独一态(空列表不能冒充「没有结果」)
|
||||
return if failure.is_offline() {
|
||||
Status::Offline(failure.detail.clone())
|
||||
} else {
|
||||
Status::Failed(failure.detail.clone())
|
||||
};
|
||||
}
|
||||
if content.pending || content.results.is_none() {
|
||||
return Status::Loading;
|
||||
@@ -237,6 +251,57 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
|
||||
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
|
||||
}
|
||||
|
||||
/// 选中项目的版本列表 + 一句说明(详情通道;迟到结果按项目 id 丢)。
|
||||
///
|
||||
/// 三条优先级:**有结果就画结果** → 在飞就画「正在取」 → 失败才画原因。选中项变了而结果是
|
||||
/// 上一个项目的,一律当「没有」(`detail.project != selected`)——否则会画着 A 的版本列表
|
||||
/// 却高亮着 B。
|
||||
fn detail_view(
|
||||
content: &ContentState,
|
||||
selected: Option<&str>,
|
||||
catalog: Catalog,
|
||||
) -> (Vec<VersionRow>, Option<String>) {
|
||||
let Some(selected) = selected else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
if let Some(detail) = content.detail.as_ref().filter(|d| d.project == selected) {
|
||||
let rows = detail
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| VersionRow {
|
||||
number: version.number.clone(),
|
||||
kind: version.kind.clone(),
|
||||
file_name: version.file_name.clone(),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", version.number.as_str()),
|
||||
("kind", capitalize(&version.kind).as_str()),
|
||||
("date", version.published.as_str()),
|
||||
],
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
return (rows, None);
|
||||
}
|
||||
if content.detail_pending {
|
||||
return (
|
||||
Vec::new(),
|
||||
Some(catalog.text(Key::ContentDetailFetching).to_string()),
|
||||
);
|
||||
}
|
||||
match &content.detail_failed {
|
||||
Some(failure) => (
|
||||
Vec::new(),
|
||||
Some(catalog.render(
|
||||
Key::ContentDetailFetchFailed,
|
||||
&[("reason", failure.detail.as_str())],
|
||||
)),
|
||||
),
|
||||
None => (Vec::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
|
||||
///
|
||||
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
|
||||
|
||||
@@ -45,13 +45,34 @@ pub enum Status {
|
||||
Empty,
|
||||
/// 失败(带一句人读原因)
|
||||
Failed(String),
|
||||
/// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏,
|
||||
/// 说清「要么联网要么没结果」这件事不可能靠空列表表达。
|
||||
Offline(String),
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// 失败了吗(视图用它决定画错误面板还是列表)。
|
||||
pub const fn failed(&self) -> bool {
|
||||
matches!(self, Self::Failed(_))
|
||||
matches!(self, Self::Failed(_) | Self::Offline(_))
|
||||
}
|
||||
|
||||
/// 未联网吗(视图据此画离线态而不是通用失败态)。
|
||||
pub const fn offline(&self) -> bool {
|
||||
matches!(self, Self::Offline(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表的一行(M162:详情通道拉回来的 `ContentVersionView`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionRow {
|
||||
/// 版本号(`mc1.20.1-0.5.11`)
|
||||
pub number: String,
|
||||
/// 版本类型(`release` / `beta` / `alpha`,站点原文)
|
||||
pub kind: String,
|
||||
/// 文件名(`None` = 站点没给)
|
||||
pub file_name: Option<String>,
|
||||
/// 已经求好文案的一行(`版本号 · 类型 · 日期`)
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use super::*;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
|
||||
LoaderKind,
|
||||
ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery,
|
||||
ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind,
|
||||
};
|
||||
|
||||
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
|
||||
@@ -200,10 +200,69 @@ fn the_four_states_are_derived_from_three_fields() {
|
||||
#[test]
|
||||
fn a_failure_outranks_the_loading_state() {
|
||||
let mut state = state_with(None, true);
|
||||
state.content.failed = Some("网络不通".to_string());
|
||||
state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503"));
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
|
||||
assert_eq!(model.status, Status::Failed("站点 503".to_string()));
|
||||
assert!(model.status.failed());
|
||||
assert!(!model.status.offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_network_is_its_own_state_not_an_empty_list() {
|
||||
// M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。
|
||||
let mut state = state_with(None, false);
|
||||
state.content.results = Some(results(Vec::new(), 0));
|
||||
state.content.failed = Some(ContentFailure::offline("connect timed out"));
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(
|
||||
model.status,
|
||||
Status::Offline("connect timed out".to_string())
|
||||
);
|
||||
assert!(model.status.offline());
|
||||
assert!(model.status.failed());
|
||||
assert_ne!(model.status, Status::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_come_from_the_detail_channel_of_the_selected_project() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
let mut page = PageState {
|
||||
selected: Some("p1".to_string()),
|
||||
..PageState::default()
|
||||
};
|
||||
// 详情还没到:说明「正在取」,不是「一个版本都没有」
|
||||
state.content.request_detail("p1");
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
assert!(model.versions_note.is_some());
|
||||
|
||||
state.content.apply_detail(ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
description: "fast".to_string(),
|
||||
body: String::new(),
|
||||
versions: vec![ContentVersionView {
|
||||
id: "v1".to_string(),
|
||||
name: "Sodium 0.6.0".to_string(),
|
||||
number: "mc1.20.1-0.6.0".to_string(),
|
||||
kind: "release".to_string(),
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
published: "2026-02-01T00:00:00Z".to_string(),
|
||||
file_name: Some("sodium.jar".to_string()),
|
||||
file_size: Some(2048),
|
||||
}],
|
||||
});
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert_eq!(model.available_versions.len(), 1);
|
||||
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
|
||||
assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0"));
|
||||
assert!(model.versions_note.is_none());
|
||||
|
||||
// 换了选中项:上一个项目的版本列表不许画在这个项目下面
|
||||
page.selected = Some("p2".to_string());
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -341,6 +341,58 @@ 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),
|
||||
)
|
||||
}
|
||||
|
||||
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
|
||||
pub(super) fn status_body<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -351,6 +403,8 @@ pub(super) fn status_body<'a>(
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,39 @@ pub(crate) fn detail<'a>(
|
||||
|
||||
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
|
||||
|
||||
// M162:可用版本(详情通道拉的;最多画 5 行——列表页负责「多」,这里只负责「有哪些」)
|
||||
if model.available_versions.is_empty() {
|
||||
if let Some(note) = model.versions_note.as_deref() {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
note,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let count = model.available_versions.len().to_string();
|
||||
let mut versions = column![frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
catalog.render(Key::ContentDetailVersionCount, &[("count", count.as_str())]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)]
|
||||
.spacing(ui.space(Space::X1));
|
||||
for version in model.available_versions.iter().take(5) {
|
||||
versions = versions.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
version.line.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
body = body.push(versions);
|
||||
}
|
||||
|
||||
// 目标实例:一行 chip(选中的那个高亮)
|
||||
if model.targets.is_empty() {
|
||||
body = body.push(frame::text_role(
|
||||
|
||||
Reference in new issue
Block a user