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:
2026-07-07 22:27:14 +08:00
parent 3d77733e11
commit 640ba9e7d2
65 changed files with 8020 additions and 1732 deletions
+452
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
pub mod api_docs;
pub mod app;
pub mod shell;
pub mod updates;
pub mod web;
-50
View File
@@ -1,50 +0,0 @@
/// 公共 HTML shellTailwind 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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
+42 -118
View File
@@ -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": "更新日志接口失败",
})),
)
}
+2 -1
View File
@@ -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};
+90 -10
View File
@@ -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
View File
@@ -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("页面不存在"));
}
}
+89
View File
@@ -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"))
}