feat: add React UI and AI page sharing tools
Move HTTP page rendering to the React app, fix multi-tool-call message handling, add API docs lookup, web page lookup, and Markdown preview page sharing.
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct ApiDoc {
|
||||
pub id: &'static str,
|
||||
pub group: &'static str,
|
||||
pub method: &'static str,
|
||||
pub path: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub description: &'static str,
|
||||
pub auth: &'static str,
|
||||
pub query_params: &'static [&'static str],
|
||||
pub path_params: &'static [&'static str],
|
||||
pub body: Option<&'static str>,
|
||||
pub response: &'static str,
|
||||
pub notes: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiDocQuery {
|
||||
q: Option<String>,
|
||||
group: Option<String>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn routes<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/v1/api-docs", get(list_api_docs))
|
||||
.route("/v1/api-docs/{id}", get(get_api_doc))
|
||||
}
|
||||
|
||||
async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
|
||||
let q = normalized_optional(query.q);
|
||||
let group = normalized_optional(query.group);
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 100);
|
||||
|
||||
let mut docs = api_docs()
|
||||
.into_iter()
|
||||
.filter(|doc| {
|
||||
group
|
||||
.as_deref()
|
||||
.map_or(true, |group| doc.group.eq_ignore_ascii_case(group))
|
||||
})
|
||||
.filter(|doc| q.as_deref().map_or(true, |q| doc_matches(doc, q)))
|
||||
.collect::<Vec<_>>();
|
||||
let total = docs.len();
|
||||
docs.truncate(limit);
|
||||
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"docs": docs,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_api_doc(Path(id): Path<String>) -> Result<Json<Value>, ApiError> {
|
||||
let doc = api_docs()
|
||||
.into_iter()
|
||||
.find(|doc| doc.id == id.trim())
|
||||
.ok_or_else(|| api_not_found("API 文档不存在"))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"doc": doc,
|
||||
})))
|
||||
}
|
||||
|
||||
fn api_docs() -> Vec<ApiDoc> {
|
||||
vec![
|
||||
ApiDoc {
|
||||
id: "health.get",
|
||||
group: "system",
|
||||
method: "GET",
|
||||
path: "/v1/health",
|
||||
summary: "检查 HTTP 服务是否运行",
|
||||
description: "返回服务存活状态,适合健康检查和本地连通性验证。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"message":"service is running"}"#,
|
||||
notes: &["该接口只表示 HTTP 服务可访问,不代表数据库、邮件或外部模型均可用。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "updates.list",
|
||||
group: "updates",
|
||||
method: "GET",
|
||||
path: "/v1/updates",
|
||||
summary: "查询升级日志列表",
|
||||
description: "按发布时间和版本倒序返回 ias_upgrade_logs 中的升级记录。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"logs":[{"version":"0.2.14","released_at":"...","title":"...","description":"...","changes":{...}}]}"#,
|
||||
notes: &["升级记录由服务迁移写入,内容应与 CHANGELOG.md 保持一致。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "updates.get",
|
||||
group: "updates",
|
||||
method: "GET",
|
||||
path: "/v1/updates/{version}",
|
||||
summary: "查询单个版本的升级日志",
|
||||
description: "根据版本号返回对应升级记录。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &["version: 版本号,例如 0.2.14"],
|
||||
body: None,
|
||||
response: r#"{"success":true,"log":{"version":"0.2.14","released_at":"...","title":"...","description":"...","changes":{...}}}"#,
|
||||
notes: &["版本不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "api-docs.list",
|
||||
group: "api-docs",
|
||||
method: "GET",
|
||||
path: "/v1/api-docs",
|
||||
summary: "搜索或列出 HTTP API 文档",
|
||||
description: "查询随服务发布的结构化 API 文档,供控制台、调试器和 AI 工具使用。",
|
||||
auth: "无",
|
||||
query_params: &[
|
||||
"q: 可选关键词,会匹配 id、分组、路径、摘要、说明、参数、响应和备注",
|
||||
"group: 可选分组,例如 web、mail、context、updates、console",
|
||||
"limit: 可选返回数量,默认 50,范围 1..100",
|
||||
],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"total":12,"limit":50,"docs":[{"id":"web.pages.list","method":"GET","path":"/v1/web/pages",...}]}"#,
|
||||
notes: &["该接口只返回 API 文档,不返回 React 页面内容。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "api-docs.get",
|
||||
group: "api-docs",
|
||||
method: "GET",
|
||||
path: "/v1/api-docs/{id}",
|
||||
summary: "查询单个 HTTP API 文档",
|
||||
description: "根据 endpoint id 返回完整 API 文档。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &["id: API 文档 id,例如 web.pages.list"],
|
||||
body: None,
|
||||
response: r#"{"success":true,"doc":{"id":"web.pages.list","method":"GET","path":"/v1/web/pages",...}}"#,
|
||||
notes: &["id 不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "web.pages.list",
|
||||
group: "web",
|
||||
method: "GET",
|
||||
path: "/v1/web/pages",
|
||||
summary: "查询动态页面列表",
|
||||
description: "返回由 create_web_page 或邮件预览创建的动态页面记录,支持按类型、关键词和分页查询。",
|
||||
auth: "无",
|
||||
query_params: &[
|
||||
"page_type: 可选页面类型,例如 page、mail",
|
||||
"q: 可选关键词,匹配 title、summary、content、source_label、metadata",
|
||||
"page: 可选页码,默认 1",
|
||||
"per_page: 可选每页数量,默认 20,范围 1..100",
|
||||
],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"page":1,"per_page":20,"total":1,"pages":[{"id":1,"slug":"...","page_type":"page","title":"...","content":"..."}]}"#,
|
||||
notes: &[
|
||||
"该接口只返回页面数据;页面展示由 React 前端负责。",
|
||||
"用户可访问的页面路径通常为 /web/{page_type}/{slug}、/mail/{slug} 或 /md/{slug}。",
|
||||
],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "web.pages.create",
|
||||
group: "web",
|
||||
method: "POST",
|
||||
path: "/v1/web/pages",
|
||||
summary: "创建动态内容页面",
|
||||
description: "创建一条可由 React 前端展示的动态页面记录,适合把长文本或结构化内容转成可分享链接。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: Some(
|
||||
r#"{"page_type":"page","title":"标题","summary":"摘要","content":"正文","source_label":"来源","metadata":{}}"#,
|
||||
),
|
||||
response: r#"{"success":true,"id":1,"slug":"...","page_type":"page","title":"标题","summary":"摘要","path":"/web/page/...","url":"http://.../web/page/..."}"#,
|
||||
notes: &[
|
||||
"page_type 只能包含字母、数字、下划线或连字符。",
|
||||
"page_type=markdown 时公开路径为 /md/{slug},React 前端会按 Markdown 预览渲染。",
|
||||
"title 与 content 必填且不能为空。",
|
||||
],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "web.pages.get",
|
||||
group: "web",
|
||||
method: "GET",
|
||||
path: "/v1/web/pages/{page_type}/{slug}",
|
||||
summary: "读取单个动态页面",
|
||||
description: "按页面类型和 slug 返回动态页面数据,供 React 页面详情页加载。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &["page_type: 页面类型", "slug: 页面 slug"],
|
||||
body: None,
|
||||
response: r#"{"success":true,"page":{"id":1,"slug":"...","page_type":"page","title":"...","content":"..."}}"#,
|
||||
notes: &["页面不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "web.mail.create",
|
||||
group: "web",
|
||||
method: "POST",
|
||||
path: "/v1/web/mail",
|
||||
summary: "创建邮件预览页面",
|
||||
description: "把邮件信息写入动态页面表,生成 /mail/{slug} 预览链接。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: Some(
|
||||
r#"{"from":"发件人","subject":"主题","reminder":"提醒摘要","content":"邮件 HTML 或文本","received_at":"收到时间"}"#,
|
||||
),
|
||||
response: r#"{"success":true,"id":1,"slug":"...","page_type":"mail","title":"主题","path":"/mail/...","url":"http://.../mail/..."}"#,
|
||||
notes: &["邮件预览内容在 React 前端中以沙箱 iframe 展示。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "web.mail.get",
|
||||
group: "web",
|
||||
method: "GET",
|
||||
path: "/v1/web/mail/{slug}",
|
||||
summary: "读取邮件预览页面",
|
||||
description: "按 slug 返回 page_type=mail 的动态页面数据。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &["slug: 邮件预览页面 slug"],
|
||||
body: None,
|
||||
response: r#"{"success":true,"page":{"id":1,"slug":"...","page_type":"mail","title":"...","content":"..."}}"#,
|
||||
notes: &["页面不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "mail.messages.list",
|
||||
group: "mail",
|
||||
method: "GET",
|
||||
path: "/v1/mail/messages",
|
||||
summary: "查询邮件记录列表",
|
||||
description: "分页查询已拉取的邮件记录,也可通过 /v1/mail/search 使用相同参数查询。",
|
||||
auth: "无",
|
||||
query_params: &[
|
||||
"q: 可选关键词,匹配发件人、主题、摘要、正文预览等",
|
||||
"account_id: 可选邮件账号 id",
|
||||
"mailbox: 可选邮箱目录",
|
||||
"source_key: 可选邮件来源标识",
|
||||
"seen: 可选是否已读",
|
||||
"page: 可选页码",
|
||||
"per_page: 可选每页数量",
|
||||
"limit: 可选返回数量,兼容旧查询",
|
||||
],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"page":1,"per_page":20,"total":1,"messages":[{"id":1,"from_label":"...","subject":"...","summary":"..."}]}"#,
|
||||
notes: &["/v1/mail/search 是该接口的别名。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "mail.messages.get",
|
||||
group: "mail",
|
||||
method: "GET",
|
||||
path: "/v1/mail/messages/{id}",
|
||||
summary: "读取单封邮件记录",
|
||||
description: "返回单封邮件详情;如果邮件尚未标记已读,读取时会尝试标记为已读。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &["id: 邮件记录 id"],
|
||||
body: None,
|
||||
response: r#"{"success":true,"message":{"id":1,"from_label":"...","subject":"...","content":"...","seen":true}}"#,
|
||||
notes: &["邮件不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "console.tools.list",
|
||||
group: "console",
|
||||
method: "GET",
|
||||
path: "/v1/tools",
|
||||
summary: "查询可用工具列表",
|
||||
description: "返回内置 AI 工具和外部 capability 工具的名称、参数、来源和警告信息。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"total":1,"tools_path":"...","builtin_tools":[...],"capability_tools":[...],"warnings":[]}"#,
|
||||
notes: &["该接口用于控制台展示和调试工具能力。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "console.user-configs.list",
|
||||
group: "console",
|
||||
method: "GET",
|
||||
path: "/v1/user-configs",
|
||||
summary: "查询用户侧配置概览",
|
||||
description: "返回微信账号和邮件账号配置的非敏感概览,密码或 token 只返回是否已配置。",
|
||||
auth: "无",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"wechat_total":1,"mail_total":1,"wechat_accounts":[...],"mail_accounts":[...]}"#,
|
||||
notes: &["不会返回明文 token 或邮箱密码。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "context.memories.create",
|
||||
group: "context",
|
||||
method: "POST",
|
||||
path: "/v1/context/memories",
|
||||
summary: "写入长期记忆",
|
||||
description: "在指定 channel、account_id 和 from_user_id 作用域下保存长期记忆。",
|
||||
auth: "需要 x-ias-context-token 或 Authorization: Bearer,与 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY 匹配",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: Some(
|
||||
r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"content":"记忆内容","metadata":{}}"#,
|
||||
),
|
||||
response: r#"{"success":true,"memory":{"id":1,"scope_key":"wechat:...","content":"...","metadata":{},"created_at":"...","updated_at":"..."}}"#,
|
||||
notes: &["content 必填且不能为空。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "context.memories.search",
|
||||
group: "context",
|
||||
method: "POST",
|
||||
path: "/v1/context/memories/search",
|
||||
summary: "查询长期记忆",
|
||||
description: "在指定作用域下按关键词查询长期记忆;关键词为空时返回最近更新的记忆。",
|
||||
auth: "需要 x-ias-context-token 或 Authorization: Bearer",
|
||||
query_params: &[],
|
||||
path_params: &[],
|
||||
body: Some(
|
||||
r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"query":"关键词","limit":20}"#,
|
||||
),
|
||||
response: r#"{"success":true,"memories":[{"id":1,"content":"...","created_at":"...","updated_at":"..."}]}"#,
|
||||
notes: &["limit 默认 20,范围 1..50。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "context.memories.update",
|
||||
group: "context",
|
||||
method: "PATCH",
|
||||
path: "/v1/context/memories/{id}",
|
||||
summary: "更新长期记忆",
|
||||
description: "在当前作用域内更新指定长期记忆的内容和可选 metadata。",
|
||||
auth: "需要 x-ias-context-token 或 Authorization: Bearer",
|
||||
query_params: &[],
|
||||
path_params: &["id: 长期记忆 id"],
|
||||
body: Some(
|
||||
r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{},"content":"新内容","metadata":{}}"#,
|
||||
),
|
||||
response: r#"{"success":true,"memory":{"id":1,"content":"新内容","updated_at":"..."}}"#,
|
||||
notes: &["content 必填且不能为空;记忆不存在时返回 404。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "context.memories.delete",
|
||||
group: "context",
|
||||
method: "DELETE",
|
||||
path: "/v1/context/memories/{id}",
|
||||
summary: "删除长期记忆",
|
||||
description: "在当前作用域内软删除指定长期记忆。",
|
||||
auth: "需要 x-ias-context-token 或 Authorization: Bearer",
|
||||
query_params: &[],
|
||||
path_params: &["id: 长期记忆 id"],
|
||||
body: Some(
|
||||
r#"{"channel":"wechat","account_id":"...","from_user_id":"...","context":{}}"#,
|
||||
),
|
||||
response: r#"{"success":true,"deleted":true}"#,
|
||||
notes: &["找不到可删除记忆时 deleted 为 false。"],
|
||||
},
|
||||
ApiDoc {
|
||||
id: "context.simulator.get",
|
||||
group: "context",
|
||||
method: "GET",
|
||||
path: "/v1/context/simulator",
|
||||
summary: "查询上下文模拟器数据",
|
||||
description: "返回调试上下文所需的工具列表、默认消息、会话、消息、记忆和摘要数据。",
|
||||
auth: "无",
|
||||
query_params: &[
|
||||
"channel: 可选渠道,默认 wechat",
|
||||
"account_id: 可选账号 id",
|
||||
"from_user_id: 可选用户 id",
|
||||
],
|
||||
path_params: &[],
|
||||
body: None,
|
||||
response: r#"{"success":true,"has_query":true,"scope_key":"wechat:...","tools":[...],"default_messages":[...],"sessions":[...],"messages":[...],"memories":[...],"summaries":[...]}"#,
|
||||
notes: &["未提供 account_id/from_user_id 时只返回默认调试数据。"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn normalized_optional(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn doc_matches(doc: &ApiDoc, query: &str) -> bool {
|
||||
let query = query.to_ascii_lowercase();
|
||||
let fields = [
|
||||
doc.id,
|
||||
doc.group,
|
||||
doc.method,
|
||||
doc.path,
|
||||
doc.summary,
|
||||
doc.description,
|
||||
doc.auth,
|
||||
doc.body.unwrap_or_default(),
|
||||
doc.response,
|
||||
];
|
||||
|
||||
fields
|
||||
.iter()
|
||||
.any(|field| field.to_ascii_lowercase().contains(&query))
|
||||
|| doc
|
||||
.query_params
|
||||
.iter()
|
||||
.chain(doc.path_params.iter())
|
||||
.chain(doc.notes.iter())
|
||||
.any(|field| field.to_ascii_lowercase().contains(&query))
|
||||
}
|
||||
|
||||
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": message.into(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn docs_include_searchable_web_page_query() {
|
||||
let doc = api_docs()
|
||||
.into_iter()
|
||||
.find(|doc| doc.id == "web.pages.list")
|
||||
.expect("web pages list doc exists");
|
||||
|
||||
assert!(doc.query_params.iter().any(|param| param.starts_with("q:")));
|
||||
assert!(doc_matches(&doc, "metadata"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_include_self_documentation() {
|
||||
assert!(api_docs().iter().any(|doc| doc.id == "api-docs.list"));
|
||||
assert!(api_docs().iter().any(|doc| doc.id == "api-docs.get"));
|
||||
}
|
||||
}
|
||||
+34
-7
@@ -1,10 +1,12 @@
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::get;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{any, get};
|
||||
use axum::{Json, Router};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::api_docs::routes as api_doc_routes;
|
||||
use crate::updates::routes as update_routes;
|
||||
use crate::web::routes as web_routes;
|
||||
|
||||
@@ -19,12 +21,17 @@ pub async fn create_http(
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let spa_assets = ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html"));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/v1/health", get(health))
|
||||
.merge(api_doc_routes::<()>())
|
||||
.merge(update_routes::<()>(db.clone()))
|
||||
.merge(web_routes::routes::<()>(db))
|
||||
.merge(extra_routes)
|
||||
.route("/v1", any(api_not_found))
|
||||
.route("/v1/{*path}", any(api_not_found))
|
||||
.fallback_service(spa_assets)
|
||||
.layer(cors);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| {
|
||||
@@ -50,13 +57,33 @@ pub async fn create_http(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn home() -> Redirect {
|
||||
Redirect::permanent("/updates")
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "service is running"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn api_not_found() -> (StatusCode, Json<serde_json::Value>) {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"message": "接口不存在"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_catch_all_can_coexist_with_specific_v1_routes() {
|
||||
let _router: Router = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
.route("/v1/updates/{version}", get(api_not_found))
|
||||
.route("/v1", any(api_not_found))
|
||||
.route("/v1/{*path}", any(api_not_found));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
pub mod api_docs;
|
||||
pub mod app;
|
||||
pub mod shell;
|
||||
pub mod updates;
|
||||
pub mod web;
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/// 公共 HTML shell:Tailwind CSS CDN
|
||||
pub fn html_shell(title: &str, body: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif; }}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen text-gray-900">
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-gray-200">
|
||||
<div class="max-w-5xl mx-auto px-5 h-[52px] flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-gray-900 no-underline tracking-tight">iAs</a>
|
||||
<nav class="flex gap-1">
|
||||
<a href="/updates" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">更新日志</a>
|
||||
<a href="/mail" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">邮件</a>
|
||||
<a href="/tools" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">工具</a>
|
||||
<a href="/configs" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">配置</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-5xl mx-auto px-5 py-6 pb-16">
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
body = body
|
||||
)
|
||||
}
|
||||
|
||||
pub fn escape_html(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
+42
-118
@@ -1,23 +1,21 @@
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Html;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use axum::{Extension, Json, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
use crate::shell::{escape_html, html_shell};
|
||||
|
||||
type HtmlError = (StatusCode, Html<String>);
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct UpdateRouteState {
|
||||
db: PgPool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
struct UpgradeLog {
|
||||
version: String,
|
||||
released_at: DateTime<Utc>,
|
||||
@@ -26,36 +24,50 @@ struct UpgradeLog {
|
||||
changes: Value,
|
||||
}
|
||||
|
||||
// ── 路由 ────────────────────────────────────────────────
|
||||
|
||||
pub fn routes<S>(db: PgPool) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/updates", get(show_updates))
|
||||
.route("/updates/{version}", get(show_update))
|
||||
.route("/v1/updates", get(list_updates_api))
|
||||
.route("/v1/updates/{version}", get(get_update_api))
|
||||
.layer(Extension(UpdateRouteState { db }))
|
||||
}
|
||||
|
||||
async fn show_updates(
|
||||
// ── JSON API ─────────────────────────────────────────────
|
||||
|
||||
async fn list_updates_api(
|
||||
Extension(state): Extension<UpdateRouteState>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let logs = list_upgrade_logs(&state.db)
|
||||
.await
|
||||
.map_err(html_internal_error)?;
|
||||
Ok(Html(render_updates_page(&logs)))
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"logs": logs,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn show_update(
|
||||
async fn get_update_api(
|
||||
Extension(state): Extension<UpdateRouteState>,
|
||||
Path(version): Path<String>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let log = find_upgrade_log(&state.db, &version)
|
||||
.await
|
||||
.map_err(html_internal_error)?
|
||||
.ok_or_else(html_not_found)?;
|
||||
Ok(Html(render_update_page(&log)))
|
||||
.map_err(api_internal_error)?
|
||||
.ok_or_else(|| api_not_found("更新日志不存在"))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"log": log,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── 数据库查询 ──────────────────────────────────────────
|
||||
|
||||
async fn list_upgrade_logs(pool: &PgPool) -> anyhow::Result<Vec<UpgradeLog>> {
|
||||
let rows = sqlx::query_as::<_, UpgradeLog>(
|
||||
r#"
|
||||
@@ -83,113 +95,25 @@ async fn find_upgrade_log(pool: &PgPool, version: &str) -> anyhow::Result<Option
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── 列表页 ──────────────────────────────────────────────
|
||||
// ── 辅助 ────────────────────────────────────────────────
|
||||
|
||||
fn render_updates_page(logs: &[UpgradeLog]) -> String {
|
||||
let items: String = logs
|
||||
.iter()
|
||||
.map(|log| {
|
||||
format!(
|
||||
r#"<a href="/updates/{version}" class="block bg-white rounded-lg border border-gray-200 p-5 hover:border-blue-400 hover:shadow-md transition">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700">v{version}</span>
|
||||
<span class="text-xs text-gray-400">{date}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{title}</h3>
|
||||
<p class="text-sm text-gray-500">{description}</p>
|
||||
</a>"#,
|
||||
version = escape_html(&log.version),
|
||||
date = escape_html(&format_date(log.released_at)),
|
||||
title = escape_html(&log.title),
|
||||
description = escape_html(&log.description),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = format!(
|
||||
r#"<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight">更新日志</h1>
|
||||
<p class="mt-1.5 text-gray-500">服务版本更新记录</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
{items}
|
||||
</div>"#,
|
||||
items = items,
|
||||
);
|
||||
|
||||
html_shell("更新日志", &body)
|
||||
}
|
||||
|
||||
// ── 详情页 ──────────────────────────────────────────────
|
||||
|
||||
fn render_update_page(log: &UpgradeLog) -> String {
|
||||
let changes = render_changes(&log.changes);
|
||||
|
||||
let body = format!(
|
||||
r#"<a href="/updates" class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6 transition">← 返回更新列表</a>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 mb-3">更新日志</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight">v{version} {title}</h1>
|
||||
<p class="mt-1.5 text-gray-500">{description}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 mb-6 text-sm text-gray-400">
|
||||
<span>版本:v{version}</span>
|
||||
<span>发布时间:{date}</span>
|
||||
</div>
|
||||
|
||||
<section class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-3">变更内容</h2>
|
||||
{changes}
|
||||
</section>"#,
|
||||
version = escape_html(&log.version),
|
||||
title = escape_html(&log.title),
|
||||
description = escape_html(&log.description),
|
||||
date = escape_html(&format_date(log.released_at)),
|
||||
changes = changes,
|
||||
);
|
||||
|
||||
html_shell(&format!("v{} {}", log.version, log.title), &body)
|
||||
}
|
||||
|
||||
fn render_changes(value: &Value) -> String {
|
||||
let Some(items) = value.as_array() else {
|
||||
return format!(
|
||||
r#"<pre class="text-sm text-gray-700 whitespace-pre-wrap">{}</pre>"#,
|
||||
escape_html(&value.to_string())
|
||||
);
|
||||
};
|
||||
|
||||
let items: String = items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|item| format!(r#"<li class="ml-4 list-disc">{}</li>"#, escape_html(item)))
|
||||
.collect();
|
||||
|
||||
format!(r#"<ul class="space-y-1.5">{items}</ul>"#)
|
||||
}
|
||||
|
||||
fn format_date(value: DateTime<Utc>) -> String {
|
||||
value.format("%Y-%m-%d %H:%M UTC").to_string()
|
||||
}
|
||||
|
||||
fn html_not_found() -> HtmlError {
|
||||
let body = r#"<h1 class="text-2xl font-bold">更新日志不存在</h1>
|
||||
<p class="mt-2 text-gray-500">没有找到对应版本的更新日志。</p>
|
||||
<p class="mt-4"><a href="/updates" class="text-blue-600 hover:text-blue-800">查看全部更新</a></p>"#;
|
||||
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(html_shell("更新日志不存在", body)),
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": message.into(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取更新日志失败: {error}");
|
||||
let body = r#"<h1 class="text-2xl font-bold">更新日志暂时不可用</h1>
|
||||
<p class="mt-2 text-gray-500">读取更新日志失败,请稍后重试。</p>"#;
|
||||
fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
eprintln!("更新日志接口失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(html_shell("更新日志不可用", body)),
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": "更新日志接口失败",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
pub mod utils;
|
||||
|
||||
pub use model::{NewWebPage, WebPage};
|
||||
pub use repository::WebPageRepository;
|
||||
pub use routes::{generate_slug, internal_url_for_path, public_path, public_url_for_path};
|
||||
pub use utils::{generate_slug, internal_url_for_path, public_path, public_url_for_path};
|
||||
|
||||
@@ -57,22 +57,85 @@ impl WebPageRepository {
|
||||
pub async fn list_pages(
|
||||
pool: &PgPool,
|
||||
page_type: Option<&str>,
|
||||
query: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> anyhow::Result<(Vec<WebPage>, i64)> {
|
||||
let total: i64 = if let Some(pt) = page_type {
|
||||
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages WHERE page_type = $1"#)
|
||||
let query_pattern = query.map(|value| format!("%{}%", value));
|
||||
let total: i64 = match (page_type, query_pattern.as_deref()) {
|
||||
(Some(pt), Some(q)) => {
|
||||
sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM ias_web_pages
|
||||
WHERE page_type = $1
|
||||
AND (
|
||||
title ILIKE $2
|
||||
OR COALESCE(summary, '') ILIKE $2
|
||||
OR content ILIKE $2
|
||||
OR COALESCE(source_label, '') ILIKE $2
|
||||
OR metadata ILIKE $2
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(pt)
|
||||
.bind(q)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages"#)
|
||||
}
|
||||
(Some(pt), None) => {
|
||||
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages WHERE page_type = $1"#)
|
||||
.bind(pt)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
}
|
||||
(None, Some(q)) => {
|
||||
sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM ias_web_pages
|
||||
WHERE title ILIKE $1
|
||||
OR COALESCE(summary, '') ILIKE $1
|
||||
OR content ILIKE $1
|
||||
OR COALESCE(source_label, '') ILIKE $1
|
||||
OR metadata ILIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(q)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
}
|
||||
(None, None) => {
|
||||
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages"#)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let rows = if let Some(pt) = page_type {
|
||||
sqlx::query_as::<_, WebPage>(
|
||||
let rows = match (page_type, query_pattern.as_deref()) {
|
||||
(Some(pt), Some(q)) => sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
WHERE page_type = $1
|
||||
AND (
|
||||
title ILIKE $2
|
||||
OR COALESCE(summary, '') ILIKE $2
|
||||
OR content ILIKE $2
|
||||
OR COALESCE(source_label, '') ILIKE $2
|
||||
OR metadata ILIKE $2
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
)
|
||||
.bind(pt)
|
||||
.bind(q)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?,
|
||||
(Some(pt), None) => sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
@@ -85,9 +148,26 @@ impl WebPageRepository {
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, WebPage>(
|
||||
.await?,
|
||||
(None, Some(q)) => sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
WHERE title ILIKE $1
|
||||
OR COALESCE(summary, '') ILIKE $1
|
||||
OR content ILIKE $1
|
||||
OR COALESCE(source_label, '') ILIKE $1
|
||||
OR metadata ILIKE $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(q)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?,
|
||||
(None, None) => sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
@@ -98,7 +178,7 @@ impl WebPageRepository {
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.await?,
|
||||
};
|
||||
|
||||
Ok((rows, total))
|
||||
|
||||
+20
-277
@@ -1,21 +1,18 @@
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Html;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::shell::{escape_html, html_shell};
|
||||
use crate::web::model::{
|
||||
CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage, WebPage,
|
||||
CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage,
|
||||
};
|
||||
use crate::web::repository::WebPageRepository;
|
||||
use crate::web::utils;
|
||||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
type HtmlError = (StatusCode, Html<String>);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebRouteState {
|
||||
@@ -31,176 +28,25 @@ impl WebRouteState {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PageListQuery {
|
||||
pub page_type: Option<String>,
|
||||
pub q: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
}
|
||||
|
||||
// ── 路由 ────────────────────────────────────────────────
|
||||
|
||||
pub fn routes<S>(db: PgPool) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
// JSON API(内部调用用)
|
||||
.route("/v1/web/pages", get(list_pages).post(create_page))
|
||||
.route("/v1/web/mail", post(create_mail_page))
|
||||
.route("/v1/web/pages/{page_type}/{slug}", get(get_page))
|
||||
.route("/v1/web/mail/{slug}", get(get_mail_page))
|
||||
// HTML 页面(用户浏览用)
|
||||
.route("/web/{page_type}/{slug}", get(show_typed_page))
|
||||
.route("/mail/{slug}", get(show_mail_page))
|
||||
.layer(Extension(WebRouteState::new(db)))
|
||||
}
|
||||
|
||||
// ── HTML 页面 ────────────────────────────────────────────
|
||||
|
||||
async fn show_mail_page(
|
||||
Extension(state): Extension<WebRouteState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
|
||||
.await
|
||||
.map_err(html_internal_error)?
|
||||
.ok_or_else(html_not_found)?;
|
||||
|
||||
Ok(Html(render_page_html(&page)))
|
||||
}
|
||||
|
||||
async fn show_typed_page(
|
||||
Extension(state): Extension<WebRouteState>,
|
||||
Path((page_type, slug)): Path<(String, String)>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
let page_type = normalize_key(&page_type, "page_type").map_err(|_| html_not_found())?;
|
||||
let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug)
|
||||
.await
|
||||
.map_err(html_internal_error)?
|
||||
.ok_or_else(html_not_found)?;
|
||||
|
||||
Ok(Html(render_page_html(&page)))
|
||||
}
|
||||
|
||||
fn render_page_html(page: &WebPage) -> String {
|
||||
let summary = page
|
||||
.summary
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
format!(
|
||||
r#"<p class="text-gray-500 text-base mt-1.5">{}</p>"#,
|
||||
escape_html(s)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let source = page
|
||||
.source_label
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
format!(
|
||||
r#"<span class="text-gray-400 text-sm">来源:{}</span>"#,
|
||||
escape_html(s)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let created = page
|
||||
.created_at
|
||||
.map(|t| {
|
||||
format!(
|
||||
"<span class=\"text-gray-400 text-sm\">创建时间:{}</span>",
|
||||
t.format("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let metadata_html = render_metadata_html(&page.metadata);
|
||||
let content_html = render_text_content(&page.content);
|
||||
|
||||
let body = format!(
|
||||
r#"<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 mb-3">{page_type}</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{summary}
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-1 mt-3 mb-6">{source}{created}</div>
|
||||
{metadata_html}
|
||||
<article class="bg-white rounded-lg border border-gray-200 p-6 text-base leading-relaxed whitespace-normal break-words">{content_html}</article>"#,
|
||||
page_type = escape_html(&page.page_type),
|
||||
title = escape_html(&page.title),
|
||||
summary = summary,
|
||||
source = source,
|
||||
created = created,
|
||||
metadata_html = metadata_html,
|
||||
content_html = content_html,
|
||||
);
|
||||
|
||||
html_shell(&page.title, &body)
|
||||
}
|
||||
|
||||
fn render_metadata_html(metadata: &str) -> String {
|
||||
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let items: String = values
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let val = metadata_value_to_text(value)?;
|
||||
Some(format!(
|
||||
r#"<dt class="text-gray-400 text-sm">{}</dt><dd class="text-gray-700 text-sm break-all">{}</dd>"#,
|
||||
escape_html(metadata_label(&key)),
|
||||
escape_html(&val),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<section class="bg-white rounded-lg border border-gray-200 mb-5">
|
||||
<dl class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 p-4">{items}</dl>
|
||||
</section>"#
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_label(key: &str) -> &str {
|
||||
match key {
|
||||
"from" => "发件人",
|
||||
"subject" => "主题",
|
||||
"message_id" => "邮件 ID",
|
||||
"mailbox" => "邮箱目录",
|
||||
"uid" => "UID",
|
||||
"reminder" => "提醒",
|
||||
"received_at" => "收件时间",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_value_to_text(value: Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => {
|
||||
let s = s.trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
Value::Array(v) if v.is_empty() => None,
|
||||
Value::Object(v) if v.is_empty() => None,
|
||||
other => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_content(content: &str) -> String {
|
||||
content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.trim().is_empty() {
|
||||
"<br>".to_string()
|
||||
} else {
|
||||
escape_html(line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("<br>\n")
|
||||
}
|
||||
|
||||
// ── JSON API ─────────────────────────────────────────────
|
||||
|
||||
async fn create_page(
|
||||
@@ -214,7 +60,7 @@ async fn create_page(
|
||||
let metadata = request.metadata.unwrap_or_else(|| json!({})).to_string();
|
||||
|
||||
let page = NewWebPage {
|
||||
slug: generate_slug(),
|
||||
slug: utils::generate_slug(),
|
||||
page_type,
|
||||
title,
|
||||
summary: optional_text(request.summary),
|
||||
@@ -227,7 +73,7 @@ async fn create_page(
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(response_for_page(&page, &headers)))
|
||||
Ok(Json(utils::response_for_page(&page, &headers)))
|
||||
}
|
||||
|
||||
async fn create_mail_page(
|
||||
@@ -253,7 +99,7 @@ async fn create_mail_page(
|
||||
.to_string();
|
||||
|
||||
let page = NewWebPage {
|
||||
slug: generate_slug(),
|
||||
slug: utils::generate_slug(),
|
||||
page_type: "mail".to_string(),
|
||||
title: subject,
|
||||
summary: Some(summary),
|
||||
@@ -266,7 +112,7 @@ async fn create_mail_page(
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(response_for_page(&page, &headers)))
|
||||
Ok(Json(utils::response_for_page(&page, &headers)))
|
||||
}
|
||||
|
||||
async fn list_pages(
|
||||
@@ -277,14 +123,20 @@ async fn list_pages(
|
||||
.page_type
|
||||
.map(|v| normalize_key(&v, "page_type"))
|
||||
.transpose()?;
|
||||
let q = optional_text(query.q);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let (pages, total) =
|
||||
WebPageRepository::list_pages(&state.db, page_type.as_deref(), per_page, offset)
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
let (pages, total) = WebPageRepository::list_pages(
|
||||
&state.db,
|
||||
page_type.as_deref(),
|
||||
q.as_deref(),
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
@@ -329,82 +181,8 @@ async fn get_mail_page(
|
||||
|
||||
// ── 辅助函数 ─────────────────────────────────────────────
|
||||
|
||||
fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse {
|
||||
let path = public_path(page);
|
||||
let url = format!("{}{}", request_base_url(headers), path);
|
||||
|
||||
CreateWebPageResponse {
|
||||
success: true,
|
||||
id: page.id,
|
||||
slug: page.slug.clone(),
|
||||
page_type: page.page_type.clone(),
|
||||
title: page.title.clone(),
|
||||
summary: page.summary.clone(),
|
||||
path,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_path(page: &WebPage) -> String {
|
||||
if page.page_type == "mail" {
|
||||
format!("/mail/{}", page.slug)
|
||||
} else {
|
||||
format!("/web/{}/{}", page.page_type, page.slug)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_url_for_path(path: &str) -> String {
|
||||
format!("{}{}", configured_base_url(), path)
|
||||
}
|
||||
|
||||
pub fn internal_url_for_path(path: &str) -> String {
|
||||
format!("{}{}", configured_internal_url(), path)
|
||||
}
|
||||
|
||||
fn request_base_url(headers: &HeaderMap) -> String {
|
||||
if let Some(base_url) = configured_base_url_from_env() {
|
||||
return base_url;
|
||||
}
|
||||
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("127.0.0.1:9003");
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("http");
|
||||
|
||||
format!("{}://{}", proto.trim(), host.trim()).replace("0.0.0.0", "127.0.0.1")
|
||||
}
|
||||
|
||||
fn configured_base_url() -> String {
|
||||
configured_base_url_from_env().unwrap_or_else(|| "http://127.0.0.1:9003".to_string())
|
||||
}
|
||||
|
||||
fn configured_internal_url() -> String {
|
||||
std::env::var("IA_WEB_INTERNAL_URL")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
|
||||
.unwrap_or_else(|| configured_base_url())
|
||||
}
|
||||
|
||||
fn configured_base_url_from_env() -> Option<String> {
|
||||
std::env::var("IA_WEB_BASE_URL")
|
||||
.or_else(|_| std::env::var("WEB_BASE_URL"))
|
||||
.or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}")))
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
|
||||
}
|
||||
|
||||
pub fn generate_slug() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
utils::generate_slug()
|
||||
}
|
||||
|
||||
fn required_text(value: &str, field: &str) -> Result<String, ApiError> {
|
||||
@@ -467,38 +245,3 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_not_found() -> HtmlError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(html_shell(
|
||||
"页面不存在",
|
||||
r#"<h1 class="text-2xl font-bold">页面不存在</h1><p class="mt-2 text-gray-500">没有找到对应的内容页面</p>"#,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取 web 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(html_shell(
|
||||
"页面暂时不可用",
|
||||
r#"<h1 class="text-2xl font-bold">页面暂时不可用</h1><p class="mt-2 text-gray-500">读取页面内容失败,请稍后重试</p>"#,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn html_not_found_returns_html_response() {
|
||||
let (status, Html(body)) = html_not_found();
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert!(body.contains("<!doctype html>"));
|
||||
assert!(body.contains("页面不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use axum::http::HeaderMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::web::model::{CreateWebPageResponse, WebPage};
|
||||
|
||||
/// 生成公开访问路径
|
||||
pub fn public_path(page: &WebPage) -> String {
|
||||
if page.page_type == "mail" {
|
||||
format!("/mail/{}", page.slug)
|
||||
} else if page.page_type == "markdown" || page.page_type == "md" {
|
||||
format!("/md/{}", page.slug)
|
||||
} else {
|
||||
format!("/web/{}/{}", page.page_type, page.slug)
|
||||
}
|
||||
}
|
||||
|
||||
/// 拼接公开 URL
|
||||
pub fn public_url_for_path(path: &str) -> String {
|
||||
format!("{}{}", configured_base_url(), path)
|
||||
}
|
||||
|
||||
/// 拼接内网 URL
|
||||
pub fn internal_url_for_path(path: &str) -> String {
|
||||
format!("{}{}", configured_internal_url(), path)
|
||||
}
|
||||
|
||||
/// 从请求头构建响应
|
||||
pub fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse {
|
||||
let path = public_path(page);
|
||||
let url = format!("{}{}", request_base_url(headers), path);
|
||||
|
||||
CreateWebPageResponse {
|
||||
success: true,
|
||||
id: page.id,
|
||||
slug: page.slug.clone(),
|
||||
page_type: page.page_type.clone(),
|
||||
title: page.title.clone(),
|
||||
summary: page.summary.clone(),
|
||||
path,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 slug
|
||||
pub fn generate_slug() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
fn request_base_url(headers: &HeaderMap) -> String {
|
||||
if let Some(base_url) = configured_base_url_from_env() {
|
||||
return base_url;
|
||||
}
|
||||
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("127.0.0.1:9003");
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("http");
|
||||
|
||||
format!("{}://{}", proto.trim(), host.trim()).replace("0.0.0.0", "127.0.0.1")
|
||||
}
|
||||
|
||||
fn configured_base_url() -> String {
|
||||
configured_base_url_from_env().unwrap_or_else(|| "http://127.0.0.1:9003".to_string())
|
||||
}
|
||||
|
||||
fn configured_internal_url() -> String {
|
||||
std::env::var("IA_WEB_INTERNAL_URL")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
|
||||
.unwrap_or_else(|| configured_base_url())
|
||||
}
|
||||
|
||||
fn configured_base_url_from_env() -> Option<String> {
|
||||
std::env::var("IA_WEB_BASE_URL")
|
||||
.or_else(|_| std::env::var("WEB_BASE_URL"))
|
||||
.or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}")))
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
|
||||
}
|
||||
Reference in New Issue
Block a user