跟随 Catppuccin 配色迁移,清理 src/ 中绕过 paper-* 语义 token 的 硬编码 dark:[#hex] 任意值,改用 Tailwind 默认灰阶(gray-700/800), 它们随主题切换自然适配。 - post.rs 状态徽章:dark:bg-[#333]/dark:text-[#9b9c9d] → gray-800/gray-400/500 (同步更新测试断言) - comments/* 与 skeletons/* 的 dark:[#2a2a2a]/[#2e2e33]/[#333]/[#5a5a62] → 对应 gray-600/700/800 - trash.rs 状态灯发光圈 rgba 由旧鼠尾草绿(92,122,94) 改 Latte green(64,160,43) - ui.rs BTN_SECONDARY 注释更新为新 Teal 强调色值 注:src/pages/admin/posts.rs 的 clippy redundant_closure 报错为既有问题, 与本次配色迁移无关,不在范围内。
334 lines
10 KiB
Rust
334 lines
10 KiB
Rust
//! 文章模型。
|
||
//!
|
||
//! 定义文章状态、文章结构体、标签、统计信息以及前后导航结构体。
|
||
//! Post 结构体在服务端渲染、客户端展示以及缓存层之间共享。
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// 文章发布状态枚举。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub enum PostStatus {
|
||
/// 草稿,仅管理员可见。
|
||
Draft,
|
||
/// 已发布,面向读者公开。
|
||
Published,
|
||
}
|
||
|
||
impl PostStatus {
|
||
/// 将状态序列化为数据库或 API 使用的小写字符串。
|
||
pub fn as_str(&self) -> &'static str {
|
||
match self {
|
||
PostStatus::Draft => "draft",
|
||
PostStatus::Published => "published",
|
||
}
|
||
}
|
||
|
||
/// 将字符串解析为 PostStatus,无法识别时返回 None。
|
||
#[cfg(feature = "server")]
|
||
pub fn from_str(s: &str) -> Option<Self> {
|
||
match s {
|
||
"draft" => Some(PostStatus::Draft),
|
||
"published" => Some(PostStatus::Published),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 文章领域模型。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct Post {
|
||
/// 文章主键。
|
||
pub id: i32,
|
||
/// 作者用户主键。
|
||
pub author_id: i32,
|
||
/// 文章标题。
|
||
pub title: String,
|
||
/// URL slug,用于生成文章链接。
|
||
pub slug: String,
|
||
/// 摘要,可选。
|
||
pub summary: Option<String>,
|
||
/// 原始 Markdown 内容。
|
||
pub content_md: String,
|
||
/// 渲染后的 HTML 内容,可选。
|
||
pub content_html: Option<String>,
|
||
/// 文章发布状态。
|
||
pub status: PostStatus,
|
||
/// 正式发布时间,None 表示尚未发布。
|
||
pub published_at: Option<DateTime<Utc>>,
|
||
/// 创建时间。
|
||
pub created_at: DateTime<Utc>,
|
||
/// 最后更新时间。
|
||
pub updated_at: DateTime<Utc>,
|
||
/// 软删除时间,None 表示未删除。仅回收站查询填充。
|
||
pub deleted_at: Option<DateTime<Utc>>,
|
||
/// 关联标签列表。
|
||
pub tags: Vec<String>,
|
||
/// 封面图片 URL。
|
||
pub cover_image: Option<String>,
|
||
/// 预计阅读时间(分钟)。
|
||
pub reading_time: u32,
|
||
/// 字数统计。
|
||
pub word_count: u32,
|
||
/// 目录 HTML。
|
||
pub toc_html: Option<String>,
|
||
/// 上一篇文章导航信息。
|
||
pub prev_post: Option<PostNav>,
|
||
/// 下一篇文章导航信息。
|
||
pub next_post: Option<PostNav>,
|
||
}
|
||
|
||
impl Post {
|
||
/// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。
|
||
pub fn formatted_date(&self) -> String {
|
||
self.published_at
|
||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||
.unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string())
|
||
}
|
||
}
|
||
|
||
/// 文章列表项 DTO。
|
||
///
|
||
/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`,
|
||
/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct PostListItem {
|
||
/// 文章主键。
|
||
pub id: i32,
|
||
/// 作者用户主键。
|
||
pub author_id: i32,
|
||
/// 文章标题。
|
||
pub title: String,
|
||
/// URL slug,用于生成文章链接。
|
||
pub slug: String,
|
||
/// 摘要,可选。
|
||
pub summary: Option<String>,
|
||
/// 文章发布状态。
|
||
pub status: PostStatus,
|
||
/// 正式发布时间,None 表示尚未发布。
|
||
pub published_at: Option<DateTime<Utc>>,
|
||
/// 创建时间。
|
||
pub created_at: DateTime<Utc>,
|
||
/// 最后更新时间。
|
||
pub updated_at: DateTime<Utc>,
|
||
/// 软删除时间,None 表示未删除。仅回收站查询填充。
|
||
pub deleted_at: Option<DateTime<Utc>>,
|
||
/// 关联标签列表。
|
||
pub tags: Vec<String>,
|
||
/// 封面图片 URL。
|
||
pub cover_image: Option<String>,
|
||
/// 预计阅读时间(分钟)。
|
||
pub reading_time: u32,
|
||
/// 字数统计。
|
||
pub word_count: u32,
|
||
}
|
||
|
||
impl PostListItem {
|
||
/// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。
|
||
pub fn formatted_date(&self) -> String {
|
||
self.published_at
|
||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||
.unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string())
|
||
}
|
||
|
||
/// 返回中文状态标签。
|
||
pub fn status_label(&self) -> &'static str {
|
||
match self.status {
|
||
PostStatus::Published => "已发布",
|
||
PostStatus::Draft => "草稿",
|
||
}
|
||
}
|
||
|
||
/// 返回状态文本在 light/dark 模式下的 Tailwind 颜色类。
|
||
pub fn status_class(&self) -> &'static str {
|
||
match self.status {
|
||
PostStatus::Published => "text-green-600 dark:text-green-400",
|
||
PostStatus::Draft => "text-gray-400 dark:text-gray-500",
|
||
}
|
||
}
|
||
|
||
/// 返回状态徽章在 light/dark 模式下的 Tailwind 背景与颜色类。
|
||
pub fn status_badge_class(&self) -> &'static str {
|
||
match self.status {
|
||
PostStatus::Published => {
|
||
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||
}
|
||
PostStatus::Draft => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 前后文章导航结构体。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct PostNav {
|
||
/// 文章标题。
|
||
pub title: String,
|
||
/// 文章 slug。
|
||
pub slug: String,
|
||
}
|
||
|
||
/// 标签领域模型。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct Tag {
|
||
/// 标签主键。
|
||
pub id: i32,
|
||
/// 标签名称。
|
||
pub name: String,
|
||
/// 关联文章数量。
|
||
pub post_count: i64,
|
||
}
|
||
|
||
/// 文章统计信息。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct PostStats {
|
||
/// 文章总数。
|
||
pub total: i64,
|
||
/// 草稿数量。
|
||
pub drafts: i64,
|
||
/// 已发布数量。
|
||
pub published: i64,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::{TimeZone, Utc};
|
||
|
||
fn sample_post() -> Post {
|
||
Post {
|
||
id: 1,
|
||
author_id: 1,
|
||
title: "Test".to_string(),
|
||
slug: "test".to_string(),
|
||
summary: None,
|
||
content_md: "content".to_string(),
|
||
content_html: None,
|
||
status: PostStatus::Draft,
|
||
published_at: None,
|
||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||
deleted_at: None,
|
||
tags: vec![],
|
||
cover_image: None,
|
||
reading_time: 1,
|
||
word_count: 10,
|
||
toc_html: None,
|
||
prev_post: None,
|
||
next_post: None,
|
||
}
|
||
}
|
||
|
||
fn sample_post_list_item() -> PostListItem {
|
||
PostListItem {
|
||
id: 1,
|
||
author_id: 1,
|
||
title: "Test".to_string(),
|
||
slug: "test".to_string(),
|
||
summary: None,
|
||
status: PostStatus::Draft,
|
||
published_at: None,
|
||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||
updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
|
||
deleted_at: None,
|
||
tags: vec![],
|
||
cover_image: None,
|
||
reading_time: 1,
|
||
word_count: 10,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "server")]
|
||
fn post_status_from_str() {
|
||
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
|
||
assert_eq!(
|
||
PostStatus::from_str("published"),
|
||
Some(PostStatus::Published)
|
||
);
|
||
assert_eq!(PostStatus::from_str("unknown"), None);
|
||
assert_eq!(PostStatus::from_str(""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn post_status_as_str() {
|
||
assert_eq!(PostStatus::Draft.as_str(), "draft");
|
||
assert_eq!(PostStatus::Published.as_str(), "published");
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "server")]
|
||
fn post_status_roundtrip() {
|
||
for status in [PostStatus::Draft, PostStatus::Published] {
|
||
assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn formatted_date_uses_published_at_when_available() {
|
||
let mut post = sample_post();
|
||
post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
|
||
assert_eq!(post.formatted_date(), "2024-06-01");
|
||
}
|
||
|
||
#[test]
|
||
fn formatted_date_falls_back_to_created_at() {
|
||
let post = sample_post();
|
||
assert_eq!(post.formatted_date(), "2024-01-15");
|
||
}
|
||
|
||
#[test]
|
||
fn post_list_item_formatted_date_uses_published_at_when_available() {
|
||
let mut post = sample_post_list_item();
|
||
post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
|
||
assert_eq!(post.formatted_date(), "2024-06-01");
|
||
}
|
||
|
||
#[test]
|
||
fn post_list_item_formatted_date_falls_back_to_created_at() {
|
||
let post = sample_post_list_item();
|
||
assert_eq!(post.formatted_date(), "2024-01-15");
|
||
}
|
||
|
||
#[test]
|
||
fn post_list_item_status_label() {
|
||
let mut post = sample_post_list_item();
|
||
post.status = PostStatus::Published;
|
||
assert_eq!(post.status_label(), "已发布");
|
||
post.status = PostStatus::Draft;
|
||
assert_eq!(post.status_label(), "草稿");
|
||
}
|
||
|
||
#[test]
|
||
fn post_list_item_status_class_returns_non_empty() {
|
||
let mut post = sample_post_list_item();
|
||
post.status = PostStatus::Published;
|
||
assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
|
||
post.status = PostStatus::Draft;
|
||
assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
|
||
}
|
||
|
||
#[test]
|
||
fn post_list_item_status_badge_class_returns_non_empty() {
|
||
let mut post = sample_post_list_item();
|
||
post.status = PostStatus::Published;
|
||
assert_eq!(
|
||
post.status_badge_class(),
|
||
"bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||
);
|
||
post.status = PostStatus::Draft;
|
||
assert_eq!(
|
||
post.status_badge_class(),
|
||
"bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn post_status_serde_roundtrip() {
|
||
let json = serde_json::to_string(&PostStatus::Draft).unwrap();
|
||
assert_eq!(
|
||
serde_json::from_str::<PostStatus>(&json).unwrap(),
|
||
PostStatus::Draft
|
||
);
|
||
}
|
||
}
|