#![allow(clippy::missing_errors_doc)] #![allow(clippy::unnecessary_struct_initialization)] #![allow(clippy::unused_async)] use axum::http::HeaderMap; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set}; use serde::{Deserialize, Serialize}; use crate::{ controllers::admin::check_auth, models::_entities::{categories, posts, tags}, services::content, }; #[derive(Clone, Debug, Deserialize)] pub struct TaxonomyPayload { pub name: Option, #[serde(default)] pub slug: Option, #[serde(default)] pub description: Option, #[serde(default)] pub cover_image: Option, #[serde(default)] pub accent_color: Option, #[serde(default)] pub seo_title: Option, #[serde(default)] pub seo_description: Option, } #[derive(Clone, Debug, Serialize)] pub struct AdminCategoryRecord { pub id: i32, pub name: String, pub slug: String, pub count: usize, pub description: Option, pub cover_image: Option, pub accent_color: Option, pub seo_title: Option, pub seo_description: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize)] pub struct AdminTagRecord { pub id: i32, pub name: String, pub slug: String, pub count: usize, pub description: Option, pub cover_image: Option, pub accent_color: Option, pub seo_title: Option, pub seo_description: Option, pub created_at: String, pub updated_at: String, } fn slugify(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in value.trim().chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); last_was_dash = false; } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { slug.push('-'); last_was_dash = true; } } slug.trim_matches('-').to_string() } fn normalized_name(params: &TaxonomyPayload, label: &str) -> Result { params .name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .ok_or_else(|| Error::BadRequest(format!("{label}名称不能为空"))) } fn normalized_slug(value: Option<&str>, fallback: &str, label: &str) -> Result { let slug = value .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| slugify(fallback)); if slug.is_empty() { return Err(Error::BadRequest(format!( "{label} slug 不能为空,请填写英文字母 / 数字 / 连字符" ))); } Ok(slug) } fn normalized_token(value: &str) -> String { value.trim().to_ascii_lowercase() } fn trim_to_option(value: Option) -> Option { value.and_then(|item| { let trimmed = item.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } }) } fn post_tag_values(post: &posts::Model) -> Vec { post.tags .as_ref() .and_then(|value| serde_json::from_value::>(value.clone()).ok()) .unwrap_or_default() .into_iter() .map(|item| normalized_token(&item)) .filter(|item| !item.is_empty()) .collect() } fn category_name(item: &categories::Model) -> String { item.name.clone().unwrap_or_else(|| item.slug.clone()) } fn tag_name(item: &tags::Model) -> String { item.name.clone().unwrap_or_else(|| item.slug.clone()) } fn build_category_record( item: &categories::Model, post_items: &[posts::Model], ) -> AdminCategoryRecord { let name = category_name(item); let aliases = [normalized_token(&name), normalized_token(&item.slug)]; let count = post_items .iter() .filter(|post| { post.category .as_deref() .map(normalized_token) .is_some_and(|value| aliases.iter().any(|alias| alias == &value)) }) .count(); AdminCategoryRecord { id: item.id, name, slug: item.slug.clone(), count, description: item.description.clone(), cover_image: item.cover_image.clone(), accent_color: item.accent_color.clone(), seo_title: item.seo_title.clone(), seo_description: item.seo_description.clone(), created_at: item.created_at.to_rfc3339(), updated_at: item.updated_at.to_rfc3339(), } } fn build_tag_record(item: &tags::Model, post_items: &[posts::Model]) -> AdminTagRecord { let name = tag_name(item); let aliases = [normalized_token(&name), normalized_token(&item.slug)]; let count = post_items .iter() .filter(|post| { post_tag_values(post) .into_iter() .any(|value| aliases.iter().any(|alias| alias == &value)) }) .count(); AdminTagRecord { id: item.id, name, slug: item.slug.clone(), count, description: item.description.clone(), cover_image: item.cover_image.clone(), accent_color: item.accent_color.clone(), seo_title: item.seo_title.clone(), seo_description: item.seo_description.clone(), created_at: item.created_at.to_rfc3339(), updated_at: item.updated_at.to_rfc3339(), } } async fn load_category(ctx: &AppContext, id: i32) -> Result { categories::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or(Error::NotFound) } async fn load_tag(ctx: &AppContext, id: i32) -> Result { tags::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or(Error::NotFound) } async fn ensure_category_slug_unique( ctx: &AppContext, slug: &str, exclude_id: Option, ) -> Result<()> { if let Some(existing) = categories::Entity::find() .filter(categories::Column::Slug.eq(slug)) .one(&ctx.db) .await? { if Some(existing.id) != exclude_id { return Err(Error::BadRequest("分类 slug 已存在".to_string())); } } Ok(()) } async fn ensure_tag_slug_unique( ctx: &AppContext, slug: &str, exclude_id: Option, ) -> Result<()> { if let Some(existing) = tags::Entity::find() .filter(tags::Column::Slug.eq(slug)) .one(&ctx.db) .await? { if Some(existing.id) != exclude_id { return Err(Error::BadRequest("标签 slug 已存在".to_string())); } } Ok(()) } async fn load_posts(ctx: &AppContext) -> Result> { Ok(posts::Entity::find().all(&ctx.db).await?) } #[debug_handler] pub async fn list_categories( headers: HeaderMap, State(ctx): State, ) -> Result { check_auth(&headers)?; let items = categories::Entity::find() .order_by_asc(categories::Column::Slug) .all(&ctx.db) .await?; let post_items = load_posts(&ctx).await?; format::json( items .into_iter() .map(|item| build_category_record(&item, &post_items)) .collect::>(), ) } #[debug_handler] pub async fn create_category( headers: HeaderMap, State(ctx): State, Json(payload): Json, ) -> Result { check_auth(&headers)?; let name = normalized_name(&payload, "分类")?; let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?; ensure_category_slug_unique(&ctx, &slug, None).await?; let item = categories::ActiveModel { name: Set(Some(name)), slug: Set(slug), description: Set(trim_to_option(payload.description)), cover_image: Set(trim_to_option(payload.cover_image)), accent_color: Set(trim_to_option(payload.accent_color)), seo_title: Set(trim_to_option(payload.seo_title)), seo_description: Set(trim_to_option(payload.seo_description)), ..Default::default() } .insert(&ctx.db) .await?; let post_items = load_posts(&ctx).await?; format::json(build_category_record(&item, &post_items)) } #[debug_handler] pub async fn update_category( headers: HeaderMap, Path(id): Path, State(ctx): State, Json(payload): Json, ) -> Result { check_auth(&headers)?; let name = normalized_name(&payload, "分类")?; let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?; ensure_category_slug_unique(&ctx, &slug, Some(id)).await?; let item = load_category(&ctx, id).await?; let previous_name = item.name.clone(); let previous_slug = item.slug.clone(); if previous_name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) != Some(name.as_str()) { content::rewrite_category_references( &ctx, previous_name.as_deref(), &previous_slug, Some(&name), ) .await?; } let mut active = item.into_active_model(); active.name = Set(Some(name)); active.slug = Set(slug); active.description = Set(trim_to_option(payload.description)); active.cover_image = Set(trim_to_option(payload.cover_image)); active.accent_color = Set(trim_to_option(payload.accent_color)); active.seo_title = Set(trim_to_option(payload.seo_title)); active.seo_description = Set(trim_to_option(payload.seo_description)); let updated = active.update(&ctx.db).await?; let post_items = load_posts(&ctx).await?; format::json(build_category_record(&updated, &post_items)) } #[debug_handler] pub async fn delete_category( headers: HeaderMap, Path(id): Path, State(ctx): State, ) -> Result { check_auth(&headers)?; let item = load_category(&ctx, id).await?; content::rewrite_category_references(&ctx, item.name.as_deref(), &item.slug, None).await?; item.delete(&ctx.db).await?; format::empty() } #[debug_handler] pub async fn list_tags(headers: HeaderMap, State(ctx): State) -> Result { check_auth(&headers)?; let items = tags::Entity::find() .order_by_asc(tags::Column::Slug) .all(&ctx.db) .await?; let post_items = load_posts(&ctx).await?; format::json( items .into_iter() .map(|item| build_tag_record(&item, &post_items)) .collect::>(), ) } #[debug_handler] pub async fn create_tag( headers: HeaderMap, State(ctx): State, Json(payload): Json, ) -> Result { check_auth(&headers)?; let name = normalized_name(&payload, "标签")?; let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?; ensure_tag_slug_unique(&ctx, &slug, None).await?; let item = tags::ActiveModel { name: Set(Some(name)), slug: Set(slug), description: Set(trim_to_option(payload.description)), cover_image: Set(trim_to_option(payload.cover_image)), accent_color: Set(trim_to_option(payload.accent_color)), seo_title: Set(trim_to_option(payload.seo_title)), seo_description: Set(trim_to_option(payload.seo_description)), ..Default::default() } .insert(&ctx.db) .await?; let post_items = load_posts(&ctx).await?; format::json(build_tag_record(&item, &post_items)) } #[debug_handler] pub async fn update_tag( headers: HeaderMap, Path(id): Path, State(ctx): State, Json(payload): Json, ) -> Result { check_auth(&headers)?; let name = normalized_name(&payload, "标签")?; let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?; ensure_tag_slug_unique(&ctx, &slug, Some(id)).await?; let item = load_tag(&ctx, id).await?; let previous_name = item.name.clone(); let previous_slug = item.slug.clone(); if previous_name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) != Some(name.as_str()) { content::rewrite_tag_references( &ctx, previous_name.as_deref(), &previous_slug, Some(&name), ) .await?; } let mut active = item.into_active_model(); active.name = Set(Some(name)); active.slug = Set(slug); active.description = Set(trim_to_option(payload.description)); active.cover_image = Set(trim_to_option(payload.cover_image)); active.accent_color = Set(trim_to_option(payload.accent_color)); active.seo_title = Set(trim_to_option(payload.seo_title)); active.seo_description = Set(trim_to_option(payload.seo_description)); let updated = active.update(&ctx.db).await?; let post_items = load_posts(&ctx).await?; format::json(build_tag_record(&updated, &post_items)) } #[debug_handler] pub async fn delete_tag( headers: HeaderMap, Path(id): Path, State(ctx): State, ) -> Result { check_auth(&headers)?; let item = load_tag(&ctx, id).await?; content::rewrite_tag_references(&ctx, item.name.as_deref(), &item.slug, None).await?; item.delete(&ctx.db).await?; format::empty() } pub fn routes() -> Routes { Routes::new() .add( "/api/admin/categories", get(list_categories).post(create_category), ) .add( "/api/admin/categories/{id}", patch(update_category).delete(delete_category), ) .add("/api/admin/tags", get(list_tags).post(create_tag)) .add("/api/admin/tags/{id}", patch(update_tag).delete(delete_tag)) }