chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user