feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
465
backend/src/controllers/admin_taxonomy.rs
Normal file
465
backend/src/controllers/admin_taxonomy.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
#![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)?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
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(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
}
|
||||
|
||||
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?;
|
||||
content::sync_markdown_posts(&ctx).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(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_tags(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
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(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
}
|
||||
|
||||
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?;
|
||||
content::sync_markdown_posts(&ctx).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(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).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))
|
||||
}
|
||||
Reference in New Issue
Block a user