feat: 邮件已读/未读状态持久化与 Web 页面增强 (0.2.8)
- 邮件列表/详情页显示已读/未读状态,支持按状态筛选 - 邮件 API GET /v1/mail/messages 支持 seen 过滤 - 数据库 ias_mail_messages 新增 seen 字段 - 新增邮件查询 YAML 工具 query_mail_messages - 新增 /tools 和 /configs Web 页面 - 新增 ias-http shell 和 ias-service console 模块 - 补充历史版本升级日志迁移 SQL
This commit is contained in:
+15
-1
@@ -1,7 +1,9 @@
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::updates::routes as update_routes;
|
||||
use crate::web::routes as web_routes;
|
||||
@@ -12,11 +14,19 @@ pub async fn create_http(
|
||||
db: PgPool,
|
||||
extra_routes: Router,
|
||||
) -> anyhow::Result<()> {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/v1/health", get(health))
|
||||
.merge(update_routes::<()>(db.clone()))
|
||||
.merge(web_routes::routes::<()>(db))
|
||||
.merge(extra_routes);
|
||||
.merge(extra_routes)
|
||||
.layer(cors);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::AddrInUse {
|
||||
anyhow::anyhow!(
|
||||
@@ -40,6 +50,10 @@ 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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod shell;
|
||||
pub mod updates;
|
||||
pub mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 公共 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
|
||||
}
|
||||
+63
-169
@@ -8,6 +8,8 @@ use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
use crate::shell::{escape_html, html_shell};
|
||||
|
||||
type HtmlError = (StatusCode, Html<String>);
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -81,221 +83,113 @@ async fn find_upgrade_log(pool: &PgPool, version: &str) -> anyhow::Result<Option
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── 列表页 ──────────────────────────────────────────────
|
||||
|
||||
fn render_updates_page(logs: &[UpgradeLog]) -> String {
|
||||
let items = logs
|
||||
let items: String = logs
|
||||
.iter()
|
||||
.map(|log| {
|
||||
format!(
|
||||
r#"<li><a href="/updates/{version}">v{version}</a><span>{date}</span><strong>{title}</strong><p>{description}</p></li>"#,
|
||||
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::<Vec<_>>()
|
||||
.join("");
|
||||
.collect();
|
||||
|
||||
render_shell(
|
||||
"更新日志",
|
||||
r#"<p class="summary">服务版本更新记录。</p>"#,
|
||||
&format!(r#"<ol class="updates">{items}</ol>"#),
|
||||
)
|
||||
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#"<p class="summary">{description}</p>
|
||||
<section class="meta">
|
||||
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>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>变更内容</h2>
|
||||
</div>
|
||||
|
||||
<section class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-3">变更内容</h2>
|
||||
{changes}
|
||||
</section>
|
||||
<p class="back"><a href="/updates">查看全部更新</a></p>"#,
|
||||
description = escape_html(&log.description),
|
||||
</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,
|
||||
);
|
||||
render_shell(&format!("v{} {}", log.version, log.title), "", &body)
|
||||
|
||||
html_shell(&format!("v{} {}", log.version, log.title), &body)
|
||||
}
|
||||
|
||||
fn render_changes(value: &Value) -> String {
|
||||
let Some(items) = value.as_array() else {
|
||||
return format!("<pre>{}</pre>", escape_html(&value.to_string()));
|
||||
return format!(
|
||||
r#"<pre class="text-sm text-gray-700 whitespace-pre-wrap">{}</pre>"#,
|
||||
escape_html(&value.to_string())
|
||||
);
|
||||
};
|
||||
|
||||
let items = items
|
||||
let items: String = items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|item| format!("<li>{}</li>", escape_html(item)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!("<ul>{items}</ul>")
|
||||
}
|
||||
.map(|item| format!(r#"<li class="ml-4 list-disc">{}</li>"#, escape_html(item)))
|
||||
.collect();
|
||||
|
||||
fn render_shell(title: &str, intro: &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>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1f2328;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
}}
|
||||
main {{
|
||||
width: min(880px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 56px;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0 0 12px;
|
||||
font-size: 34px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
h2 {{
|
||||
margin: 0 0 14px;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
.summary {{
|
||||
margin: 0 0 22px;
|
||||
color: #57606a;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
margin: 0 0 24px;
|
||||
color: #57606a;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.panel {{
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 22px 24px;
|
||||
}}
|
||||
ul {{
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
line-height: 1.8;
|
||||
}}
|
||||
.updates {{
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}}
|
||||
.updates li {{
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 18px 20px;
|
||||
}}
|
||||
.updates a {{
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
color: #0969da;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.updates span {{
|
||||
color: #6e7781;
|
||||
font-size: 13px;
|
||||
}}
|
||||
.updates strong {{
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}}
|
||||
.updates p {{
|
||||
margin: 8px 0 0;
|
||||
color: #57606a;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
.back {{
|
||||
margin: 18px 0 0;
|
||||
}}
|
||||
.back a {{
|
||||
color: #0969da;
|
||||
text-decoration: none;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{title}</h1>
|
||||
{intro}
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
intro = intro,
|
||||
body = body,
|
||||
)
|
||||
format!(r#"<ul class="space-y-1.5">{items}</ul>"#)
|
||||
}
|
||||
|
||||
fn format_date(value: DateTime<Utc>) -> String {
|
||||
value.format("%Y-%m-%d %H:%M:%S UTC").to_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>"#;
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(render_shell(
|
||||
"更新日志不存在",
|
||||
r#"<p class="summary">没有找到对应版本的更新日志。</p>"#,
|
||||
r#"<p class="back"><a href="/updates">查看全部更新</a></p>"#,
|
||||
)),
|
||||
Html(html_shell("更新日志不存在", body)),
|
||||
)
|
||||
}
|
||||
|
||||
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>"#;
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(render_shell(
|
||||
"更新日志暂时不可用",
|
||||
r#"<p class="summary">读取更新日志失败。</p>"#,
|
||||
"",
|
||||
)),
|
||||
Html(html_shell("更新日志不可用", body)),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,4 +53,54 @@ impl WebPageRepository {
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn list_pages(
|
||||
pool: &PgPool,
|
||||
page_type: 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"#)
|
||||
.bind(pt)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
} else {
|
||||
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>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
WHERE page_type = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(pt)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok((rows, total))
|
||||
}
|
||||
}
|
||||
|
||||
+249
-269
@@ -1,12 +1,14 @@
|
||||
use axum::extract::Path;
|
||||
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,
|
||||
};
|
||||
@@ -26,18 +28,181 @@ impl WebRouteState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PageListQuery {
|
||||
pub page_type: 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()
|
||||
.route("/v1/web/pages", post(create_page))
|
||||
// JSON API(内部调用用)
|
||||
.route("/v1/web/pages", get(list_pages).post(create_page))
|
||||
.route("/v1/web/mail", post(create_mail_page))
|
||||
.route("/mail/{slug}", get(show_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(
|
||||
Extension(state): Extension<WebRouteState>,
|
||||
headers: HeaderMap,
|
||||
@@ -104,31 +269,66 @@ async fn create_mail_page(
|
||||
Ok(Json(response_for_page(&page, &headers)))
|
||||
}
|
||||
|
||||
async fn show_mail_page(
|
||||
async fn list_pages(
|
||||
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)?;
|
||||
Query(query): Query<PageListQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let page_type = query
|
||||
.page_type
|
||||
.map(|v| normalize_key(&v, "page_type"))
|
||||
.transpose()?;
|
||||
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;
|
||||
|
||||
Ok(Html(render_page(&page)))
|
||||
let (pages, total) =
|
||||
WebPageRepository::list_pages(&state.db, page_type.as_deref(), per_page, offset)
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": pages,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn show_typed_page(
|
||||
async fn get_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())?;
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let page_type =
|
||||
normalize_key(&page_type, "page_type").map_err(|_| api_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)?;
|
||||
.map_err(api_internal_error)?
|
||||
.ok_or_else(|| api_not_found("页面不存在"))?;
|
||||
|
||||
Ok(Html(render_page(&page)))
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"page": &page,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_mail_page(
|
||||
Extension(state): Extension<WebRouteState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
|
||||
.await
|
||||
.map_err(api_internal_error)?
|
||||
.ok_or_else(|| api_not_found("页面不存在"))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"page": &page,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── 辅助函数 ─────────────────────────────────────────────
|
||||
|
||||
fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse {
|
||||
let path = public_path(page);
|
||||
let url = format!("{}{}", request_base_url(headers), path);
|
||||
@@ -237,235 +437,6 @@ fn normalize_key(value: &str, field: &str) -> Result<String, ApiError> {
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn render_page(page: &WebPage) -> String {
|
||||
let summary = page
|
||||
.summary
|
||||
.as_deref()
|
||||
.map(|summary| format!(r#"<p class="summary">{}</p>"#, escape_html(summary)))
|
||||
.unwrap_or_default();
|
||||
let source = page
|
||||
.source_label
|
||||
.as_deref()
|
||||
.map(|source| {
|
||||
format!(
|
||||
r#"<span class="meta-item">来源:{}</span>"#,
|
||||
escape_html(source)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let created_at = page
|
||||
.created_at
|
||||
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let metadata = render_metadata(&page.metadata);
|
||||
let content = render_text_content(&page.content);
|
||||
let page_type = escape_html(&page.page_type);
|
||||
|
||||
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>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1f2328;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
}}
|
||||
main {{
|
||||
width: min(880px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 56px;
|
||||
}}
|
||||
.eyebrow {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #57606a;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 18px 0 10px;
|
||||
font-size: 32px;
|
||||
line-height: 1.18;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
.summary {{
|
||||
margin: 0 0 18px;
|
||||
color: #57606a;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 28px;
|
||||
color: #6e7781;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.content {{
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.details {{
|
||||
margin: 24px 0;
|
||||
border-top: 1px solid #d8dee4;
|
||||
border-bottom: 1px solid #d8dee4;
|
||||
padding: 14px 0;
|
||||
}}
|
||||
dl {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(88px, 160px) 1fr;
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
dt {{
|
||||
color: #6e7781;
|
||||
}}
|
||||
dd {{
|
||||
margin: 0;
|
||||
color: #24292f;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
@media (max-width: 640px) {{
|
||||
main {{
|
||||
width: min(100% - 24px, 880px);
|
||||
padding-top: 28px;
|
||||
}}
|
||||
.content {{
|
||||
padding: 18px;
|
||||
}}
|
||||
dl {{
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span class="eyebrow">{page_type}</span>
|
||||
<h1>{title}</h1>
|
||||
{summary}
|
||||
<div class="meta">{source}<span class="meta-item">创建时间:{created_at}</span></div>
|
||||
{metadata}
|
||||
<article class="content">{content}</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(&page.title),
|
||||
summary = summary,
|
||||
source = source,
|
||||
created_at = escape_html(&created_at),
|
||||
metadata = metadata,
|
||||
content = content,
|
||||
page_type = page_type
|
||||
)
|
||||
}
|
||||
|
||||
fn render_metadata(metadata: &str) -> String {
|
||||
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let items = values
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = metadata_value_to_text(value)?;
|
||||
Some(format!(
|
||||
"<dt>{}</dt><dd>{}</dd>",
|
||||
escape_html(metadata_label(&key)),
|
||||
escape_html(&value)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#"<section class="details"><dl>{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(value) => {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
Value::Array(values) if values.is_empty() => None,
|
||||
Value::Object(values) if values.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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn api_bad_request(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -476,13 +447,23 @@ fn api_bad_request(message: impl Into<String>) -> ApiError {
|
||||
)
|
||||
}
|
||||
|
||||
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": message.into(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
eprintln!("创建 web 页面失败: {error}");
|
||||
eprintln!("web 接口失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": "创建 web 页面失败",
|
||||
"message": "web 接口失败",
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -490,7 +471,10 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
fn html_not_found() -> HtmlError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(render_error_page("页面不存在", "没有找到对应的内容页面")),
|
||||
Html(html_shell(
|
||||
"页面不存在",
|
||||
r#"<h1 class="text-2xl font-bold">页面不存在</h1><p class="mt-2 text-gray-500">没有找到对应的内容页面</p>"#,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -498,27 +482,23 @@ fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取 web 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(render_error_page("页面暂时不可用", "读取页面内容失败")),
|
||||
Html(html_shell(
|
||||
"页面暂时不可用",
|
||||
r#"<h1 class="text-2xl font-bold">页面暂时不可用</h1><p class="mt-2 text-gray-500">读取页面内容失败,请稍后重试</p>"#,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_error_page(title: &str, message: &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>
|
||||
</head>
|
||||
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328;">
|
||||
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:48px 0;">
|
||||
<h1 style="font-size:32px;line-height:1.2;margin:0 0 12px;">{title}</h1>
|
||||
<p style="font-size:16px;line-height:1.7;margin:0;color:#57606a;">{message}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
message = escape_html(message)
|
||||
)
|
||||
#[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("页面不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user