feat: add shadcn admin workspace

This commit is contained in:
2026-03-28 17:56:36 +08:00
parent ec96d91548
commit 178434d63e
41 changed files with 6153 additions and 16 deletions

View File

@@ -63,6 +63,7 @@ impl Hooks for App {
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::admin::routes())
.add_route(controllers::admin_api::routes())
.add_route(controllers::review::routes())
.add_route(controllers::category::routes())
.add_route(controllers::friend_link::routes())

View File

@@ -19,6 +19,8 @@ use crate::services::{ai, content};
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
const DEFAULT_ADMIN_USERNAME: &str = "admin";
const DEFAULT_ADMIN_PASSWORD: &str = "admin123";
#[derive(Deserialize)]
pub struct LoginForm {
@@ -397,15 +399,35 @@ fn render_admin(
format::view(&view_engine.0, template, Value::Object(context))
}
pub(crate) fn admin_username() -> String {
std::env::var("TERMI_ADMIN_USERNAME").unwrap_or_else(|_| DEFAULT_ADMIN_USERNAME.to_string())
}
pub(crate) fn admin_password() -> String {
std::env::var("TERMI_ADMIN_PASSWORD").unwrap_or_else(|_| DEFAULT_ADMIN_PASSWORD.to_string())
}
pub(crate) fn is_admin_logged_in() -> bool {
ADMIN_LOGGED_IN.load(Ordering::SeqCst)
}
pub(crate) fn set_admin_logged_in(value: bool) {
ADMIN_LOGGED_IN.store(value, Ordering::SeqCst);
}
pub(crate) fn validate_admin_credentials(username: &str, password: &str) -> bool {
username == admin_username() && password == admin_password()
}
pub(crate) fn check_auth() -> Result<()> {
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
if !is_admin_logged_in() {
return Err(Error::Unauthorized("Not logged in".to_string()));
}
Ok(())
}
pub async fn root() -> Result<impl IntoResponse> {
if ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
if is_admin_logged_in() {
Ok(format::redirect("/admin"))
} else {
Ok(format::redirect("/admin/login"))
@@ -428,15 +450,15 @@ pub async fn login_page(
}
pub async fn login_submit(Form(form): Form<LoginForm>) -> Result<impl IntoResponse> {
if form.username == "admin" && form.password == "admin123" {
ADMIN_LOGGED_IN.store(true, Ordering::SeqCst);
if validate_admin_credentials(&form.username, &form.password) {
set_admin_logged_in(true);
return Ok(format::redirect("/admin"));
}
Ok(format::redirect("/admin/login?error=1"))
}
pub async fn logout() -> Result<impl IntoResponse> {
ADMIN_LOGGED_IN.store(false, Ordering::SeqCst);
set_admin_logged_in(false);
Ok(format::redirect("/admin/login"))
}

View File

@@ -0,0 +1,408 @@
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect,
};
use serde::{Deserialize, Serialize};
use crate::{
controllers::{
admin::{admin_username, check_auth, is_admin_logged_in, set_admin_logged_in, validate_admin_credentials},
site_settings::{self, SiteSettingsPayload},
},
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
services::{ai, content},
};
#[derive(Clone, Debug, Deserialize)]
pub struct AdminLoginPayload {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminSessionResponse {
pub authenticated: bool,
pub username: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardStats {
pub total_posts: u64,
pub total_comments: u64,
pub pending_comments: u64,
pub total_categories: u64,
pub total_tags: u64,
pub total_reviews: u64,
pub total_links: u64,
pub pending_links: u64,
pub ai_chunks: u64,
pub ai_enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardPostItem {
pub id: i32,
pub title: String,
pub slug: String,
pub category: String,
pub post_type: String,
pub pinned: bool,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardCommentItem {
pub id: i32,
pub author: String,
pub post_slug: String,
pub scope: String,
pub excerpt: String,
pub approved: bool,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardFriendLinkItem {
pub id: i32,
pub site_name: String,
pub site_url: String,
pub category: String,
pub status: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardReviewItem {
pub id: i32,
pub title: String,
pub review_type: String,
pub rating: i32,
pub status: String,
pub review_date: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardSiteSummary {
pub site_name: String,
pub site_url: String,
pub ai_enabled: bool,
pub ai_chunks: u64,
pub ai_last_indexed_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminDashboardResponse {
pub stats: DashboardStats,
pub site: DashboardSiteSummary,
pub recent_posts: Vec<DashboardPostItem>,
pub pending_comments: Vec<DashboardCommentItem>,
pub pending_friend_links: Vec<DashboardFriendLinkItem>,
pub recent_reviews: Vec<DashboardReviewItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminSiteSettingsResponse {
pub id: i32,
pub site_name: Option<String>,
pub site_short_name: Option<String>,
pub site_url: Option<String>,
pub site_title: Option<String>,
pub site_description: Option<String>,
pub hero_title: Option<String>,
pub hero_subtitle: Option<String>,
pub owner_name: Option<String>,
pub owner_title: Option<String>,
pub owner_bio: Option<String>,
pub owner_avatar_url: Option<String>,
pub social_github: Option<String>,
pub social_twitter: Option<String>,
pub social_email: Option<String>,
pub location: Option<String>,
pub tech_stack: Vec<String>,
pub ai_enabled: bool,
pub ai_provider: Option<String>,
pub ai_api_base: Option<String>,
pub ai_api_key: Option<String>,
pub ai_chat_model: Option<String>,
pub ai_embedding_model: Option<String>,
pub ai_system_prompt: Option<String>,
pub ai_top_k: Option<i32>,
pub ai_chunk_size: Option<i32>,
pub ai_last_indexed_at: Option<String>,
pub ai_chunks_count: u64,
pub ai_local_embedding: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
}
fn format_timestamp(
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
pattern: &str,
) -> Option<String> {
value.map(|item| item.format(pattern).to_string())
}
fn required_text(value: Option<&str>, fallback: &str) -> String {
value
.map(str::trim)
.filter(|item| !item.is_empty())
.unwrap_or(fallback)
.to_string()
}
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
value
.as_ref()
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| item.as_str().map(ToString::to_string))
.collect()
}
fn build_settings_response(
item: crate::models::_entities::site_settings::Model,
ai_chunks_count: u64,
) -> AdminSiteSettingsResponse {
AdminSiteSettingsResponse {
id: item.id,
site_name: item.site_name,
site_short_name: item.site_short_name,
site_url: item.site_url,
site_title: item.site_title,
site_description: item.site_description,
hero_title: item.hero_title,
hero_subtitle: item.hero_subtitle,
owner_name: item.owner_name,
owner_title: item.owner_title,
owner_bio: item.owner_bio,
owner_avatar_url: item.owner_avatar_url,
social_github: item.social_github,
social_twitter: item.social_twitter,
social_email: item.social_email,
location: item.location,
tech_stack: tech_stack_values(&item.tech_stack),
ai_enabled: item.ai_enabled.unwrap_or(false),
ai_provider: item.ai_provider,
ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key,
ai_chat_model: item.ai_chat_model,
ai_embedding_model: item.ai_embedding_model,
ai_system_prompt: item.ai_system_prompt,
ai_top_k: item.ai_top_k,
ai_chunk_size: item.ai_chunk_size,
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
ai_chunks_count,
ai_local_embedding: ai::local_embedding_label().to_string(),
}
}
#[debug_handler]
pub async fn session_status() -> Result<Response> {
format::json(AdminSessionResponse {
authenticated: is_admin_logged_in(),
username: is_admin_logged_in().then(admin_username),
})
}
#[debug_handler]
pub async fn session_login(Json(payload): Json<AdminLoginPayload>) -> Result<Response> {
if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) {
return unauthorized("Invalid credentials");
}
set_admin_logged_in(true);
format::json(AdminSessionResponse {
authenticated: true,
username: Some(admin_username()),
})
}
#[debug_handler]
pub async fn session_logout() -> Result<Response> {
set_admin_logged_in(false);
format::json(AdminSessionResponse {
authenticated: false,
username: None,
})
}
#[debug_handler]
pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
content::sync_markdown_posts(&ctx).await?;
let total_posts = posts::Entity::find().count(&ctx.db).await?;
let total_comments = comments::Entity::find().count(&ctx.db).await?;
let pending_comments = comments::Entity::find()
.filter(comments::Column::Approved.eq(false))
.count(&ctx.db)
.await?;
let total_categories = crate::models::_entities::categories::Entity::find()
.count(&ctx.db)
.await?;
let total_tags = crate::models::_entities::tags::Entity::find()
.count(&ctx.db)
.await?;
let total_reviews = reviews::Entity::find().count(&ctx.db).await?;
let total_links = friend_links::Entity::find().count(&ctx.db).await?;
let pending_links = friend_links::Entity::find()
.filter(friend_links::Column::Status.eq("pending"))
.count(&ctx.db)
.await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
let site_settings = site_settings::load_current(&ctx).await?;
let recent_posts = posts::Entity::find()
.order_by_desc(posts::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|post| DashboardPostItem {
id: post.id,
title: required_text(post.title.as_deref(), "Untitled post"),
slug: post.slug,
category: required_text(post.category.as_deref(), "Uncategorized"),
post_type: required_text(post.post_type.as_deref(), "article"),
pinned: post.pinned.unwrap_or(false),
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let pending_comment_rows = comments::Entity::find()
.filter(comments::Column::Approved.eq(false))
.order_by_desc(comments::Column::CreatedAt)
.limit(8)
.all(&ctx.db)
.await?
.into_iter()
.map(|comment| DashboardCommentItem {
id: comment.id,
author: required_text(comment.author.as_deref(), "Anonymous"),
post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"),
scope: required_text(Some(comment.scope.as_str()), "global"),
excerpt: required_text(comment.content.as_deref(), ""),
approved: comment.approved.unwrap_or(false),
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let pending_friend_links = friend_links::Entity::find()
.filter(friend_links::Column::Status.eq("pending"))
.order_by_desc(friend_links::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|link| DashboardFriendLinkItem {
id: link.id,
site_name: required_text(link.site_name.as_deref(), "Unnamed site"),
site_url: link.site_url,
category: required_text(link.category.as_deref(), "Other"),
status: required_text(link.status.as_deref(), "pending"),
created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let recent_reviews = reviews::Entity::find()
.order_by_desc(reviews::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|review| DashboardReviewItem {
id: review.id,
title: required_text(review.title.as_deref(), "Untitled review"),
review_type: required_text(review.review_type.as_deref(), "game"),
rating: review.rating.unwrap_or(0),
status: required_text(review.status.as_deref(), "completed"),
review_date: required_text(review.review_date.as_deref(), ""),
})
.collect::<Vec<_>>();
format::json(AdminDashboardResponse {
stats: DashboardStats {
total_posts,
total_comments,
pending_comments,
total_categories,
total_tags,
total_reviews,
total_links,
pending_links,
ai_chunks: ai_chunks_count,
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
},
site: DashboardSiteSummary {
site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"),
site_url: required_text(site_settings.site_url.as_deref(), ""),
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
ai_chunks: ai_chunks_count,
ai_last_indexed_at: format_timestamp(
site_settings.ai_last_indexed_at,
"%Y-%m-%d %H:%M:%S UTC",
),
},
recent_posts,
pending_comments: pending_comment_rows,
pending_friend_links,
recent_reviews,
})
}
#[debug_handler]
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let current = site_settings::load_current(&ctx).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
format::json(build_settings_response(current, ai_chunks_count))
}
#[debug_handler]
pub async fn update_site_settings(
State(ctx): State<AppContext>,
Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
check_auth()?;
let current = site_settings::load_current(&ctx).await?;
let mut item = current.into_active_model();
params.apply(&mut item);
let updated = item.update(&ctx.db).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
format::json(build_settings_response(updated, ai_chunks_count))
}
#[debug_handler]
pub async fn reindex_ai(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let summary = ai::rebuild_index(&ctx).await?;
format::json(AdminAiReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(summary.last_indexed_at.map(Into::into), "%Y-%m-%d %H:%M:%S UTC"),
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
.add("/session", get(session_status))
.add("/session", delete(session_logout))
.add("/session/login", post(session_login))
.add("/dashboard", get(dashboard))
.add("/site-settings", get(get_site_settings))
.add("/site-settings", patch(update_site_settings))
.add("/site-settings", put(update_site_settings))
.add("/ai/reindex", post(reindex_ai))
}

View File

@@ -1,4 +1,5 @@
pub mod admin;
pub mod admin_api;
pub mod ai;
pub mod auth;
pub mod category;

View File

@@ -104,7 +104,7 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
}
impl SiteSettingsPayload {
fn apply(self, item: &mut ActiveModel) {
pub(crate) fn apply(self, item: &mut ActiveModel) {
if let Some(site_name) = self.site_name {
item.site_name = Set(normalize_optional_string(Some(site_name)));
}