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 #[cfg(feature = "server")]
29 pub fn from_str(s: &str) -> Option<Self> {
30 match s {
31 "draft" => Some(PostStatus::Draft),
32 "published" => Some(PostStatus::Published),
33 _ => None,
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Post {
41 pub id: i32,
43 pub author_id: i32,
45 pub title: String,
47 pub slug: String,
49 pub summary: Option<String>,
51 pub content_md: String,
53 pub content_html: Option<String>,
55 pub status: PostStatus,
57 pub published_at: Option<DateTime<Utc>>,
59 pub created_at: DateTime<Utc>,
61 pub updated_at: DateTime<Utc>,
63 pub deleted_at: Option<DateTime<Utc>>,
65 pub tags: Vec<String>,
67 pub cover_image: Option<String>,
69 pub reading_time: u32,
71 pub word_count: u32,
73 pub toc_html: Option<String>,
75 pub prev_post: Option<PostNav>,
77 pub next_post: Option<PostNav>,
79}
80
81fn format_date(dt: DateTime<Utc>) -> String {
85 dt.format("%Y-%m-%d").to_string()
86}
87
88impl Post {
89 pub fn formatted_date(&self) -> String {
91 format_date(self.published_at.unwrap_or(self.created_at))
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct PostListItem {
101 pub id: i32,
103 pub author_id: i32,
105 pub title: String,
107 pub slug: String,
109 pub summary: Option<String>,
111 pub status: PostStatus,
113 pub published_at: Option<DateTime<Utc>>,
115 pub created_at: DateTime<Utc>,
117 pub updated_at: DateTime<Utc>,
119 pub deleted_at: Option<DateTime<Utc>>,
121 pub tags: Vec<String>,
123 pub cover_image: Option<String>,
125 pub reading_time: u32,
127 pub word_count: u32,
129}
130
131impl PostListItem {
132 pub fn formatted_date(&self) -> String {
134 format_date(self.published_at.unwrap_or(self.created_at))
135 }
136
137 pub fn status_label(&self) -> &'static str {
139 match self.status {
140 PostStatus::Published => "已发布",
141 PostStatus::Draft => "草稿",
142 }
143 }
144
145 pub fn status_class(&self) -> &'static str {
147 match self.status {
148 PostStatus::Published => "text-green-600 dark:text-green-400",
149 PostStatus::Draft => "text-gray-400 dark:text-gray-500",
150 }
151 }
152
153 pub fn status_badge_class(&self) -> &'static str {
155 match self.status {
156 PostStatus::Published => {
157 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
158 }
159 PostStatus::Draft => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct FeedItem {
170 pub title: String,
172 pub slug: String,
174 pub summary: Option<String>,
176 pub content_html: Option<String>,
178 pub published_at: DateTime<Utc>,
180 pub updated_at: DateTime<Utc>,
182 pub tags: Vec<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct PostNav {
189 pub title: String,
191 pub slug: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct Tag {
198 pub id: i32,
200 pub name: String,
202 pub post_count: i64,
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct PostStats {
209 pub total: i64,
211 pub drafts: i64,
213 pub published: i64,
215 pub trash: i64,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use chrono::{TimeZone, Utc};
223
224 fn sample_post() -> Post {
225 Post {
226 id: 1,
227 author_id: 1,
228 title: "Test".to_string(),
229 slug: "test".to_string(),
230 summary: None,
231 content_md: "content".to_string(),
232 content_html: None,
233 status: PostStatus::Draft,
234 published_at: None,
235 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
236 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
237 deleted_at: None,
238 tags: vec![],
239 cover_image: None,
240 reading_time: 1,
241 word_count: 10,
242 toc_html: None,
243 prev_post: None,
244 next_post: None,
245 }
246 }
247
248 fn sample_post_list_item() -> PostListItem {
249 PostListItem {
250 id: 1,
251 author_id: 1,
252 title: "Test".to_string(),
253 slug: "test".to_string(),
254 summary: None,
255 status: PostStatus::Draft,
256 published_at: None,
257 created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
258 updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
259 deleted_at: None,
260 tags: vec![],
261 cover_image: None,
262 reading_time: 1,
263 word_count: 10,
264 }
265 }
266
267 #[test]
268 #[cfg(feature = "server")]
269 fn post_status_from_str() {
270 assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
271 assert_eq!(
272 PostStatus::from_str("published"),
273 Some(PostStatus::Published)
274 );
275 assert_eq!(PostStatus::from_str("unknown"), None);
276 assert_eq!(PostStatus::from_str(""), None);
277 }
278
279 #[test]
280 fn post_status_as_str() {
281 assert_eq!(PostStatus::Draft.as_str(), "draft");
282 assert_eq!(PostStatus::Published.as_str(), "published");
283 }
284
285 #[test]
286 #[cfg(feature = "server")]
287 fn post_status_roundtrip() {
288 for status in [PostStatus::Draft, PostStatus::Published] {
289 assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
290 }
291 }
292
293 #[test]
294 fn formatted_date_uses_published_at_when_available() {
295 let mut post = sample_post();
296 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
297 assert_eq!(post.formatted_date(), "2024-06-01");
298 }
299
300 #[test]
301 fn formatted_date_falls_back_to_created_at() {
302 let post = sample_post();
303 assert_eq!(post.formatted_date(), "2024-01-15");
304 }
305
306 #[test]
307 fn post_list_item_formatted_date_uses_published_at_when_available() {
308 let mut post = sample_post_list_item();
309 post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
310 assert_eq!(post.formatted_date(), "2024-06-01");
311 }
312
313 #[test]
314 fn post_list_item_formatted_date_falls_back_to_created_at() {
315 let post = sample_post_list_item();
316 assert_eq!(post.formatted_date(), "2024-01-15");
317 }
318
319 #[test]
320 fn post_list_item_status_label() {
321 let mut post = sample_post_list_item();
322 post.status = PostStatus::Published;
323 assert_eq!(post.status_label(), "已发布");
324 post.status = PostStatus::Draft;
325 assert_eq!(post.status_label(), "草稿");
326 }
327
328 #[test]
329 fn post_list_item_status_class_returns_non_empty() {
330 let mut post = sample_post_list_item();
331 post.status = PostStatus::Published;
332 assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
333 post.status = PostStatus::Draft;
334 assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
335 }
336
337 #[test]
338 fn post_list_item_status_badge_class_returns_non_empty() {
339 let mut post = sample_post_list_item();
340 post.status = PostStatus::Published;
341 assert_eq!(
342 post.status_badge_class(),
343 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
344 );
345 post.status = PostStatus::Draft;
346 assert_eq!(
347 post.status_badge_class(),
348 "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
349 );
350 }
351
352 #[test]
353 fn post_status_serde_roundtrip() {
354 let json = serde_json::to_string(&PostStatus::Draft).unwrap();
355 assert_eq!(
356 serde_json::from_str::<PostStatus>(&json).unwrap(),
357 PostStatus::Draft
358 );
359 }
360}