1use chrono::{DateTime, Datelike, Timelike};
12
13#[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 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#[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#[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
56pub 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
70pub 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
86pub fn local_now() -> DateTime<chrono::FixedOffset> {
88 to_local(DateTime::from_timestamp_millis(now_millis()).unwrap_or_else(chrono::Utc::now))
89}
90
91pub 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
120pub 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))]
146pub 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
158fn 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 String::new()
181 }
182 }
183 }
184 };
185
186 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
197pub 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
206pub 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
215pub 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
224pub 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
238pub 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 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 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 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}