#![allow(clippy::unused_unit, deprecated, unused_imports)] use dioxus::prelude::*; #[cfg(feature = "server")] use crate::api::utils::{db_conn_error, query_error, tx_error}; #[cfg(feature = "server")] use crate::auth::session::get_session_from_ctx; use crate::db::pool::get_conn; use crate::models::post::{Post, PostStats, PostStatus, Tag}; use crate::models::user::{User, UserRole}; #[cfg(feature = "server")] use crate::utils::text::{auto_summary, count_words}; // ============================================================================ // Server-side helpers (only compiled when server feature is enabled) // ============================================================================ #[cfg(feature = "server")] async fn get_current_admin_user() -> Result { let token = match get_session_from_ctx() { Some(t) => t, None => return Err(ServerFnError::new("未登录")), }; let user = match crate::api::auth::get_user_by_token(&token).await? { Some(u) => u, None => return Err(ServerFnError::new("会话已过期")), }; if user.role != UserRole::Admin { return Err(ServerFnError::new("权限不足")); } Ok(user) } // ============================================================================ // Slug utilities // ============================================================================ #[cfg(feature = "server")] fn slugify(title: &str) -> String { let slug: String = title .to_lowercase() .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' } }) .collect(); let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect(); let slug = parts.join("-"); if slug.is_empty() { return format!("{}", chrono::Utc::now().timestamp()); } slug.chars().take(100).collect() } #[cfg(feature = "server")] fn is_valid_slug(slug: &str) -> bool { if slug.is_empty() || slug.len() > 200 { return false; } slug.chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') } #[cfg(feature = "server")] async fn ensure_unique_slug( client: &tokio_postgres::Client, base: &str, exclude_id: Option, ) -> Result { let mut candidate = base.to_string(); let mut suffix = 2; loop { let exists = if let Some(exclude) = exclude_id { client .query_opt( "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2", &[&candidate, &exclude], ) .await .map_err(query_error)? .is_some() } else { client .query_opt( "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL", &[&candidate], ) .await .map_err(query_error)? .is_some() }; if !exists { return Ok(candidate); } candidate = format!("{}-{}", base, suffix); suffix += 1; if candidate.len() > 200 { return Err(ServerFnError::new("无法生成唯一 slug")); } } } // ============================================================================ // Markdown rendering (enhanced with TOC, word count, reading time, anchors) // ============================================================================ #[cfg(feature = "server")] fn clean_html(input: &str) -> String { let mut builder = ammonia::Builder::default(); builder .add_generic_attributes(&[ "class", "aria-hidden", "aria-label", "id", "role", "accesskey", "title", ]) .add_tags(&["details", "summary"]) .url_relative(ammonia::UrlRelative::PassThrough) .add_tag_attributes("a", &["class", "aria-hidden", "aria-label"]) .add_tag_attributes("span", &["class"]) .add_tag_attributes("h1", &["id", "class"]) .add_tag_attributes("h2", &["id", "class"]) .add_tag_attributes("h3", &["id", "class"]) .add_tag_attributes("h4", &["id", "class"]) .add_tag_attributes("h5", &["id", "class"]) .add_tag_attributes("h6", &["id", "class"]); builder.clean(input).to_string() } #[derive(Debug, Clone)] #[cfg(feature = "server")] struct RenderedContent { html: String, toc_html: String, } #[cfg(feature = "server")] fn render_markdown_enhanced(md: &str) -> RenderedContent { use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd}; // 1. Parse markdown and collect headings for TOC let parser = pulldown_cmark::Parser::new_ext(md, Options::all()); let mut headings: Vec<(u8, String, String)> = Vec::new(); // (level, text, id) let mut current_heading: Option<(u8, String)> = None; for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { let lvl = match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, }; current_heading = Some((lvl, String::new())); } Event::Text(text) => { if let Some((_, ref mut content)) = current_heading { content.push_str(&text); } } Event::Code(code) => { if let Some((_, ref mut content)) = current_heading { content.push_str(&code); } } Event::End(TagEnd::Heading(_)) => { if let Some((lvl, text)) = current_heading.take() { let id = slugify_heading(&text); headings.push((lvl, text, id)); } } _ => {} } } // 2. Generate TOC HTML let toc_html = generate_toc_html(&headings); // 3. Generate HTML with heading anchors let parser = pulldown_cmark::Parser::new_ext(md, Options::ENABLE_TABLES); let mut html = String::new(); let mut heading_idx = 0; let mut in_heading = false; let mut in_codeblock = false; let mut code_lang: Option = None; let mut code_buffer = String::new(); let mut non_heading_events: Vec = Vec::new(); for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); } in_heading = true; if heading_idx < headings.len() { let (_, _, ref id) = headings[heading_idx]; let tag = match level { HeadingLevel::H1 => "h1", HeadingLevel::H2 => "h2", HeadingLevel::H3 => "h3", HeadingLevel::H4 => "h4", HeadingLevel::H5 => "h5", HeadingLevel::H6 => "h6", }; html.push_str(&format!("<{} id=\"{}\">", tag, id)); } } Event::End(TagEnd::Heading(level)) => { if heading_idx < headings.len() { let (_, _, ref id) = headings[heading_idx]; let tag = match level { HeadingLevel::H1 => "h1", HeadingLevel::H2 => "h2", HeadingLevel::H3 => "h3", HeadingLevel::H4 => "h4", HeadingLevel::H5 => "h5", HeadingLevel::H6 => "h6", }; html.push_str(&format!( "#", id, tag )); heading_idx += 1; } in_heading = false; } Event::Start(Tag::CodeBlock(kind)) => { if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); non_heading_events = Vec::new(); } in_codeblock = true; code_lang = match kind { pulldown_cmark::CodeBlockKind::Fenced(lang) => { if lang.is_empty() { None } else { Some(lang.to_string()) } } _ => None, }; code_buffer.clear(); } Event::Text(text) if in_codeblock => { code_buffer.push_str(&text); } Event::End(TagEnd::CodeBlock) => { let highlighted = crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); html.push_str("
");
                html.push_str(&highlighted);
                html.push_str("
"); in_codeblock = false; } _ => { if in_heading { match event { Event::Text(text) => html.push_str(&clean_html(&text)), Event::Code(code) => { html.push_str(""); html.push_str(&clean_html(&code)); html.push_str(""); } _ => {} } } else if !in_codeblock { non_heading_events.push(event); } } } } // Flush remaining non-heading events if !non_heading_events.is_empty() { pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); } RenderedContent { html: clean_html(&html), toc_html, } } #[cfg(feature = "server")] fn generate_toc_html(headings: &[(u8, String, String)]) -> String { if headings.is_empty() { return String::new(); } let mut html = String::from("
    "); let mut stack: Vec = vec![headings[0].0]; for (i, (level, text, id)) in headings.iter().enumerate() { let level = *level; if i > 0 { let prev_level = headings[i - 1].0; if level > prev_level { // Open new nested lists for _ in prev_level..level { html.push_str("
      "); stack.push(level); } } else if level < prev_level { // Close nested lists while let Some(top) = stack.last() { if *top > level { html.push_str("
    "); stack.pop(); } else { break; } } html.push_str(""); } else { html.push_str(""); } } html.push_str(&format!( "
  • {}", id, clean_html(text), clean_html(text) )); } // Close remaining lists while stack.len() > 1 { html.push_str("
"); stack.pop(); } html.push_str(""); html } #[cfg(feature = "server")] fn slugify_heading(text: &str) -> String { let mut slug = String::new(); let mut prev_dash = true; for c in text.to_lowercase().chars() { if c.is_alphanumeric() { slug.push(c); prev_dash = false; } else if !prev_dash { slug.push('-'); prev_dash = true; } } if slug.ends_with('-') { slug.pop(); } if slug.is_empty() { slug.push_str("heading"); } slug } // ============================================================================ // Tag helpers // ============================================================================ #[cfg(feature = "server")] #[allow(dead_code)] async fn set_post_tags( client: &tokio_postgres::Client, post_id: i32, tags: &[String], ) -> Result<(), ServerFnError> { // Remove existing tags client .execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id]) .await .map_err(|e| { tracing::error!("delete tag links failed: {:?}", e); ServerFnError::new(format!("删除标签关联失败: {}", e)) })?; for tag_name in tags { let tag_name = tag_name.trim(); if tag_name.is_empty() { continue; } // Insert or get tag let tag_id: i32 = { let row = client .query_opt( "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", &[&tag_name], ) .await .map_err(|e| { tracing::error!("create tag failed: {:?}", e); ServerFnError::new(format!("创建标签失败: {}", e)) })?; match row { Some(r) => r.get(0), None => { // Tag already exists, fetch its id let row = client .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name]) .await .map_err(|e| { tracing::error!("query tag failed: {:?}", e); ServerFnError::new(format!("查询标签失败: {}", e)) })?; row.map(|r| r.get(0)) .ok_or_else(|| ServerFnError::new(format!("标签不存在: {}", tag_name)))? } } }; client .execute( "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", &[&post_id, &tag_id], ) .await .map_err(|e| { tracing::error!("link tag failed: {:?}", e); ServerFnError::new(format!("关联标签失败: {}", e)) })?; } Ok(()) } #[cfg(feature = "server")] async fn get_post_tags(client: &tokio_postgres::Client, post_id: i32) -> Vec { let rows = client .query( "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1 ORDER BY t.name", &[&post_id], ) .await; match rows { Ok(rows) => rows.iter().map(|r| r.get(0)).collect(), Err(_) => vec![], } } // ============================================================================ // Row to Post conversion // ============================================================================ #[cfg(feature = "server")] async fn row_to_post_list(client: &tokio_postgres::Client, row: &tokio_postgres::Row) -> Post { let id: i32 = row.get("id"); let role_str: String = row.get("status"); let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); let tags = get_post_tags(client, id).await; let content_md: String = row.get("content_md"); let word_count = count_words(&content_md); Post { id, author_id: row.get("author_id"), title: row.get("title"), slug: row.get("slug"), summary: row.get("summary"), content_md, content_html: row.get("content_html"), status, published_at: row.get("published_at"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), tags, cover_image: row.get("cover_image"), reading_time: (word_count / 200).max(1), word_count, toc_html: None, prev_post: None, next_post: None, } } #[cfg(feature = "server")] async fn row_to_post_full(client: &tokio_postgres::Client, row: &tokio_postgres::Row) -> Post { let id: i32 = row.get("id"); let role_str: String = row.get("status"); let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); let tags = get_post_tags(client, id).await; let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") { if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") { Some(crate::models::post::PostNav { title: prev_title, slug: prev_slug, }) } else { None } } else { None }; let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") { if let Ok(next_slug) = row.try_get::<_, String>("next_slug") { Some(crate::models::post::PostNav { title: next_title, slug: next_slug, }) } else { None } } else { None }; let content_md: String = row.get("content_md"); let word_count = count_words(&content_md); let rendered = render_markdown_enhanced(&content_md); Post { id, author_id: row.get("author_id"), title: row.get("title"), slug: row.get("slug"), summary: row.get("summary"), content_md, content_html: Some(rendered.html), status, published_at: row.get("published_at"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), tags, cover_image: row.get("cover_image"), reading_time: (word_count / 200).max(1), word_count, toc_html: if rendered.toc_html.is_empty() { None } else { Some(rendered.toc_html) }, prev_post, next_post, } } // ============================================================================ // API Response structs // ============================================================================ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[allow(dead_code)] pub struct CreatePostRequest { pub title: String, pub slug: Option, pub summary: Option, pub content_md: String, pub status: String, pub tags: Vec, pub cover_image: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CreatePostResponse { pub success: bool, pub message: String, pub post_id: Option, pub slug: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PostListResponse { pub posts: Vec, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TagListResponse { pub tags: Vec, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PostStatsResponse { pub stats: PostStats, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SinglePostResponse { pub post: Option, } // ============================================================================ // Server Functions // ============================================================================ #[server(CreatePost, "/api")] pub async fn create_post( title: String, slug: Option, summary: Option, content_md: String, status: String, tags: Vec, cover_image: Option, ) -> Result { let user = get_current_admin_user().await?; if title.trim().is_empty() { return Ok(CreatePostResponse { success: false, message: "标题不能为空".to_string(), post_id: None, slug: None, }); } if content_md.trim().is_empty() { return Ok(CreatePostResponse { success: false, message: "内容不能为空".to_string(), post_id: None, slug: None, }); } let base_slug = match slug { Some(s) if !s.trim().is_empty() => { let s = s.trim(); if !is_valid_slug(s) { return Ok(CreatePostResponse { success: false, message: "slug 格式无效,只能包含字母、数字、连字符和下划线".to_string(), post_id: None, slug: None, }); } s.to_string() } _ => slugify(&title), }; let mut client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let final_slug = ensure_unique_slug(&client, &base_slug, None).await?; let rendered = render_markdown_enhanced(&content_md); let content_html = rendered.html; let summary = summary .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| auto_summary(&content_md)); let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let cover_image = cover_image.filter(|s| !s.trim().is_empty()); let published_at = if post_status == PostStatus::Published { Some(chrono::Utc::now()) } else { None }; let tx = client.transaction().await.map_err(|e| { tx_error(e) })?; let row = tx .query_one( "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, status, published_at, cover_image) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id", &[ &user.id, &title.trim(), &final_slug, &summary, &content_md, &content_html, &post_status.as_str(), &published_at, &cover_image, ], ) .await .map_err(|e| { tracing::error!("create post failed: {:?}", e); ServerFnError::new(format!("创建文章失败: {}", e)) })?; let post_id: i32 = row.get(0); // Set tags let tags_cleaned: Vec = tags .into_iter() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); if !tags_cleaned.is_empty() { // Use the non-transaction client for tag operations (simpler) // Actually we should use the transaction. Let's implement inline. for tag_name in &tags_cleaned { let tag_id: i32 = { let row = tx .query_opt( "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", &[&tag_name.as_str()], ) .await .map_err(|e| { tracing::error!("create tag failed: {:?}", e); ServerFnError::new(format!("创建标签失败: {}", e)) })?; match row { Some(r) => r.get(0), None => { let row = tx .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) .await .map_err(|e| { tracing::error!("query tag failed: {:?}", e); ServerFnError::new(format!("查询标签失败: {}", e)) })?; row.map(|r| r.get(0)).ok_or_else(|| { ServerFnError::new(format!("标签不存在: {}", tag_name)) })? } } }; tx.execute( "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", &[&post_id, &tag_id], ) .await .map_err(|e| { tracing::error!("link tag failed: {:?}", e); ServerFnError::new(format!("关联标签失败: {}", e)) })?; } } tx.commit().await.map_err(|e| { tx_error(e) })?; Ok(CreatePostResponse { success: true, message: "创建成功".to_string(), post_id: Some(post_id), slug: Some(final_slug), }) } #[server(UpdatePost, "/api")] pub async fn update_post( post_id: i32, title: String, slug: Option, summary: Option, content_md: String, status: String, tags: Vec, cover_image: Option, ) -> Result { let user = get_current_admin_user().await?; let mut client = get_conn().await.map_err(|e| { db_conn_error(e) })?; // Verify ownership let exists: bool = client .query_opt( "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", &[&post_id, &user.id], ) .await .map_err(query_error)? .is_some(); if !exists { return Ok(CreatePostResponse { success: false, message: "文章不存在或无权限".to_string(), post_id: None, slug: None, }); } let base_slug = match slug { Some(s) if !s.trim().is_empty() => { let s = s.trim(); if !is_valid_slug(s) { return Ok(CreatePostResponse { success: false, message: "slug 格式无效".to_string(), post_id: None, slug: None, }); } s.to_string() } _ => slugify(&title), }; let final_slug = ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; let rendered = render_markdown_enhanced(&content_md); let content_html = rendered.html; let summary = summary .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| auto_summary(&content_md)); let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); let cover_image = cover_image.filter(|s| !s.trim().is_empty()); let tx = client.transaction().await.map_err(|e| { tx_error(e) })?; // Check if status changed to published and was not published before let old_status_row = tx .query_opt( "SELECT status, published_at FROM posts WHERE id = $1", &[&post_id], ) .await .map_err(|e| { query_error(e) })?; let published_at = if post_status == PostStatus::Published { let was_published = old_status_row .as_ref() .map(|r| { let s: String = r.get(0); s == "published" }) .unwrap_or(false); let existing_published: Option> = old_status_row.as_ref().and_then(|r| r.get(1)); if was_published { existing_published } else { Some(chrono::Utc::now()) } } else { old_status_row.and_then(|r| r.get(1)) }; tx.execute( "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, status = $6, published_at = $7, cover_image = $8, updated_at = NOW() WHERE id = $9", &[ &title.trim(), &final_slug, &summary, &content_md, &content_html, &post_status.as_str(), &published_at, &cover_image, &post_id, ], ) .await .map_err(|e| { tracing::error!("update post failed: {:?}", e); ServerFnError::new(format!("更新文章失败: {}", e)) })?; // Update tags let tags_cleaned: Vec = tags .into_iter() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id]) .await .map_err(|e| { tracing::error!("delete old tags failed: {:?}", e); ServerFnError::new(format!("删除旧标签失败: {}", e)) })?; for tag_name in &tags_cleaned { let tag_id: i32 = { let row = tx .query_opt( "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", &[&tag_name.as_str()], ) .await .map_err(|e| { tracing::error!("create tag failed: {:?}", e); ServerFnError::new(format!("创建标签失败: {}", e)) })?; match row { Some(r) => r.get(0), None => { let row = tx .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) .await .map_err(|e| { tracing::error!("query tag failed: {:?}", e); ServerFnError::new(format!("查询标签失败: {}", e)) })?; row.map(|r| r.get(0)) .ok_or_else(|| ServerFnError::new(format!("标签不存在: {}", tag_name)))? } } }; tx.execute( "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", &[&post_id, &tag_id], ) .await .map_err(|e| { tracing::error!("link tag failed: {:?}", e); ServerFnError::new(format!("关联标签失败: {}", e)) })?; } tx.commit().await.map_err(|e| { tx_error(e) })?; Ok(CreatePostResponse { success: true, message: "更新成功".to_string(), post_id: Some(post_id), slug: Some(final_slug), }) } #[server(GetPostById, "/api")] pub async fn get_post_by_id(post_id: i32) -> Result { let _user = get_current_admin_user().await?; let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let row = client .query_opt( "SELECT id, author_id, title, slug, summary, content_md, content_html, status, published_at, created_at, updated_at, cover_image FROM posts WHERE id = $1 AND deleted_at IS NULL", &[&post_id], ) .await .map_err(|e| { query_error(e) })?; let post = match row { Some(row) => Some(row_to_post_list(&client, &row).await), None => None, }; Ok(SinglePostResponse { post }) } #[server(GetPostBySlug, "/api")] pub async fn get_post_by_slug(slug: String) -> Result { let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let row = client .query_opt( "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, prev.title as prev_title, prev.slug as prev_slug, next.title as next_title, next.slug as next_slug FROM posts p LEFT JOIN LATERAL ( SELECT title, slug FROM posts WHERE published_at < p.published_at AND status = 'published' AND deleted_at IS NULL ORDER BY published_at DESC LIMIT 1 ) prev ON true LEFT JOIN LATERAL ( SELECT title, slug FROM posts WHERE published_at > p.published_at AND status = 'published' AND deleted_at IS NULL ORDER BY published_at ASC LIMIT 1 ) next ON true WHERE p.slug = $1 AND p.deleted_at IS NULL", &[&slug], ) .await .map_err(|e| { query_error(e) })?; let post = match row { Some(row) => Some(row_to_post_full(&client, &row).await), None => None, }; Ok(SinglePostResponse { post }) } #[server(ListPublishedPosts, "/api")] pub async fn list_published_posts( page: i32, per_page: i32, ) -> Result { let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let offset = ((page - 1).max(0) as i64) * (per_page as i64); let limit = per_page as i64; let rows = client .query( "SELECT id, author_id, title, slug, summary, content_md, content_html, status, published_at, created_at, updated_at, cover_image FROM posts WHERE status = 'published' AND deleted_at IS NULL ORDER BY published_at DESC LIMIT $1 OFFSET $2", &[&limit, &offset], ) .await .map_err(|e| { query_error(e) })?; let mut posts = Vec::new(); for row in &rows { posts.push(row_to_post_list(&client, row).await); } Ok(PostListResponse { posts }) } #[server(ListPosts, "/api")] pub async fn list_posts() -> Result { let _user = get_current_admin_user().await?; let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let rows = client .query( "SELECT id, author_id, title, slug, summary, content_md, content_html, status, published_at, created_at, updated_at, cover_image FROM posts WHERE deleted_at IS NULL ORDER BY created_at DESC", &[], ) .await .map_err(|e| { query_error(e) })?; let mut posts = Vec::new(); for row in &rows { posts.push(row_to_post_list(&client, row).await); } Ok(PostListResponse { posts }) } #[server(DeletePost, "/api")] pub async fn delete_post(post_id: i32) -> Result { let _user = get_current_admin_user().await?; let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let result = client .execute( "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", &[&post_id], ) .await .map_err(|e| { tracing::error!("delete failed: {:?}", e); ServerFnError::new(format!("删除失败: {}", e)) })?; if result == 0 { return Ok(CreatePostResponse { success: false, message: "文章不存在".to_string(), post_id: None, slug: None, }); } Ok(CreatePostResponse { success: true, message: "删除成功".to_string(), post_id: Some(post_id), slug: None, }) } #[server(ListTags, "/api")] pub async fn list_tags() -> Result { let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let rows = client .query( "SELECT t.id, t.name, COUNT(pt.post_id) as post_count FROM tags t LEFT JOIN post_tags pt ON t.id = pt.tag_id LEFT JOIN posts p ON pt.post_id = p.id AND p.deleted_at IS NULL AND p.status = 'published' GROUP BY t.id, t.name ORDER BY t.name", &[], ) .await .map_err(|e| { query_error(e) })?; let tags: Vec = rows .iter() .map(|r| Tag { id: r.get("id"), name: r.get("name"), post_count: r.get("post_count"), }) .collect(); Ok(TagListResponse { tags }) } #[server(GetPostsByTag, "/api")] pub async fn get_posts_by_tag(tag_name: String) -> Result { let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let rows = client .query( "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image FROM posts p JOIN post_tags pt ON p.id = pt.post_id JOIN tags t ON pt.tag_id = t.id WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL ORDER BY p.published_at DESC", &[&tag_name], ) .await .map_err(|e| { query_error(e) })?; let mut posts = Vec::new(); for row in &rows { posts.push(row_to_post_list(&client, row).await); } Ok(PostListResponse { posts }) } #[server(GetPostStats, "/api")] pub async fn get_post_stats() -> Result { let _user = get_current_admin_user().await?; let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let total: i64 = client .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) .await .map_err(query_error)? .get(0); let drafts: i64 = client .query_one( "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'", &[], ) .await .map_err(query_error)? .get(0); let published: i64 = client .query_one( "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'", &[], ) .await .map_err(query_error)? .get(0); Ok(PostStatsResponse { stats: PostStats { total, drafts, published, }, }) } #[server(SearchPosts, "/api")] pub async fn search_posts(query: String) -> Result { let client = get_conn().await.map_err(|e| { db_conn_error(e) })?; let search_pattern = format!("%{}%", query); let rows = client .query( "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image FROM posts p WHERE p.status = 'published' AND p.deleted_at IS NULL AND (p.title ILIKE $1 OR p.content_md ILIKE $1) ORDER BY p.published_at DESC", &[&search_pattern], ) .await .map_err(|e| { query_error(e) })?; let mut posts = Vec::new(); for row in &rows { posts.push(row_to_post_list(&client, row).await); } Ok(PostListResponse { posts }) }