refactor(comment): share relative-time bucketing between server and client
提取相对时间分档逻辑,服务端与前端共享同一份实现 - comment_storage.rs 新增 relative_label_from_millis / format_relative_time_iso: 按 delta 毫秒分档(刚刚/N 分钟前/N 小时前/N 天前/超过 30 天显示日期), 返回 (相对文本, 绝对日期 YYYY-MM-DD);解析失败兜底为空串/刚刚,不 panic - helpers.rs 的 format_relative_time 重构为调用共享函数,消除服务端预渲染 与前端实时计算两份分档逻辑的口径漂移风险 - 补充 8 个单元测试覆盖各分档边界与异常输入 测试: cargo test relative (18 passed)
This commit is contained in:
parent
6ff37322b2
commit
d3082d5c64
@ -78,22 +78,15 @@ pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
|
/// 将 UTC 时间格式化为相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。
|
||||||
|
///
|
||||||
|
/// 分档规则与前端 `crate::hooks::comment_storage::relative_label_from_millis` 完全一致,
|
||||||
|
/// 通过共享分档函数保证服务端预渲染与前端实时计算的口径统一。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let diff = now.signed_duration_since(dt);
|
let delta_millis = now.signed_duration_since(dt).num_milliseconds();
|
||||||
|
let iso = dt.to_rfc3339();
|
||||||
if diff.num_seconds() < 60 {
|
crate::hooks::comment_storage::relative_label_from_millis(delta_millis, &iso).0
|
||||||
"刚刚".to_string()
|
|
||||||
} else if diff.num_minutes() < 60 {
|
|
||||||
format!("{} 分钟前", diff.num_minutes())
|
|
||||||
} else if diff.num_hours() < 24 {
|
|
||||||
format!("{} 小时前", diff.num_hours())
|
|
||||||
} else if diff.num_days() < 30 {
|
|
||||||
format!("{} 天前", diff.num_days())
|
|
||||||
} else {
|
|
||||||
dt.format("%Y-%m-%d").to_string()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 校验评论作者昵称:非空且不超过 50 字符。
|
/// 校验评论作者昵称:非空且不超过 50 字符。
|
||||||
|
|||||||
@ -235,6 +235,59 @@ fn load_all_pending() -> PendingMap {
|
|||||||
serde_json::from_str(&json).unwrap_or_default()
|
serde_json::from_str(&json).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 YYYY-MM-DD)。
|
||||||
|
///
|
||||||
|
/// 分档规则与服务端 `format_relative_time` 完全一致,前端在展示待审核评论时复用,
|
||||||
|
/// 保证两类评论的时间展示口径统一。返回绝对日期用于 `title` 悬浮提示。
|
||||||
|
///
|
||||||
|
/// - `delta_millis`:目标时间与"现在"的差值(毫秒)。正值表示过去,负值表示未来(兜底按刚刚处理)。
|
||||||
|
/// - `created_iso`:评论的 RFC3339 创建时间,用于兜底生成绝对日期。
|
||||||
|
pub fn relative_label_from_millis(delta_millis: i64, created_iso: &str) -> (String, String) {
|
||||||
|
let seconds = delta_millis / 1000;
|
||||||
|
|
||||||
|
let label = if seconds < 60 {
|
||||||
|
"刚刚".to_string()
|
||||||
|
} else {
|
||||||
|
let minutes = seconds / 60;
|
||||||
|
if minutes < 60 {
|
||||||
|
format!("{} 分钟前", minutes)
|
||||||
|
} else {
|
||||||
|
let hours = minutes / 60;
|
||||||
|
if hours < 24 {
|
||||||
|
format!("{} 小时前", hours)
|
||||||
|
} else {
|
||||||
|
let days = hours / 24;
|
||||||
|
if days < 30 {
|
||||||
|
format!("{} 天前", days)
|
||||||
|
} else {
|
||||||
|
// 超过 30 天直接显示日期,下方 absolute 复用
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绝对日期:优先解析 ISO;解析失败时退化为空串,避免组件报错。
|
||||||
|
let absolute = DateTime::parse_from_rfc3339(created_iso)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let label = if label.is_empty() { absolute.clone() } else { label };
|
||||||
|
(label, absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端友好的相对时间格式化:返回相对文本,用于展示待审核评论的创建时间。
|
||||||
|
///
|
||||||
|
/// 这是 `relative_label_from_millis` 的薄封装,仅返回相对文本。
|
||||||
|
pub fn format_relative_time_iso(created_iso: &str) -> String {
|
||||||
|
// 解析失败时退化为 "刚刚",避免组件崩溃。
|
||||||
|
let Ok(dt) = DateTime::parse_from_rfc3339(created_iso) else {
|
||||||
|
return "刚刚".to_string();
|
||||||
|
};
|
||||||
|
let delta_millis = now_millis() - dt.timestamp_millis();
|
||||||
|
relative_label_from_millis(delta_millis, created_iso).0
|
||||||
|
}
|
||||||
|
|
||||||
/// HTML 转义辅助函数。
|
/// HTML 转义辅助函数。
|
||||||
pub(crate) fn escape_html(input: &str) -> String {
|
pub(crate) fn escape_html(input: &str) -> String {
|
||||||
input
|
input
|
||||||
@ -250,3 +303,76 @@ pub fn render_pending_content(md: &str) -> String {
|
|||||||
let escaped = escape_html(md);
|
let escaped = escape_html(md);
|
||||||
escaped.replace('\n', "<br>")
|
escaped.replace('\n', "<br>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ISO: &str = "2026-06-22T05:43:57.565+00:00";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_just_now_under_60s() {
|
||||||
|
let (label, _) = relative_label_from_millis(0, ISO);
|
||||||
|
assert_eq!(label, "刚刚");
|
||||||
|
let (label, _) = relative_label_from_millis(59_999, ISO);
|
||||||
|
assert_eq!(label, "刚刚");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_minutes() {
|
||||||
|
let (label, _) = relative_label_from_millis(60_000, ISO);
|
||||||
|
assert_eq!(label, "1 分钟前");
|
||||||
|
let (label, _) = relative_label_from_millis(5 * 60_000, ISO);
|
||||||
|
assert_eq!(label, "5 分钟前");
|
||||||
|
let (label, _) = relative_label_from_millis(59 * 60_000, ISO);
|
||||||
|
assert_eq!(label, "59 分钟前");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_hours() {
|
||||||
|
let (label, _) = relative_label_from_millis(60 * 60_000, ISO);
|
||||||
|
assert_eq!(label, "1 小时前");
|
||||||
|
let (label, _) = relative_label_from_millis(3 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "3 小时前");
|
||||||
|
let (label, _) = relative_label_from_millis(23 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "23 小时前");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_days() {
|
||||||
|
let (label, _) = relative_label_from_millis(24 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "1 天前");
|
||||||
|
let (label, _) = relative_label_from_millis(7 * 24 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "7 天前");
|
||||||
|
let (label, _) = relative_label_from_millis(29 * 24 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "29 天前");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_falls_back_to_date_over_30_days() {
|
||||||
|
let (label, absolute) = relative_label_from_millis(60 * 24 * 3600_000, ISO);
|
||||||
|
assert_eq!(label, "2026-06-22");
|
||||||
|
assert_eq!(absolute, "2026-06-22");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_future_falls_back_to_just_now() {
|
||||||
|
// 未来时间差为负,秒数 < 60,归为"刚刚"。
|
||||||
|
let (label, _) = relative_label_from_millis(-5_000, ISO);
|
||||||
|
assert_eq!(label, "刚刚");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_invalid_iso_still_returns_absolute_empty() {
|
||||||
|
// 无法解析时 absolute 为空,但分档逻辑仍按 delta 决定。
|
||||||
|
let (label, absolute) = relative_label_from_millis(0, "not-a-date");
|
||||||
|
assert_eq!(label, "刚刚");
|
||||||
|
assert_eq!(absolute, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_time_iso_invalid_iso_falls_back() {
|
||||||
|
// 解析失败退化为"刚刚",不 panic。
|
||||||
|
assert_eq!(format_relative_time_iso("not-a-date"), "刚刚");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user