1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum PostStatus {
12 Draft,
14 Published,
16}
17
18impl PostStatus {
19 pub fn as_str(&self) -> &'static str {
21 match self {
22 PostStatus::Draft => "draft",
23 PostStatus::Published => "published",
24 }
25 }
26
27 pub fn label(&self) -> &'static str {
29 match self {
30 PostStatus::Draft => "草稿",
31 PostStatus::Published => "已发布",
32 }
33 }
34
35 pub fn badge_class(&self) -> &'static str {
37 match self {
38 PostStatus::Published => {
39 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
40 }
41 PostStatus::Draft => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
42 }
43 }
44
45 #[cfg(feature = "server")]
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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Post {
59 pub id: i32,
61 pub author_id: i32,
63 pub title: String,
65 pub slug: String,
67 pub summary: Option<String>,
69 pub content_md: String,
71 pub content_html: Option<String>,
73 pub status: PostStatus,
75 pub published_at: Option<DateTime<Utc>>,
77 pub created_at: DateTime<Utc>,
79 pub updated_at: DateTime<Utc>,
81 pub deleted_at: Option<DateTime<Utc>>,
83 pub tags: Vec<String>,
85 pub cover_image: Option<String>,
87 pub reading_time: u32,
89 pub word_count: u32,
91 pub toc_html: Option<String>,
93 pub prev_post: Option<PostNav>,
95 pub next_post: Option<PostNav>,
97}
98
99fn format_date(dt: DateTime<Utc>) -> String {
103 dt.format("%Y-%m-%d").to_string()
104}
105
106impl Post {
107 pub fn formatted_date(&self) -> String {
109 format_date(self.published_at.unwrap_or(self.created_at))
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct PostListItem {
119 pub id: i32,
121 pub author_id: i32,
123 pub title: String,
125 pub slug: String,
127 pub summary: Option<String>,
129 pub status: PostStatus,
131 pub published_at: Option<DateTime<Utc>>,
133 pub created_at: DateTime<Utc>,
135 pub updated_at: DateTime<Utc>,
137 pub deleted_at: Option<DateTime<Utc>>,
139 pub tags: Vec<String>,
141 pub cover_image: Option<String>,
143 pub reading_time: u32,
145 pub word_count: u32,
147}
148
149impl PostListItem {
150 pub fn formatted_date(&self) -> String {
152 format_date(self.published_at.unwrap_or(self.created_at))
153 }
154
155 pub fn status_label(&self) -> &'static str {
157 self.status.label()
158 }
159
160 pub fn status_class(&self) -> &'static str {
162 match self.status {
163 PostStatus::Published => "text-green-600 dark:text-green-400",
164 PostStatus::Draft => "text-gray-400 dark:text-gray-500",
165 }
166 }
167}
168
169#[cfg(any(feature = "server", test))]
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct FeedItem {
176 pub title: String,
178 pub slug: String,
180 pub summary: Option<String>,
182 pub content_html: Option<String>,
184 pub published_at: DateTime<Utc>,
186 pub updated_at: DateTime<Utc>,
188 pub tags: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct PostNav {
195 pub title: String,
197 pub slug: String,
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct Tag {
204 pub id: i32,
206 pub name: String,
208 pub post_count: i64,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct PostStats {
215 pub total: i64,
217 pub drafts: i64,
219 pub published: i64,
221 pub trash: i64,
223 pub recent_30d: i64,
225 pub activity_30d: Vec<i64>,
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use chrono::{TimeZone, Utc};
234
235 fn sample_post() -> Post {
236 Post {
237 id: 1,
238 author_id: 1,
239 title: "Test".to_string(),
240 slug: "test".to_string(),
241 summary: None,
242 content_md: "content".to_string(),
243 content_html: None,
244 status: PostStatus::Draft,
245 published_at: None,
246 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
247 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
248 deleted_at: None,
249 tags: vec![],
250 cover_image: None,
251 reading_time: 1,
252 word_count: 10,
253 toc_html: None,
254 prev_post: None,
255 next_post: None,
256 }
257 }
258
259 fn sample_post_list_item() -> PostListItem {
260 PostListItem {
261 id: 1,
262 author_id: 1,
263 title: "Test".to_string(),
264 slug: "test".to_string(),
265 summary: None,
266 status: PostStatus::Draft,
267 published_at: None,
268 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
269 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
270 deleted_at: None,
271 tags: vec![],
272 cover_image: None,
273 reading_time: 1,
274 word_count: 10,
275 }
276 }
277
278 #[test]
279 #[cfg(feature = "server")]
280 fn post_status_from_str() {
281 assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
282 assert_eq!(
283 PostStatus::from_str("published"),
284 Some(PostStatus::Published)
285 );
286 assert_eq!(PostStatus::from_str("unknown"), None);
287 assert_eq!(PostStatus::from_str(""), None);
288 }
289
290 #[test]
291 fn post_status_as_str() {
292 assert_eq!(PostStatus::Draft.as_str(), "draft");
293 assert_eq!(PostStatus::Published.as_str(), "published");
294 }
295
296 #[test]
297 #[cfg(feature = "server")]
298 fn post_status_roundtrip() {
299 for status in [PostStatus::Draft, PostStatus::Published] {
300 assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
301 }
302 }
303
304 #[test]
305 fn formatted_date_uses_published_at_when_available() {
306 let mut post = sample_post();
307 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
308 assert_eq!(post.formatted_date(), "2024-06-01");
309 }
310
311 #[test]
312 fn formatted_date_falls_back_to_created_at() {
313 let post = sample_post();
314 assert_eq!(post.formatted_date(), "2024-01-15");
315 }
316
317 #[test]
318 fn post_list_item_formatted_date_uses_published_at_when_available() {
319 let mut post = sample_post_list_item();
320 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
321 assert_eq!(post.formatted_date(), "2024-06-01");
322 }
323
324 #[test]
325 fn post_list_item_formatted_date_falls_back_to_created_at() {
326 let post = sample_post_list_item();
327 assert_eq!(post.formatted_date(), "2024-01-15");
328 }
329
330 #[test]
331 fn post_list_item_status_label() {
332 let mut post = sample_post_list_item();
333 post.status = PostStatus::Published;
334 assert_eq!(post.status_label(), "已发布");
335 post.status = PostStatus::Draft;
336 assert_eq!(post.status_label(), "草稿");
337 }
338
339 #[test]
340 fn post_list_item_status_class_returns_non_empty() {
341 let mut post = sample_post_list_item();
342 post.status = PostStatus::Published;
343 assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
344 post.status = PostStatus::Draft;
345 assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
346 }
347
348 #[test]
349 fn post_status_badge_class_matches_status() {
350 assert_eq!(
351 PostStatus::Published.badge_class(),
352 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
353 );
354 assert_eq!(
355 PostStatus::Draft.badge_class(),
356 "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
357 );
358 }
359
360 #[test]
361 fn post_status_serde_roundtrip() {
362 let json = serde_json::to_string(&PostStatus::Draft).unwrap();
363 assert_eq!(
364 serde_json::from_str::<PostStatus>(&json).unwrap(),
365 PostStatus::Draft
366 );
367 }
368}