chore: checkpoint ai search comments and i18n foundation

This commit is contained in:
2026-03-28 17:17:31 +08:00
parent d18a709987
commit ec96d91548
71 changed files with 9494 additions and 423 deletions

View File

@@ -1,13 +1,21 @@
use axum::{extract::{Multipart, Query, State}, Form};
use axum::{
extract::{Multipart, Query, State},
Form,
};
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags};
use crate::services::content;
use crate::models::_entities::{
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags,
};
use crate::services::{ai, content};
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
@@ -23,6 +31,14 @@ pub struct LoginQuery {
error: Option<String>,
}
#[derive(Default, Deserialize)]
pub struct CommentAdminQuery {
scope: Option<String>,
approved: Option<String>,
post_slug: Option<String>,
q: Option<String>,
}
#[derive(Serialize)]
struct HeaderAction {
label: String,
@@ -89,12 +105,31 @@ struct CommentRow {
author: String,
post_slug: String,
content: String,
scope: String,
scope_label: String,
paragraph_excerpt: String,
paragraph_key: String,
reply_target: String,
approved: bool,
created_at: String,
frontend_url: Option<String>,
api_url: String,
}
#[derive(Serialize)]
struct CommentFilterState {
scope: String,
approved: String,
post_slug: String,
q: String,
}
#[derive(Serialize)]
struct CommentFilterStat {
label: String,
value: String,
}
#[derive(Serialize)]
struct TagRow {
id: i32,
@@ -174,13 +209,9 @@ fn url_encode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~' => encoded.push(*byte as char),
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(*byte as char)
}
b' ' => encoded.push_str("%20"),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
@@ -286,6 +317,60 @@ fn link_status_text(status: &str) -> &'static str {
}
}
fn comment_scope_label(scope: &str) -> &'static str {
match scope {
"paragraph" => "段落评论",
_ => "全文评论",
}
}
fn comment_frontend_url(comment: &comments::Model) -> Option<String> {
let slug = comment
.post_slug
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut url = frontend_path(&format!("/articles/{slug}"));
if comment.scope == "paragraph" {
if let Some(paragraph_key) = comment
.paragraph_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
url.push('#');
url.push_str("paragraph-");
url.push_str(paragraph_key);
}
}
Some(url)
}
fn normalized_filter_value(value: Option<&str>) -> String {
value.unwrap_or_default().trim().to_string()
}
fn comment_matches_query(comment: &comments::Model, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query = query.to_lowercase();
let fields = [
comment.author.as_deref().unwrap_or_default(),
comment.post_slug.as_deref().unwrap_or_default(),
comment.content.as_deref().unwrap_or_default(),
comment.paragraph_excerpt.as_deref().unwrap_or_default(),
comment.paragraph_key.as_deref().unwrap_or_default(),
];
fields
.iter()
.any(|value| value.to_lowercase().contains(&query))
}
fn page_context(title: &str, description: &str, active_nav: &str) -> Map<String, Value> {
let mut context = Map::new();
context.insert("page_title".into(), json!(title));
@@ -312,7 +397,7 @@ fn render_admin(
format::view(&view_engine.0, template, Value::Object(context))
}
fn check_auth() -> Result<()> {
pub(crate) fn check_auth() -> Result<()> {
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
return Err(Error::Unauthorized("Not logged in".to_string()));
}
@@ -470,16 +555,26 @@ pub async fn index(
];
let profile = SiteProfile {
site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"),
site_name: non_empty(
site.as_ref().and_then(|item| item.site_name.as_deref()),
"未配置站点",
),
site_description: non_empty(
site.as_ref()
.and_then(|item| item.site_description.as_deref()),
"站点简介尚未设置",
),
site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"),
site_url: non_empty(
site.as_ref().and_then(|item| item.site_url.as_deref()),
"未配置站点链接",
),
};
let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard");
let mut context = page_context(
"后台总览",
"前后台共用同一份数据,这里可以快速处理内容和跳转前台。",
"dashboard",
);
context.insert(
"header_actions".into(),
json!([
@@ -523,7 +618,11 @@ pub async fn posts_admin(
file_path: file_path_by_slug
.get(&post.slug)
.cloned()
.unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()),
.unwrap_or_else(|| {
content::markdown_post_path(&post.slug)
.to_string_lossy()
.to_string()
}),
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
category_name: category_name.clone(),
category_frontend_url: frontend_query_url("/articles", "category", &category_name),
@@ -535,7 +634,11 @@ pub async fn posts_admin(
})
.collect::<Vec<_>>();
let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts");
let mut context = page_context(
"文章管理",
"核对文章、分类和标签,并可直接跳到前台详情页。",
"posts",
);
context.insert(
"header_actions".into(),
json!([
@@ -596,7 +699,11 @@ pub async fn posts_import(
let mut files = Vec::new();
while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? {
while let Some(field) = multipart
.next_field()
.await
.map_err(|error| Error::BadRequest(error.to_string()))?
{
let file_name = field
.file_name()
.map(ToString::to_string)
@@ -642,9 +749,19 @@ pub async fn post_editor(
"header_actions".into(),
json!([
action("返回文章管理", "/admin/posts".to_string(), "ghost", false),
action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true),
action(
"前台预览",
frontend_path(&format!("/articles/{}", slug)),
"primary",
true
),
action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true),
action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true),
action(
"Markdown API",
format!("/api/posts/slug/{slug}/markdown"),
"ghost",
true
),
]),
);
context.insert(
@@ -662,6 +779,7 @@ pub async fn post_editor(
pub async fn comments_admin(
view_engine: ViewEngine<TeraView>,
Query(query): Query<CommentAdminQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth()?;
@@ -672,8 +790,62 @@ pub async fn comments_admin(
.all(&ctx.db)
.await?;
let scope_filter = normalized_filter_value(query.scope.as_deref());
let approved_filter = normalized_filter_value(query.approved.as_deref());
let post_slug_filter = normalized_filter_value(query.post_slug.as_deref());
let text_filter = normalized_filter_value(query.q.as_deref());
let total_count = items.len();
let article_count = items.iter().filter(|comment| comment.scope != "paragraph").count();
let paragraph_count = items.iter().filter(|comment| comment.scope == "paragraph").count();
let pending_count = items
.iter()
.filter(|comment| !comment.approved.unwrap_or(false))
.count();
let author_by_id = items
.iter()
.map(|comment| {
(
comment.id,
non_empty(comment.author.as_deref(), "匿名"),
)
})
.collect::<BTreeMap<_, _>>();
let post_options = items
.iter()
.filter_map(|comment| comment.post_slug.as_deref())
.map(str::trim)
.filter(|slug| !slug.is_empty())
.map(ToString::to_string)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let rows = items
.iter()
.filter(|comment| {
if !scope_filter.is_empty() && comment.scope != scope_filter {
return false;
}
if approved_filter == "true" && !comment.approved.unwrap_or(false) {
return false;
}
if approved_filter == "false" && comment.approved.unwrap_or(false) {
return false;
}
if !post_slug_filter.is_empty()
&& comment.post_slug.as_deref().unwrap_or_default().trim() != post_slug_filter
{
return false;
}
comment_matches_query(comment, &text_filter)
})
.map(|comment| {
let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章");
CommentRow {
@@ -681,19 +853,33 @@ pub async fn comments_admin(
author: non_empty(comment.author.as_deref(), "匿名"),
post_slug: post_slug.clone(),
content: non_empty(comment.content.as_deref(), "-"),
scope: comment.scope.clone(),
scope_label: comment_scope_label(&comment.scope).to_string(),
paragraph_excerpt: non_empty(comment.paragraph_excerpt.as_deref(), "-"),
paragraph_key: non_empty(comment.paragraph_key.as_deref(), "-"),
reply_target: comment
.reply_to_comment_id
.map(|reply_id| {
let author = author_by_id
.get(&reply_id)
.cloned()
.unwrap_or_else(|| "未知评论".to_string());
format!("#{reply_id} · {author}")
})
.unwrap_or_else(|| "-".to_string()),
approved: comment.approved.unwrap_or(false),
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
frontend_url: comment
.post_slug
.as_deref()
.filter(|slug| !slug.trim().is_empty())
.map(|slug| frontend_path(&format!("/articles/{slug}"))),
frontend_url: comment_frontend_url(comment),
api_url: format!("/api/comments/{}", comment.id),
}
})
.collect::<Vec<_>>();
let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments");
let mut context = page_context(
"评论审核",
"前台真实评论会先进入这里,审核通过后再展示到文章页。",
"comments",
);
context.insert(
"header_actions".into(),
json!([
@@ -701,6 +887,37 @@ pub async fn comments_admin(
action("评论 API", "/api/comments".to_string(), "ghost", true),
]),
);
context.insert(
"filters".into(),
json!(CommentFilterState {
scope: scope_filter,
approved: approved_filter,
post_slug: post_slug_filter,
q: text_filter,
}),
);
context.insert("post_options".into(), json!(post_options));
context.insert(
"stats".into(),
json!([
CommentFilterStat {
label: "全部评论".to_string(),
value: total_count.to_string(),
},
CommentFilterStat {
label: "全文评论".to_string(),
value: article_count.to_string(),
},
CommentFilterStat {
label: "段落评论".to_string(),
value: paragraph_count.to_string(),
},
CommentFilterStat {
label: "待审核".to_string(),
value: pending_count.to_string(),
},
]),
);
context.insert("rows".into(), json!(rows));
render_admin(&view_engine, "admin/comments.html", context)
@@ -742,7 +959,8 @@ pub async fn categories_admin(
.and_then(|post| post.title.as_deref())
.unwrap_or("最近文章")
.to_string(),
latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
latest_frontend_url: latest
.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
frontend_url: frontend_path("/categories"),
articles_url: frontend_query_url("/articles", "category", &name),
api_url: format!("/api/categories/{}", category.id),
@@ -758,7 +976,11 @@ pub async fn categories_admin(
.then_with(|| left.name.cmp(&right.name))
});
let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories");
let mut context = page_context(
"分类管理",
"维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。",
"categories",
);
context.insert(
"header_actions".into(),
json!([
@@ -896,7 +1118,11 @@ pub async fn tags_admin(
})
.collect::<Vec<_>>();
let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags");
let mut context = page_context(
"标签管理",
"维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。",
"tags",
);
context.insert(
"header_actions".into(),
json!([
@@ -1019,7 +1245,11 @@ pub async fn reviews_admin(
})
.collect::<Vec<_>>();
let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews");
let mut context = page_context(
"评价管理",
"创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。",
"reviews",
);
context.insert(
"header_actions".into(),
json!([
@@ -1058,7 +1288,9 @@ pub async fn reviews_create(
review_date: Set(Some(normalize_admin_text(&form.review_date))),
status: Set(Some(normalize_admin_text(&form.status))),
description: Set(Some(normalize_admin_text(&form.description))),
tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())),
tags: Set(Some(
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
)),
cover: Set(Some(normalize_admin_text(&form.cover))),
..Default::default()
}
@@ -1087,7 +1319,9 @@ pub async fn reviews_update(
model.review_date = Set(Some(normalize_admin_text(&form.review_date)));
model.status = Set(Some(normalize_admin_text(&form.status)));
model.description = Set(Some(normalize_admin_text(&form.description)));
model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default()));
model.tags = Set(Some(
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
));
model.cover = Set(Some(normalize_admin_text(&form.cover)));
let _ = model.update(&ctx.db).await?;
@@ -1134,7 +1368,11 @@ pub async fn friend_links_admin(
})
.collect::<Vec<_>>();
let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links");
let mut context = page_context(
"友链申请",
"处理前台友链申请状态,并跳转到前台友链页或目标站点。",
"friend_links",
);
context.insert(
"header_actions".into(),
json!([
@@ -1159,6 +1397,13 @@ fn tech_stack_text(item: &site_settings::Model) -> String {
.join("\n")
}
fn indexed_at_text(item: &site_settings::Model) -> String {
item.ai_last_indexed_at
.as_ref()
.map(|value| value.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "尚未建立索引".to_string())
}
pub async fn site_settings_admin(
view_engine: ViewEngine<TeraView>,
State(ctx): State<AppContext>,
@@ -1171,8 +1416,13 @@ pub async fn site_settings_admin(
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings");
let mut context = page_context(
"站点设置",
"修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。",
"site_settings",
);
context.insert(
"header_actions".into(),
json!([
@@ -1201,6 +1451,17 @@ pub async fn site_settings_admin(
"social_email": non_empty(item.social_email.as_deref(), ""),
"owner_bio": non_empty(item.owner_bio.as_deref(), ""),
"tech_stack": tech_stack_text(&item),
"ai_enabled": item.ai_enabled.unwrap_or(false),
"ai_provider": non_empty(item.ai_provider.as_deref(), &ai::provider_name(None)),
"ai_api_base": non_empty(item.ai_api_base.as_deref(), ai::default_api_base()),
"ai_api_key": non_empty(item.ai_api_key.as_deref(), ""),
"ai_chat_model": non_empty(item.ai_chat_model.as_deref(), ai::default_chat_model()),
"ai_local_embedding": ai::local_embedding_label(),
"ai_system_prompt": non_empty(item.ai_system_prompt.as_deref(), ""),
"ai_top_k": item.ai_top_k.unwrap_or(4),
"ai_chunk_size": item.ai_chunk_size.unwrap_or(1200),
"ai_last_indexed_at": indexed_at_text(&item),
"ai_chunks_count": ai_chunks_count,
}),
);
@@ -1217,7 +1478,10 @@ pub fn routes() -> Routes {
.add("/admin/posts/import", post(posts_import))
.add("/admin/posts/{slug}/edit", get(post_editor))
.add("/admin/comments", get(comments_admin))
.add("/admin/categories", get(categories_admin).post(categories_create))
.add(
"/admin/categories",
get(categories_admin).post(categories_create),
)
.add("/admin/categories/{id}/update", post(categories_update))
.add("/admin/categories/{id}/delete", post(categories_delete))
.add("/admin/tags", get(tags_admin).post(tags_create))