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`
8//! 适配预渲染,前端通过 `format_relative_time_iso` 适配待审核评论,二者都复用
9//! `relative_label_inner`,保证分档口径一致。
10
11use chrono::{DateTime, Datelike, Timelike};
12
13/// 异步睡眠指定毫秒数。
14///
15/// WASM 端用 `js_sys::Promise` + `web_sys::Window::set_timeout_*` 构造,
16/// 避免 `js_sys::eval` 字符串求值。全项目统一的 sleep 入口。
17///
18/// `setTimeout` 的 delay 参数是 i32,超过 `i32::MAX` 会被浏览器立即触发;这里 clamp
19/// 到安全上限,既避免 `u32 -> i32` 转换溢出 panic,也贴合 setTimeout 的合法范围。
20#[cfg(target_arch = "wasm32")]
21pub async fn sleep_ms(ms: u32) {
22    use wasm_bindgen::JsCast;
23    use wasm_bindgen_futures::JsFuture;
24    let promise = js_sys::Promise::new(&mut |resolve, _| {
25        // ms 是 u32,setTimeout 接受 i32;clamp 到 i32::MAX(约 24.8 天)避免溢出。
26        let delay = ms.min(i32::MAX as u32) as i32;
27        let window = web_sys::window().expect("sleep_ms 必须在浏览器上下文中调用:无 window");
28        window
29            .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve.unchecked_into(), delay)
30            .expect("setTimeout with a number delay cannot fail per WebIDL");
31    });
32    let _ = JsFuture::from(promise).await;
33}
34
35/// 异步睡眠指定毫秒数(原生 tokio 版本)。
36///
37/// 仅在 `server` feature 启用且非 wasm32 目标下编译。`tokio` 是 server-only 的
38/// optional 依赖(见 Cargo.toml),不可用 `#[cfg(not(target_arch = "wasm32"))]`——
39/// 那样会在「非 wasm32 主机 + 仅 web feature」组合下误激活,此时 tokio 未引入,
40/// 导致编译失败(此 bug 曾被 `[dev-dependencies] tokio` 掩盖,发布构建才暴露)。
41#[cfg(all(feature = "server", not(target_arch = "wasm32")))]
42pub async fn sleep_ms(ms: u32) {
43    tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await;
44}
45
46/// `sleep_ms` 的占位 stub,仅用于「非 wasm32 且非 server」的无效构建组合。
47///
48/// 此组合(如非 wasm32 主机执行 `cargo build --features web`)不是有效部署目标——
49/// web feature 的真实构建目标就是 wasm32,会走上面的 JS setTimeout 分支。
50/// 此 stub 仅保证符号可编译,永远不会在有效运行时被调用;若被调用说明部署配置错误。
51#[cfg(all(not(feature = "server"), not(target_arch = "wasm32")))]
52pub async fn sleep_ms(_ms: u32) {
53    panic!("sleep_ms 在非 wasm32 且非 server 的无效构建组合下被调用:请检查 feature 配置");
54}
55
56/// 获取当前时间戳(毫秒)。
57///
58/// WASM 端使用 `js_sys::Date::now()`,服务端回退到 `chrono::Utc`。
59pub fn now_millis() -> i64 {
60    #[cfg(target_arch = "wasm32")]
61    {
62        js_sys::Date::now() as i64
63    }
64    #[cfg(not(target_arch = "wasm32"))]
65    {
66        chrono::Utc::now().timestamp_millis()
67    }
68}
69
70/// 把 UTC 时间换到浏览器本地时区(按该时刻的偏移,跨夏令时也准确)。
71///
72/// 非 wasm32 按 UTC 返回:后台页面只在浏览器里渲染时间。
73pub fn to_local(dt: DateTime<chrono::Utc>) -> DateTime<chrono::FixedOffset> {
74    #[cfg(target_arch = "wasm32")]
75    let offset_secs = {
76        let date = js_sys::Date::new(&(dt.timestamp_millis() as f64).into());
77        -(date.get_timezone_offset() as i32) * 60
78    };
79    #[cfg(not(target_arch = "wasm32"))]
80    let offset_secs = 0;
81    let offset = chrono::FixedOffset::east_opt(offset_secs)
82        .unwrap_or_else(|| chrono::FixedOffset::east_opt(0).expect("zero offset"));
83    dt.with_timezone(&offset)
84}
85
86/// 浏览器本地的当前时间。
87pub fn local_now() -> DateTime<chrono::FixedOffset> {
88    to_local(DateTime::from_timestamp_millis(now_millis()).unwrap_or_else(chrono::Utc::now))
89}
90
91/// UTC "HH:MM" → 浏览器本地 "HH:MM"(按当天时区偏移换算)。
92///
93/// 非 wasm32 原样返回(SSR 不渲染设置值,此分支不会出现在用户可见路径)。
94/// 供备份设置卡片在挂载回填时使用——服务端只存 UTC,面板按本地时区显示。
95pub fn utc_hhmm_to_local(t: &str) -> String {
96    #[cfg(target_arch = "wasm32")]
97    {
98        let mut parts = t.split(':');
99        let h = parts
100            .next()
101            .and_then(|s| s.parse::<u32>().ok())
102            .unwrap_or(0);
103        let m = parts
104            .next()
105            .and_then(|s| s.parse::<u32>().ok())
106            .unwrap_or(0);
107        let d = js_sys::Date::new_0();
108        d.set_utc_hours(h);
109        d.set_utc_minutes(m);
110        d.set_utc_seconds(0);
111        d.set_utc_milliseconds(0);
112        format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
113    }
114    #[cfg(not(target_arch = "wasm32"))]
115    {
116        t.to_string()
117    }
118}
119
120/// 浏览器本地 "HH:MM" → UTC "HH:MM"。空串/非法输入回退 "04:00"
121/// (服务端 normalize 会再兜底一次)。非 wasm32 原样返回(只有设置页保存时调用)。
122pub fn local_hhmm_to_utc(t: &str) -> String {
123    #[cfg(target_arch = "wasm32")]
124    {
125        let mut parts = t.split(':');
126        let Some(h) = parts.next().and_then(|s| s.parse::<u32>().ok()) else {
127            return "04:00".to_string();
128        };
129        let Some(m) = parts.next().and_then(|s| s.parse::<u32>().ok()) else {
130            return "04:00".to_string();
131        };
132        let d = js_sys::Date::new_0();
133        d.set_hours(h);
134        d.set_minutes(m);
135        d.set_seconds(0);
136        d.set_milliseconds(0);
137        format!("{:02}:{:02}", d.get_utc_hours(), d.get_utc_minutes())
138    }
139    #[cfg(not(target_arch = "wasm32"))]
140    {
141        t.to_string()
142    }
143}
144
145#[cfg(any(feature = "server", test))]
146/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。
147///
148/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用,
149/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。
150///
151/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。
152/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。
153pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) {
154    let dt = DateTime::parse_from_rfc3339(created_iso).ok();
155    relative_label_inner(delta_millis, dt.as_ref())
156}
157
158/// 桶化相对时间标签 + 绝对日期,复用已解析的 DateTime 避免二次 ISO 解析。
159fn relative_label_inner(
160    delta_millis: i64,
161    dt: Option<&chrono::DateTime<chrono::FixedOffset>>,
162) -> (String, String) {
163    let seconds = delta_millis / 1000;
164    let label = if seconds < 60 {
165        "刚刚".to_string()
166    } else {
167        let minutes = seconds / 60;
168        if minutes < 60 {
169            format!("{minutes} 分钟前")
170        } else {
171            let hours = minutes / 60;
172            if hours < 24 {
173                format!("{hours} 小时前")
174            } else {
175                let days = hours / 24;
176                if days < 30 {
177                    format!("{days} 天前")
178                } else {
179                    // 超过 30 天直接显示日期,下方 absolute 复用
180                    String::new()
181                }
182            }
183        }
184    };
185
186    // 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。
187    let absolute = dt.map(|d| ymd(d, '-')).unwrap_or_default();
188
189    let label = if label.is_empty() {
190        absolute.clone()
191    } else {
192        label
193    };
194    (label, absolute)
195}
196
197/// `2026.10.30` / `2026-10-30`:代替 `format("%Y{sep}%m{sep}%d")`(客户端不带 strftime 解析器)。
198pub fn ymd(d: &impl Datelike, sep: char) -> String {
199    ymd_parts(d.year(), d.month(), d.day(), sep)
200}
201
202fn ymd_parts(y: i32, m: u32, d: u32, sep: char) -> String {
203    format!("{y:04}{sep}{m:02}{sep}{d:02}")
204}
205
206/// `10.30` / `10-30`:代替 `format("%m{sep}%d")`。
207pub fn md(d: &impl Datelike, sep: char) -> String {
208    md_parts(d.month(), d.day(), sep)
209}
210
211fn md_parts(m: u32, d: u32, sep: char) -> String {
212    format!("{m:02}{sep}{d:02}")
213}
214
215/// `08:05`:代替 `format("%H:%M")`。
216pub fn hm(t: &impl Timelike) -> String {
217    hm_parts(t.hour(), t.minute())
218}
219
220fn hm_parts(h: u32, m: u32) -> String {
221    format!("{h:02}:{m:02}")
222}
223
224/// `08:05:09.123`:代替 `format("%H:%M:%S%.3f")`(闰秒的纳秒值按 chrono 取模)。
225pub fn hms_millis(t: &impl Timelike) -> String {
226    hms_millis_parts(
227        t.hour(),
228        t.minute(),
229        t.second(),
230        t.nanosecond() % 1_000_000_000 / 1_000_000,
231    )
232}
233
234fn hms_millis_parts(h: u32, m: u32, s: u32, ms: u32) -> String {
235    format!("{h:02}:{m:02}:{s:02}.{ms:03}")
236}
237
238/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。
239///
240/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。
241pub fn format_relative_time_iso(created_iso: &str) -> String {
242    let dt = DateTime::parse_from_rfc3339(created_iso).ok();
243    let delta_millis = match &dt {
244        Some(d) => now_millis() - d.timestamp_millis(),
245        None => return "刚刚".to_string(),
246    };
247    relative_label_inner(delta_millis, dt.as_ref()).0
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    const ISO: &str = "2026-06-22T05:43:57.565+00:00";
255
256    #[test]
257    fn relative_label_just_now_under_60s() {
258        let (label, _) = relative_label_from_millis(0, ISO);
259        assert_eq!(label, "刚刚");
260        let (label, _) = relative_label_from_millis(59_999, ISO);
261        assert_eq!(label, "刚刚");
262    }
263
264    #[test]
265    fn relative_label_minutes() {
266        let (label, _) = relative_label_from_millis(60_000, ISO);
267        assert_eq!(label, "1 分钟前");
268        let (label, _) = relative_label_from_millis(5 * 60_000, ISO);
269        assert_eq!(label, "5 分钟前");
270        let (label, _) = relative_label_from_millis(59 * 60_000, ISO);
271        assert_eq!(label, "59 分钟前");
272    }
273
274    #[test]
275    fn relative_label_hours() {
276        let (label, _) = relative_label_from_millis(60 * 60_000, ISO);
277        assert_eq!(label, "1 小时前");
278        let (label, _) = relative_label_from_millis(3 * 3_600_000, ISO);
279        assert_eq!(label, "3 小时前");
280        let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO);
281        assert_eq!(label, "23 小时前");
282    }
283
284    #[test]
285    fn relative_label_days() {
286        let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO);
287        assert_eq!(label, "1 天前");
288        let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO);
289        assert_eq!(label, "7 天前");
290        let (label, _) = relative_label_from_millis(29 * 24 * 3_600_000, ISO);
291        assert_eq!(label, "29 天前");
292    }
293
294    #[test]
295    fn relative_label_falls_back_to_date_over_30_days() {
296        let (label, absolute) = relative_label_from_millis(60 * 24 * 3_600_000, ISO);
297        assert_eq!(label, "2026-06-22");
298        assert_eq!(absolute, "2026-06-22");
299    }
300
301    #[test]
302    fn relative_label_future_falls_back_to_just_now() {
303        // 未来时间差为负,秒数 < 60,归为"刚刚"。
304        let (label, _) = relative_label_from_millis(-5_000, ISO);
305        assert_eq!(label, "刚刚");
306    }
307
308    #[test]
309    fn relative_label_invalid_iso_still_returns_absolute_empty() {
310        // 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。
311        let (label, absolute) = relative_label_from_millis(0, "not-a-date");
312        assert_eq!(label, "刚刚");
313        assert_eq!(absolute, "");
314    }
315
316    #[test]
317    fn format_relative_time_iso_invalid_iso_falls_back() {
318        // 解析失败退化为"刚刚",不 panic。
319        assert_eq!(format_relative_time_iso("not-a-date"), "刚刚");
320    }
321
322    #[test]
323    fn date_helpers_match_chrono_strftime() {
324        use chrono::{FixedOffset, NaiveDate, TimeZone, Utc};
325        let utc = Utc.with_ymd_and_hms(2026, 1, 5, 8, 3, 9).unwrap()
326            + chrono::Duration::nanoseconds(123_456_789);
327        let east8 = FixedOffset::east_opt(8 * 3600)
328            .unwrap()
329            .with_ymd_and_hms(2026, 12, 31, 23, 59, 59)
330            .unwrap();
331        for (helper, chrono) in [
332            (ymd(&utc, '.'), utc.format("%Y.%m.%d").to_string()),
333            (ymd(&utc, '-'), utc.format("%Y-%m-%d").to_string()),
334            (md(&utc, '.'), utc.format("%m.%d").to_string()),
335            (md(&utc, '-'), utc.format("%m-%d").to_string()),
336            (hm(&utc), utc.format("%H:%M").to_string()),
337            (hms_millis(&utc), utc.format("%H:%M:%S%.3f").to_string()),
338            (ymd(&east8, '.'), east8.format("%Y.%m.%d").to_string()),
339            (ymd(&east8, '-'), east8.format("%Y-%m-%d").to_string()),
340            (md(&east8, '.'), east8.format("%m.%d").to_string()),
341            (md(&east8, '-'), east8.format("%m-%d").to_string()),
342            (hm(&east8), east8.format("%H:%M").to_string()),
343            (hms_millis(&east8), east8.format("%H:%M:%S%.3f").to_string()),
344        ] {
345            assert_eq!(helper, chrono);
346        }
347        let day = NaiveDate::from_ymd_opt(2026, 10, 30).unwrap();
348        assert_eq!(ymd(&day, '.'), day.format("%Y.%m.%d").to_string());
349        assert_eq!(ymd(&day, '-'), day.format("%Y-%m-%d").to_string());
350        assert_eq!(md(&day, '.'), day.format("%m.%d").to_string());
351        assert_eq!(md(&day, '-'), day.format("%m-%d").to_string());
352    }
353}