Skip to main content

yggdrasil/utils/
time.rs

1//! 跨平台时间/睡眠工具。
2//!
3//! 根据目标架构分别实现:
4//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout` / `Date.now()`。
5//! - 其他平台:使用 `tokio::time::sleep` / `chrono::Utc`。
6//!
7//! 相对时间分档(`relative_label_from_millis` / `format_relative_time_iso`)由
8//! 前端待审核评论展示与服务端评论预渲染共享,保证两端口径一致。
9
10use chrono::DateTime;
11
12/// 异步睡眠指定毫秒数。
13///
14/// WASM 端用 `js_sys::Promise` + `web_sys::Window::set_timeout_*` 构造,
15/// 避免 `js_sys::eval` 字符串求值。全项目统一的 sleep 入口。
16///
17/// `setTimeout` 的 delay 参数是 i32,超过 `i32::MAX` 会被浏览器立即触发;这里 clamp
18/// 到安全上限,既避免 `u32 -> i32` 转换溢出 panic,也贴合 setTimeout 的合法范围。
19#[cfg(target_arch = "wasm32")]
20pub async fn sleep_ms(ms: u32) {
21    use wasm_bindgen::JsCast;
22    use wasm_bindgen_futures::JsFuture;
23    let promise = js_sys::Promise::new(&mut |resolve, _| {
24        // ms 是 u32,setTimeout 接受 i32;clamp 到 i32::MAX(约 24.8 天)避免溢出。
25        let delay = ms.min(i32::MAX as u32) as i32;
26        let window = web_sys::window().expect("sleep_ms 必须在浏览器上下文中调用:无 window");
27        window
28            .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve.unchecked_into(), delay)
29            .expect("setTimeout with a number delay cannot fail per WebIDL");
30    });
31    let _ = JsFuture::from(promise).await;
32}
33
34/// 异步睡眠指定毫秒数(原生 tokio 版本)。
35///
36/// 仅在 `server` feature 启用且非 wasm32 目标下编译。`tokio` 是 server-only 的
37/// optional 依赖(见 Cargo.toml),不可用 `#[cfg(not(target_arch = "wasm32"))]`——
38/// 那样会在「非 wasm32 主机 + 仅 web feature」组合下误激活,此时 tokio 未引入,
39/// 导致编译失败(此 bug 曾被 `[dev-dependencies] tokio` 掩盖,发布构建才暴露)。
40#[cfg(all(feature = "server", not(target_arch = "wasm32")))]
41pub async fn sleep_ms(ms: u32) {
42    tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await;
43}
44
45/// `sleep_ms` 的占位 stub,仅用于「非 wasm32 且非 server」的无效构建组合。
46///
47/// 此组合(如非 wasm32 主机执行 `cargo build --features web`)不是有效部署目标——
48/// web feature 的真实构建目标就是 wasm32,会走上面的 JS setTimeout 分支。
49/// 此 stub 仅保证符号可编译,永远不会在有效运行时被调用;若被调用说明部署配置错误。
50#[cfg(all(not(feature = "server"), not(target_arch = "wasm32")))]
51pub async fn sleep_ms(_ms: u32) {
52    panic!("sleep_ms 在非 wasm32 且非 server 的无效构建组合下被调用:请检查 feature 配置");
53}
54
55/// 获取当前时间戳(毫秒)。
56///
57/// WASM 端使用 `js_sys::Date::now()`,服务端回退到 `chrono::Utc`。
58pub fn now_millis() -> i64 {
59    #[cfg(target_arch = "wasm32")]
60    {
61        js_sys::Date::now() as i64
62    }
63    #[cfg(not(target_arch = "wasm32"))]
64    {
65        chrono::Utc::now().timestamp_millis()
66    }
67}
68
69/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。
70///
71/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用,
72/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。
73///
74/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。
75/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。
76pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) {
77    let seconds = delta_millis / 1000;
78
79    let label = if seconds < 60 {
80        "刚刚".to_string()
81    } else {
82        let minutes = seconds / 60;
83        if minutes < 60 {
84            format!("{} 分钟前", minutes)
85        } else {
86            let hours = minutes / 60;
87            if hours < 24 {
88                format!("{} 小时前", hours)
89            } else {
90                let days = hours / 24;
91                if days < 30 {
92                    format!("{} 天前", days)
93                } else {
94                    // 超过 30 天直接显示日期,下方 absolute 复用
95                    String::new()
96                }
97            }
98        }
99    };
100
101    // 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。
102    let absolute = DateTime::parse_from_rfc3339(created_iso)
103        .map(|dt| dt.format("%Y-%m-%d").to_string())
104        .unwrap_or_default();
105
106    let label = if label.is_empty() {
107        absolute.clone()
108    } else {
109        label
110    };
111    (label, absolute)
112}
113
114/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。
115///
116/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。
117pub fn format_relative_time_iso(created_iso: &str) -> String {
118    // 解析失败时退化为 "刚刚",避免组件崩溃。
119    let Ok(dt) = DateTime::parse_from_rfc3339(created_iso) else {
120        return "刚刚".to_string();
121    };
122    let delta_millis = now_millis() - dt.timestamp_millis();
123    relative_label_from_millis(delta_millis, created_iso).0
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    const ISO: &str = "2026-06-22T05:43:57.565+00:00";
131
132    #[test]
133    fn relative_label_just_now_under_60s() {
134        let (label, _) = relative_label_from_millis(0, ISO);
135        assert_eq!(label, "刚刚");
136        let (label, _) = relative_label_from_millis(59_999, ISO);
137        assert_eq!(label, "刚刚");
138    }
139
140    #[test]
141    fn relative_label_minutes() {
142        let (label, _) = relative_label_from_millis(60_000, ISO);
143        assert_eq!(label, "1 分钟前");
144        let (label, _) = relative_label_from_millis(5 * 60_000, ISO);
145        assert_eq!(label, "5 分钟前");
146        let (label, _) = relative_label_from_millis(59 * 60_000, ISO);
147        assert_eq!(label, "59 分钟前");
148    }
149
150    #[test]
151    fn relative_label_hours() {
152        let (label, _) = relative_label_from_millis(60 * 60_000, ISO);
153        assert_eq!(label, "1 小时前");
154        let (label, _) = relative_label_from_millis(3 * 3_600_000, ISO);
155        assert_eq!(label, "3 小时前");
156        let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO);
157        assert_eq!(label, "23 小时前");
158    }
159
160    #[test]
161    fn relative_label_days() {
162        let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO);
163        assert_eq!(label, "1 天前");
164        let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO);
165        assert_eq!(label, "7 天前");
166        let (label, _) = relative_label_from_millis(29 * 24 * 3_600_000, ISO);
167        assert_eq!(label, "29 天前");
168    }
169
170    #[test]
171    fn relative_label_falls_back_to_date_over_30_days() {
172        let (label, absolute) = relative_label_from_millis(60 * 24 * 3_600_000, ISO);
173        assert_eq!(label, "2026-06-22");
174        assert_eq!(absolute, "2026-06-22");
175    }
176
177    #[test]
178    fn relative_label_future_falls_back_to_just_now() {
179        // 未来时间差为负,秒数 < 60,归为"刚刚"。
180        let (label, _) = relative_label_from_millis(-5_000, ISO);
181        assert_eq!(label, "刚刚");
182    }
183
184    #[test]
185    fn relative_label_invalid_iso_still_returns_absolute_empty() {
186        // 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。
187        let (label, absolute) = relative_label_from_millis(0, "not-a-date");
188        assert_eq!(label, "刚刚");
189        assert_eq!(absolute, "");
190    }
191
192    #[test]
193    fn format_relative_time_iso_invalid_iso_falls_back() {
194        // 解析失败退化为"刚刚",不 panic。
195        assert_eq!(format_relative_time_iso("not-a-date"), "刚刚");
196    }
197}