All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
484 lines
14 KiB
Rust
484 lines
14 KiB
Rust
#![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<String>,
|
|
#[serde(default)]
|
|
pub slug: Option<String>,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
#[serde(default)]
|
|
pub cover_image: Option<String>,
|
|
#[serde(default)]
|
|
pub accent_color: Option<String>,
|
|
#[serde(default)]
|
|
pub seo_title: Option<String>,
|
|
#[serde(default)]
|
|
pub seo_description: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminCategoryRecord {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub slug: String,
|
|
pub count: usize,
|
|
pub description: Option<String>,
|
|
pub cover_image: Option<String>,
|
|
pub accent_color: Option<String>,
|
|
pub seo_title: Option<String>,
|
|
pub seo_description: Option<String>,
|
|
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<String>,
|
|
pub cover_image: Option<String>,
|
|
pub accent_color: Option<String>,
|
|
pub seo_title: Option<String>,
|
|
pub seo_description: Option<String>,
|
|
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<String> {
|
|
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<String> {
|
|
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<String>) -> Option<String> {
|
|
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<String> {
|
|
post.tags
|
|
.as_ref()
|
|
.and_then(|value| serde_json::from_value::<Vec<String>>(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::Model> {
|
|
categories::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or(Error::NotFound)
|
|
}
|
|
|
|
async fn load_tag(ctx: &AppContext, id: i32) -> Result<tags::Model> {
|
|
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<i32>,
|
|
) -> 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<i32>,
|
|
) -> 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<Vec<posts::Model>> {
|
|
Ok(posts::Entity::find().all(&ctx.db).await?)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn list_categories(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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::<Vec<_>>(),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn create_category(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<TaxonomyPayload>,
|
|
) -> Result<Response> {
|
|
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<i32>,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<TaxonomyPayload>,
|
|
) -> Result<Response> {
|
|
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<i32>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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<AppContext>) -> Result<Response> {
|
|
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::<Vec<_>>(),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn create_tag(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<TaxonomyPayload>,
|
|
) -> Result<Response> {
|
|
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<i32>,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<TaxonomyPayload>,
|
|
) -> Result<Response> {
|
|
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<i32>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
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))
|
|
}
|