feat(ui): Phase 2 C2 — Mock 消息模型 + Message list + Empty state

- ui/message-card.slint: export enum Priority + struct MessageItem +
  MessageCard 组件(§7 优先级圆点 + 标题 + 应用·预览 + 相对时间 +
  URL 指示)
- ui/message-list.slint: VerticalLayout + for 渲染 MessageCard
  (避开 std-widgets 的 include_paths 配置需求)
- ui/empty-state.slint: §9 ✦ glyph(Path 自绘)+ "You're all caught up"
  + Test Notification 按钮
- ui/app.slint: messages.length 切换 list / empty-state;点击回
  调到 MainWindow 顶层 callback
- src/models.rs: mock_messages() 生成 12 条覆盖 4 个 priority 的
  假消息;format_relative() 输出 "2 min ago" / Yesterday 等
- src/lib.rs: ui.set_messages(mock_messages_model()) 装载;wire
  on_test_notification_clicked / on_message_card_clicked

避坑:
- Slint 1.18 不允许覆盖 Rectangle 内建 border-color,命名用 card-border
- ModelRc::from 需要 Rc<VecModel<T>> 不是裸 VecModel

验证:cargo build / build --release 都 exit 0,0 warning
- debug   26.55 MB, subsystem = WINDOWS_CUI
- release  7.94 MB, subsystem = WINDOWS_GUI
This commit is contained in:
2026-09-19 04:39:24 +08:00
parent 49cbd9e371
commit 11a122216b
6 changed files with 436 additions and 21 deletions
+19 -5
View File
@@ -1,17 +1,18 @@
//! Windows Gotify Client (Rust) — core library.
//!
//! Phase 2 C1 only:
//! Phase 2 surface:
//! - [`error`]: AppError / AppResult
//! - [`logging`]: tracing-subscriber init
//! - [`models`]: MessageItem / Priority (re-exported from slint-generated
//! types) + the mock data feed used until Phase 3 lands.
//! - [`run`]: builds the MainWindow and runs its event loop
//!
//! Larger subsystems (gotify / storage / windows integration / mock data)
//! land in later commits per AGENTS.md §57.
//! Larger subsystems (gotify / storage / windows integration) land in later
//! phases per AGENTS.md §57.
pub mod error;
pub mod logging;
use slint::ComponentHandle;
pub mod models;
slint::include_modules!();
@@ -31,6 +32,19 @@ pub fn run() -> error::AppResult<()> {
// of the Settings page and to `state.json` so the choice persists.
ui.set_theme(AppearanceMode::Dark);
// Phase 2 C2: hook up the two top-level callbacks. C6/Phase 6 swap these
// for real Windows toast firing.
ui.on_test_notification_clicked(|| {
tracing::info!("empty-state: Test Notification clicked (stub)");
});
ui.on_message_card_clicked(|idx| {
tracing::debug!(idx, "message card clicked");
});
// Phase 2 C2: feed mock data into the message list. Phase 3 swaps this
// for an HTTP catch-up + WebSocket live stream.
ui.set_messages(models::mock_messages_model());
ui.run()
.map_err(|e| error::AppError::Ui(e.to_string()))
}
+156
View File
@@ -0,0 +1,156 @@
//! Domain model + mock data feed for Phase 2 C2.
//!
//! `MessageItem` and `Priority` are NOT defined here — both come from
//! `ui/message-card.slint` via `slint::include_modules!()` so the Slint view
//! and the Rust side agree on shape without duplicating the schema.
//!
//! This module only owns:
//! - [`format_relative`]: timestamp -> "2 min ago" / "Yesterday" / date
//! - [`mock_messages`]: the placeholder feed used until Phase 3 wires
//! real Gotify data into the model.
use std::time::{SystemTime, UNIX_EPOCH};
use slint::{ModelRc, VecModel};
use crate::MessageItem;
/// Wall-clock now, as Unix seconds. Falls back to 0 if the system clock is
/// somehow pre-1970, which should never happen on a real Windows install.
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Render `unix` as a coarse relative-time string for the message list:
/// "just now" / "N min ago" / "N hours ago" / "Yesterday" / "MM-DD".
///
/// Used only by the mock data builder; the real formatter once we have a
/// clock-driven timestamp stream is the same function with a different
/// `now` source.
fn format_relative(unix: i64, now: i64) -> String {
let diff = now.saturating_sub(unix);
if diff < 0 {
// Future timestamp (clock skew or a synthetic test message).
return "just now".into();
}
if diff < 60 {
return "just now".into();
}
if diff < 3600 {
let m = diff / 60;
return format!("{m} min ago");
}
if diff < 86_400 {
let h = diff / 3600;
return format!("{h} hours ago");
}
if diff < 86_400 * 2 {
return "Yesterday".into();
}
// Beyond yesterday, fall back to a compact date. No strftime / chrono
// yet — keep the dependency footprint small until Phase 5 (Storage)
// brings serde / anyhow / etc. anyway.
let secs_per_day = 86_400;
let days_ago = diff / secs_per_day;
if days_ago < 7 {
return format!("{days_ago} days ago");
}
format!("{}", unix % 100_000_000) // placeholder until Phase 5 introduces proper date fmt.
}
/// Build the Phase 2 mock feed: 12 messages covering the priority spectrum
/// (Low / Normal / High / Max), three different apps, time spread across
/// "just now" through "3 days ago".
pub fn mock_messages() -> Vec<MessageItem> {
let now = now_unix();
let mk = |id: i32,
app_id: i32,
app_name: &str,
title: &str,
body: &str,
priority: crate::Priority,
seconds_ago: i64,
url: Option<&str>| MessageItem {
id: id as i32,
app_id: app_id as i32,
app_name: app_name.into(),
title: title.into(),
body: body.into(),
priority,
timestamp_unix: (now - seconds_ago) as i32,
relative_time: format_relative(now - seconds_ago, now).into(),
has_click_url: url.is_some(),
click_url: url.unwrap_or("").into(),
};
vec![
mk(1, 1, "GitLab Runner",
"Build succeeded",
"Deploy v1.4.2 to production completed in 3m 12s.",
crate::Priority::Normal, 90,
Some("https://gitlab.example.com/pipelines/123")),
mk(2, 2, "Gotify SMS",
"Incoming SMS",
"Mom: 今晚回家吃饭吗?",
crate::Priority::Normal, 600,
None),
mk(3, 3, "青龙",
"签到失败",
"JD 任务 \"摇京豆\" 返回 -1: cookie 已过期。",
crate::Priority::High, 1_400,
None),
mk(4, 4, "Airflow",
"DAG \"dsa-daily-report\" failed",
"Task `fetch_quotes` exited 137 (OOM killed).",
crate::Priority::Max, 1_900,
Some("https://airflow.example.com/dags/dsa-daily-report")),
mk(5, 5, "Galaxy S9",
"Low battery",
"Battery is at 15%, charger recommended.",
crate::Priority::Low, 3_600,
None),
mk(6, 4, "Airflow",
"DAG \"stock-analysis\" completed",
"20 symbols processed, results in /reports/2026-09-19/.",
crate::Priority::Low, 7_200,
None),
mk(7, 2, "Gotify SMS",
"Incoming SMS",
"快递员: 您的包裹已放在驿站。",
crate::Priority::Normal, 14_400,
None),
mk(8, 1, "GitLab Runner",
"Build failed",
"Compile error in `crates/api/src/lib.rs:142`: mismatched types.",
crate::Priority::High, 22_000,
Some("https://gitlab.example.com/pipelines/124")),
mk(9, 3, "青龙",
"脚本运行成功",
"jd_bean_sign.js 已完成,获得 12 京豆。",
crate::Priority::Low, 43_200,
None),
mk(10, 4, "Airflow",
"DAG \"backup-nas\" completed",
"Backup of /volume1 to /volume2 finished, 142 GB transferred.",
crate::Priority::Normal, 64_800,
None),
mk(11, 5, "Galaxy S20",
"New SMS",
"运营商: 您本月流量已使用 80%. ",
crate::Priority::Low, 86_400,
None),
mk(12, 4, "Airflow",
"DAG \"weekly-report\" completed",
"Weekly summary exported to /reports/weekly/2026-W38.pdf.",
crate::Priority::Normal, 172_800,
None),
]
}
/// Wrap a `Vec<MessageItem>` in a Slint model ready for `MainWindow.set_messages`.
pub fn mock_messages_model() -> ModelRc<MessageItem> {
ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages())))
}