Skip to main content

yggdrasil/models/
post.rs

1//! 文章模型。
2//!
3//! 定义文章状态、文章结构体、标签、统计信息以及前后导航结构体。
4//! Post 结构体在服务端渲染、客户端展示以及缓存层之间共享。
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// 文章发布状态枚举。
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum PostStatus {
12    /// 草稿,仅管理员可见。
13    Draft,
14    /// 已发布,面向读者公开。
15    Published,
16}
17
18impl PostStatus {
19    /// 将状态序列化为数据库或 API 使用的小写字符串。
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            PostStatus::Draft => "draft",
23            PostStatus::Published => "published",
24        }
25    }
26
27    /// 返回中文展示标签(草稿/已发布)。
28    pub fn label(&self) -> &'static str {
29        match self {
30            PostStatus::Draft => "草稿",
31            PostStatus::Published => "已发布",
32        }
33    }
34
35    /// 后台状态圆点的语气:已发布为陶土色,草稿为灰色(`StatusDot` 的 `tone`)。
36    pub fn tone(&self) -> &'static str {
37        match self {
38            PostStatus::Draft => "muted",
39            PostStatus::Published => "clay",
40        }
41    }
42
43    /// 将字符串解析为 PostStatus,无法识别时返回 None。
44    #[cfg(feature = "server")]
45    // 返回值不是 Result(未知值回退/None),不能实现 std::str::FromStr。
46    #[allow(clippy::should_implement_trait)]
47    pub fn from_str(s: &str) -> Option<Self> {
48        match s {
49            "draft" => Some(PostStatus::Draft),
50            "published" => Some(PostStatus::Published),
51            _ => None,
52        }
53    }
54}
55
56/// 文章领域模型。
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Post {
59    /// 文章主键。
60    pub id: i32,
61    /// 作者用户主键。
62    pub author_id: i32,
63    /// 文章标题。
64    pub title: String,
65    /// URL slug,用于生成文章链接。
66    pub slug: String,
67    /// 摘要,可选。
68    pub summary: Option<String>,
69    /// 原始 Markdown 内容。
70    pub content_md: String,
71    /// 渲染后的 HTML 内容,可选。
72    pub content_html: Option<String>,
73    /// 文章发布状态。
74    pub status: PostStatus,
75    /// 正式发布时间,None 表示尚未发布。
76    #[cfg_attr(
77        target_arch = "wasm32",
78        serde(
79            default,
80            deserialize_with = "crate::models::rfc3339::deserialize_option"
81        )
82    )]
83    pub published_at: Option<DateTime<Utc>>,
84    /// 创建时间。
85    #[cfg_attr(
86        target_arch = "wasm32",
87        serde(deserialize_with = "crate::models::rfc3339::deserialize")
88    )]
89    pub created_at: DateTime<Utc>,
90    /// 最后更新时间。
91    #[cfg_attr(
92        target_arch = "wasm32",
93        serde(deserialize_with = "crate::models::rfc3339::deserialize")
94    )]
95    pub updated_at: DateTime<Utc>,
96    /// 软删除时间,None 表示未删除。仅回收站查询填充。
97    #[cfg_attr(
98        target_arch = "wasm32",
99        serde(
100            default,
101            deserialize_with = "crate::models::rfc3339::deserialize_option"
102        )
103    )]
104    pub deleted_at: Option<DateTime<Utc>>,
105    /// 关联标签列表。
106    pub tags: Vec<String>,
107    /// 封面图片 URL。
108    pub cover_image: Option<String>,
109    /// 预计阅读时间(分钟)。
110    pub reading_time: u32,
111    /// 字数统计。
112    pub word_count: u32,
113    /// 目录 HTML。
114    pub toc_html: Option<String>,
115    /// 上一篇文章导航信息。
116    pub prev_post: Option<PostNav>,
117    /// 下一篇文章导航信息。
118    pub next_post: Option<PostNav>,
119}
120
121/// 页眉展示所需的最小字段集(P4.2)。
122///
123/// [`crate::components::post::post_header::PostHeader`] 及其内部元信息组件只读取这些字段;
124/// 以小型 props 传递可避免为页眉复制/深比较完整正文(content_html 可能达数百 KB)。
125#[derive(Debug, Clone, PartialEq)]
126pub struct PostHeaderData {
127    pub id: i32,
128    pub title: String,
129    pub status: PostStatus,
130    pub summary: Option<String>,
131    pub tags: Vec<String>,
132    pub published_at: Option<DateTime<Utc>>,
133    pub created_at: DateTime<Utc>,
134    pub reading_time: u32,
135    pub word_count: u32,
136}
137
138impl From<&Post> for PostHeaderData {
139    fn from(post: &Post) -> Self {
140        Self {
141            id: post.id,
142            title: post.title.clone(),
143            status: post.status.clone(),
144            summary: post.summary.clone(),
145            tags: post.tags.clone(),
146            published_at: post.published_at,
147            created_at: post.created_at,
148            reading_time: post.reading_time,
149            word_count: post.word_count,
150        }
151    }
152}
153
154/// 页脚展示所需的最小字段集(P4.2):前后篇导航(标签已移到页眉)。
155#[derive(Debug, Clone, PartialEq)]
156pub struct PostFooterData {
157    pub prev_post: Option<PostNav>,
158    pub next_post: Option<PostNav>,
159}
160
161impl From<&Post> for PostFooterData {
162    fn from(post: &Post) -> Self {
163        Self {
164            prev_post: post.prev_post.clone(),
165            next_post: post.next_post.clone(),
166        }
167    }
168}
169
170/// 文章列表项 DTO。
171///
172/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`,
173/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct PostListItem {
176    /// 文章主键。
177    pub id: i32,
178    /// 作者用户主键。
179    pub author_id: i32,
180    /// 文章标题。
181    pub title: String,
182    /// URL slug,用于生成文章链接。
183    pub slug: String,
184    /// 摘要,可选。
185    pub summary: Option<String>,
186    /// 文章发布状态。
187    pub status: PostStatus,
188    /// 正式发布时间,None 表示尚未发布。
189    #[cfg_attr(
190        target_arch = "wasm32",
191        serde(
192            default,
193            deserialize_with = "crate::models::rfc3339::deserialize_option"
194        )
195    )]
196    pub published_at: Option<DateTime<Utc>>,
197    /// 创建时间。
198    #[cfg_attr(
199        target_arch = "wasm32",
200        serde(deserialize_with = "crate::models::rfc3339::deserialize")
201    )]
202    pub created_at: DateTime<Utc>,
203    /// 最后更新时间。
204    #[cfg_attr(
205        target_arch = "wasm32",
206        serde(deserialize_with = "crate::models::rfc3339::deserialize")
207    )]
208    pub updated_at: DateTime<Utc>,
209    /// 软删除时间,None 表示未删除。仅回收站查询填充。
210    #[cfg_attr(
211        target_arch = "wasm32",
212        serde(
213            default,
214            deserialize_with = "crate::models::rfc3339::deserialize_option"
215        )
216    )]
217    pub deleted_at: Option<DateTime<Utc>>,
218    /// 关联标签列表。
219    pub tags: Vec<String>,
220    /// 封面图片 URL。
221    pub cover_image: Option<String>,
222    /// 预计阅读时间(分钟)。
223    pub reading_time: u32,
224    /// 字数统计。
225    pub word_count: u32,
226}
227
228#[cfg(any(feature = "server", test))]
229/// Feed 条目 DTO(RSS 2.0 / JSON Feed 共享)。
230///
231/// 与 `PostListItem` 不同,这里携带已渲染的 `content_html` 全文,
232/// 供订阅端点渲染;缓存里存的是渲染后的成品(`api::feed::RenderedFeed`)。
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct FeedItem {
235    /// 文章标题。
236    pub title: String,
237    /// URL slug,用于生成文章链接。
238    pub slug: String,
239    /// 摘要,可选。
240    pub summary: Option<String>,
241    /// 渲染后的 HTML 内容,可选(历史文章可能为空)。
242    pub content_html: Option<String>,
243    /// 正式发布时间(已发布文章必有值,行映射时回退 updated_at)。
244    #[cfg_attr(
245        target_arch = "wasm32",
246        serde(deserialize_with = "crate::models::rfc3339::deserialize")
247    )]
248    pub published_at: DateTime<Utc>,
249    /// 最后更新时间。
250    #[cfg_attr(
251        target_arch = "wasm32",
252        serde(deserialize_with = "crate::models::rfc3339::deserialize")
253    )]
254    pub updated_at: DateTime<Utc>,
255    /// 关联标签列表。
256    pub tags: Vec<String>,
257}
258
259/// 前后文章导航结构体。
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct PostNav {
262    /// 文章标题。
263    pub title: String,
264    /// 文章 slug。
265    pub slug: String,
266}
267
268/// 标签领域模型。
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct Tag {
271    /// 标签主键。
272    pub id: i32,
273    /// 标签名称。
274    pub name: String,
275    /// 关联文章数量。
276    pub post_count: i64,
277}
278
279/// 文章统计信息。
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct PostStats {
282    /// 文章总数。
283    pub total: i64,
284    /// 草稿数量。
285    pub drafts: i64,
286    /// 已发布数量。
287    pub published: i64,
288    /// 回收站(软删除)数量。
289    pub trash: i64,
290    /// 近 30 天新建文章数(不含软删除),供仪表盘趋势徽章展示真实增量。
291    pub recent_30d: i64,
292    /// 近 12 个月(含本月)每月发布篇数,旧 → 新,无文章的月为 0;按 UTC 月份
293    /// 归入,供仪表盘「每月发布篇数」柱图使用。
294    pub monthly_published: Vec<MonthCount>,
295}
296
297/// 某个月的篇数。
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299pub struct MonthCount {
300    pub year: i32,
301    /// 1–12。
302    pub month: u32,
303    pub count: i64,
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    #[cfg(feature = "server")]
312    fn post_status_from_str() {
313        assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
314        assert_eq!(
315            PostStatus::from_str("published"),
316            Some(PostStatus::Published)
317        );
318        assert_eq!(PostStatus::from_str("unknown"), None);
319        assert_eq!(PostStatus::from_str(""), None);
320    }
321
322    #[test]
323    fn post_status_as_str() {
324        assert_eq!(PostStatus::Draft.as_str(), "draft");
325        assert_eq!(PostStatus::Published.as_str(), "published");
326    }
327
328    #[test]
329    #[cfg(feature = "server")]
330    fn post_status_roundtrip() {
331        for status in [PostStatus::Draft, PostStatus::Published] {
332            assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
333        }
334    }
335
336    #[test]
337    fn post_status_serde_roundtrip() {
338        let json = serde_json::to_string(&PostStatus::Draft).unwrap();
339        assert_eq!(
340            serde_json::from_str::<PostStatus>(&json).unwrap(),
341            PostStatus::Draft
342        );
343    }
344}