refactor(utils): 拆分 comment_storage 的时间/HTML 工具函数
消除 server 端对 hooks 模块的跨层引用: - escape_html(+ 两份重复实现:comment_storage 用 '、helpers 用 ') 统一到 utils/html.rs,单引号采用 HTML5 标准 '。 - now_millis / relative_label_from_millis / format_relative_time_iso 及其 单元测试迁到 utils/time.rs,与既有 sleep_ms 同构(wasm32/非 wasm 双版本)。 调用方迁移: - api/comments/helpers.rs:删掉本地 escape_html,format_relative_time 改引 utils::time;其 escape_html 测试随之删除(utils/html.rs 已覆盖)。 - api/comments/create.rs:两处 helpers::escape_html 改 utils::html。 - api/markdown.rs:escape_heading_text 改 utils::html。 - components/comments/pending_item.rs:format_relative_time_iso 改从 utils::time 引入;render_pending_content 留在 comment_storage(语义紧贴 待审核评论)。 - hooks/comment_storage.rs:is_expired 的 now_millis 改引 utils::time。 comment_storage.rs 现在只剩结构体与 localStorage 读写,文件名即将自洽。
This commit is contained in:
parent
f00c2bf963
commit
dcdd72a8c2
@ -212,10 +212,10 @@ pub async fn create_comment(
|
|||||||
// 在开事务前完成纯计算(Markdown 渲染、字段转义、IP/UA 提取),避免
|
// 在开事务前完成纯计算(Markdown 渲染、字段转义、IP/UA 提取),避免
|
||||||
// 在事务持锁期间做无谓工作,缩短关键排他锁窗口。
|
// 在事务持锁期间做无谓工作,缩短关键排他锁窗口。
|
||||||
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
|
||||||
let author_name_safe = crate::api::comments::helpers::escape_html(author_name.trim());
|
let author_name_safe = crate::utils::html::escape_html(author_name.trim());
|
||||||
let author_url_safe = author_url
|
let author_url_safe = author_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|u| crate::api::comments::helpers::escape_html(u.trim()))
|
.map(|u| crate::utils::html::escape_html(u.trim()))
|
||||||
.filter(|u| !u.is_empty());
|
.filter(|u| !u.is_empty());
|
||||||
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
|
||||||
let parts = ctx.parts_mut();
|
let parts = ctx.parts_mut();
|
||||||
|
|||||||
@ -15,17 +15,6 @@ pub fn md5_hash(input: &str) -> String {
|
|||||||
hex::encode(hash)
|
hex::encode(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对用于 HTML 展示的文本做基础转义,防止 XSS。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub fn escape_html(input: &str) -> String {
|
|
||||||
input
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。
|
/// 根据邮箱生成 Cravatar(Gravatar 国内镜像)头像 URL。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn gravatar_url(email: &str) -> String {
|
pub fn gravatar_url(email: &str) -> String {
|
||||||
@ -79,14 +68,14 @@ 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` 完全一致,
|
/// 分档规则与前端 `crate::utils::time::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 delta_millis = now.signed_duration_since(dt).num_milliseconds();
|
let delta_millis = now.signed_duration_since(dt).num_milliseconds();
|
||||||
let iso = dt.to_rfc3339();
|
let iso = dt.to_rfc3339();
|
||||||
crate::hooks::comment_storage::relative_label_from_millis(delta_millis, &iso).0
|
crate::utils::time::relative_label_from_millis(delta_millis, &iso).0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 校验评论作者昵称:非空且不超过 50 字符。
|
/// 校验评论作者昵称:非空且不超过 50 字符。
|
||||||
@ -190,18 +179,6 @@ pub fn compute_content_hash(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escape_html_escapes_special_chars() {
|
|
||||||
assert_eq!(
|
|
||||||
escape_html("<script>alert(1)</script>"),
|
|
||||||
"<script>alert(1)</script>"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
escape_html("\"quoted' & ampersand"),
|
|
||||||
""quoted' & ampersand"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn md5_hash_known_value() {
|
fn md5_hash_known_value() {
|
||||||
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
|
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
|
||||||
|
|||||||
@ -15,11 +15,11 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
|
/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
|
||||||
///
|
///
|
||||||
/// 复用 `hooks::comment_storage::escape_html`(转义 `& < > " '`),避免在仓库内
|
/// 复用 `utils::html::escape_html`(转义 `& < > " '`),避免在仓库内
|
||||||
/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
|
/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
|
||||||
/// `" onmouseover="alert(1)` 会越出属性边界。
|
/// `" onmouseover="alert(1)` 会越出属性边界。
|
||||||
fn escape_heading_text(s: &str) -> String {
|
fn escape_heading_text(s: &str) -> String {
|
||||||
crate::hooks::comment_storage::escape_html(s)
|
crate::utils::html::escape_html(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@ -5,9 +5,8 @@
|
|||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::hooks::comment_storage::{
|
use crate::hooks::comment_storage::{render_pending_content, PendingComment};
|
||||||
format_relative_time_iso, render_pending_content, PendingComment,
|
use crate::utils::time::format_relative_time_iso;
|
||||||
};
|
|
||||||
|
|
||||||
/// 待审核评论项组件。
|
/// 待审核评论项组件。
|
||||||
///
|
///
|
||||||
|
|||||||
@ -85,26 +85,12 @@ fn write_storage(key: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前时间戳(毫秒)。
|
|
||||||
///
|
|
||||||
/// WASM 端使用 `js_sys::Date`,服务端回退到 `chrono::Utc`。
|
|
||||||
fn now_millis() -> i64 {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
js_sys::Date::now() as i64
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
chrono::Utc::now().timestamp_millis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断给定存储时间是否已经过期。
|
/// 判断给定存储时间是否已经过期。
|
||||||
fn is_expired(stored_at: &str) -> bool {
|
fn is_expired(stored_at: &str) -> bool {
|
||||||
let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else {
|
let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let now_ms = now_millis();
|
let now_ms = crate::utils::time::now_millis();
|
||||||
let stored_ms = dt.timestamp_millis();
|
let stored_ms = dt.timestamp_millis();
|
||||||
(now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000)
|
(now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||||
}
|
}
|
||||||
@ -235,148 +221,8 @@ 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 转义辅助函数。
|
|
||||||
pub(crate) fn escape_html(input: &str) -> String {
|
|
||||||
input
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `<br>`)。
|
/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `<br>`)。
|
||||||
pub fn render_pending_content(md: &str) -> String {
|
pub fn render_pending_content(md: &str) -> String {
|
||||||
let escaped = escape_html(md);
|
let escaped = crate::utils::html::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 * 3_600_000, ISO);
|
|
||||||
assert_eq!(label, "3 小时前");
|
|
||||||
let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO);
|
|
||||||
assert_eq!(label, "23 小时前");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_label_days() {
|
|
||||||
let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO);
|
|
||||||
assert_eq!(label, "1 天前");
|
|
||||||
let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO);
|
|
||||||
assert_eq!(label, "7 天前");
|
|
||||||
let (label, _) = relative_label_from_millis(29 * 24 * 3_600_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 * 3_600_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"), "刚刚");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
45
src/utils/html.rs
Normal file
45
src/utils/html.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
//! HTML 转义工具(零依赖纯函数,前端后端通用)。
|
||||||
|
//!
|
||||||
|
//! 仓库内原先存在两份 `escape_html` 实现:
|
||||||
|
//! - `hooks::comment_storage::escape_html`(`'` → `'`)
|
||||||
|
//! - `api::comments::helpers::escape_html`(`'` → `'`,server-only)
|
||||||
|
//! 现统一到本模块,单引号采用 HTML5 标准的 `'`。
|
||||||
|
|
||||||
|
/// 转义 HTML 特殊字符:`& < > " '`。
|
||||||
|
///
|
||||||
|
/// 单引号统一为 `'`(HTML5 规范,与原 server 端 `helpers::escape_html` 一致)。
|
||||||
|
/// 可安全用于文本节点与属性值上下文。
|
||||||
|
pub fn escape_html(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_all_five_special_chars() {
|
||||||
|
assert_eq!(escape_html("&<>\"'"), "&<>"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_ampersand_first_to_avoid_double_escape() {
|
||||||
|
// & 必须先转义,否则后续引入的 & 会被再次处理。
|
||||||
|
assert_eq!(escape_html("<&>"), "<&>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_plain_text_untouched() {
|
||||||
|
assert_eq!(escape_html("hello world"), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_returns_empty() {
|
||||||
|
assert_eq!(escape_html(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,13 @@
|
|||||||
//! 通用工具函数子模块。
|
//! 通用工具函数子模块。
|
||||||
//!
|
//!
|
||||||
//! `text` 模块仅在 `server` feature 启用时编译;`time` 模块同时提供 WASM 与原生异步版本。
|
//! - `html`:HTML 转义(两端通用)。
|
||||||
|
//! - `text`:Markdown / 纯文本处理(仅 `server` feature)。
|
||||||
|
//! - `time`:跨平台时间/睡眠工具(WASM 与原生异步版本)。
|
||||||
|
|
||||||
|
/// HTML 转义工具(前端后端通用)。
|
||||||
|
pub mod html;
|
||||||
/// Markdown / 纯文本处理工具。
|
/// Markdown / 纯文本处理工具。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod text;
|
pub mod text;
|
||||||
/// 跨平台的异步睡眠等时间工具。
|
/// 跨平台时间/睡眠工具。
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
//! 跨平台时间/睡眠工具。
|
//! 跨平台时间/睡眠工具。
|
||||||
//!
|
//!
|
||||||
//! 根据目标架构分别实现:
|
//! 根据目标架构分别实现:
|
||||||
//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout`。
|
//! - `wasm32`:通过 `js_sys` 调用 JavaScript 的 `setTimeout` / `Date.now()`。
|
||||||
//! - 其他平台:使用 `tokio::time::sleep`。
|
//! - 其他平台:使用 `tokio::time::sleep` / `chrono::Utc`。
|
||||||
|
//!
|
||||||
|
//! 相对时间分档(`relative_label_from_millis` / `format_relative_time_iso`)由
|
||||||
|
//! 前端待审核评论展示与服务端评论预渲染共享,保证两端口径一致。
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
/// 异步睡眠指定毫秒数。
|
/// 异步睡眠指定毫秒数。
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -21,3 +26,147 @@ pub async fn sleep_ms(ms: u32) {
|
|||||||
pub async fn sleep_ms(ms: u32) {
|
pub async fn sleep_ms(ms: u32) {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取当前时间戳(毫秒)。
|
||||||
|
///
|
||||||
|
/// WASM 端使用 `js_sys::Date::now()`,服务端回退到 `chrono::Utc`。
|
||||||
|
pub fn now_millis() -> i64 {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
js_sys::Date::now() as i64
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
chrono::Utc::now().timestamp_millis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相对时间分档:根据"距现在的毫秒数"返回 (相对文本, 绝对日期 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 * 3_600_000, ISO);
|
||||||
|
assert_eq!(label, "3 小时前");
|
||||||
|
let (label, _) = relative_label_from_millis(23 * 3_600_000, ISO);
|
||||||
|
assert_eq!(label, "23 小时前");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_label_days() {
|
||||||
|
let (label, _) = relative_label_from_millis(24 * 3_600_000, ISO);
|
||||||
|
assert_eq!(label, "1 天前");
|
||||||
|
let (label, _) = relative_label_from_millis(7 * 24 * 3_600_000, ISO);
|
||||||
|
assert_eq!(label, "7 天前");
|
||||||
|
let (label, _) = relative_label_from_millis(29 * 24 * 3_600_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 * 3_600_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