feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
+209
-34
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{OriginalUri, Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
@@ -113,7 +113,30 @@ fn not_found_page(path: &str) -> Response {
|
||||
(StatusCode::NOT_FOUND, Html(body)).into_response()
|
||||
}
|
||||
|
||||
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
|
||||
/// 前端客户端路由占用的路径前缀(见 `stash-web/src/data/view.ts`)。
|
||||
///
|
||||
/// 这些地址在服务端没有对应文件:`/c/apps` 刷新时 `ServeDir` 找不到东西,
|
||||
/// 会落到下面的兜底处理器,由这里回落到内嵌的 `index.html`,
|
||||
/// 前端再按 `location.pathname` 还原出同一页。
|
||||
///
|
||||
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
|
||||
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
|
||||
/// 到底是路由拼错了还是文件真的不存在。
|
||||
const SPA_PREFIXES: &[&str] = &["/c/"];
|
||||
|
||||
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
|
||||
fn is_spa_path(path: &str) -> bool {
|
||||
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
|
||||
async fn fallback_handler(OriginalUri(uri): OriginalUri, method: Method) -> Response {
|
||||
let is_page = method == Method::GET || method == Method::HEAD;
|
||||
|
||||
if is_page && is_spa_path(uri.path()) {
|
||||
return web_index().await;
|
||||
}
|
||||
|
||||
not_found_page(uri.path())
|
||||
}
|
||||
|
||||
@@ -141,9 +164,10 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
|
||||
}))
|
||||
}
|
||||
|
||||
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
|
||||
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
|
||||
///
|
||||
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
|
||||
/// 侧栏由这份数据派生,不再单独下发导航。
|
||||
async fn catalog_api(State(state): State<AppState>) -> Response {
|
||||
let db = state.db.clone();
|
||||
|
||||
@@ -151,7 +175,6 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
|
||||
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
|
||||
Ok(Ok(catalog)) => Json(serde_json::json!({
|
||||
"categories": catalog.categories,
|
||||
"nav": catalog.nav,
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(Err(error)) => {
|
||||
@@ -252,6 +275,47 @@ fn project_event(projects: Vec<AppProject>) -> Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||
///
|
||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||
/// 起监听任务,没法在单元测试里用。
|
||||
fn build_router(state: AppState) -> Router {
|
||||
let root = state.config.root.clone();
|
||||
let enable_cors = state.config.enable_cors;
|
||||
|
||||
// 顺序就是优先级:显式路由(API / 内嵌前端)→ 托管目录 → 前端路由回落 → 404
|
||||
let serve_dir = ServeDir::new(&root)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||
fallback_handler,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/api/catalog", get(catalog_api))
|
||||
.route("/api/catalog/events", get(catalog_events))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if enable_cors {
|
||||
app = app.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
if !config.root.exists() {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
@@ -271,36 +335,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
db,
|
||||
};
|
||||
|
||||
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
||||
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
|
||||
let serve_dir = ServeDir::new(&config.root)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||
not_found_handler,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/api/catalog", get(catalog_api))
|
||||
.route("/api/catalog/events", get(catalog_events))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(shared_state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if config.enable_cors {
|
||||
app = app.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
}
|
||||
let app = build_router(shared_state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
@@ -328,3 +363,143 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
local_addr,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use std::path::PathBuf;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// 测试用的托管目录:放在 `target/` 下,不依赖系统临时目录的权限。
|
||||
fn hosted_root() -> PathBuf {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/spa-root");
|
||||
|
||||
std::fs::create_dir_all(root.join("demo")).expect("应能创建测试项目");
|
||||
std::fs::write(
|
||||
root.join("demo/index.html"),
|
||||
"<!doctype html><title>demo</title>",
|
||||
)
|
||||
.expect("应能写入项目入口页");
|
||||
std::fs::write(root.join("note.txt"), "plain file").expect("应能写入普通文件");
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
fn router() -> Router {
|
||||
let mut config = Config::default();
|
||||
config.root = hosted_root();
|
||||
// 测试不关心 CORS 标头
|
||||
config.enable_cors = false;
|
||||
|
||||
let (project_updates, _) = broadcast::channel(4);
|
||||
|
||||
build_router(AppState {
|
||||
config: Arc::new(config),
|
||||
project_updates,
|
||||
db: Arc::new(Db::in_memory()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
|
||||
async fn get(path: &str) -> (StatusCode, String, String) {
|
||||
let response = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.expect("请求应能构造"),
|
||||
)
|
||||
.await
|
||||
.expect("路由应能处理请求");
|
||||
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("应能读出响应体");
|
||||
|
||||
(
|
||||
status,
|
||||
content_type,
|
||||
String::from_utf8_lossy(&bytes).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新分类明细页:服务端没有 `/c/apps` 这个文件,必须回落到前端入口页。
|
||||
#[tokio::test]
|
||||
async fn spa_routes_fall_back_to_the_embedded_index() {
|
||||
let (status, content_type, body) = get("/c/apps").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
content_type.starts_with("text/html"),
|
||||
"应返回 HTML: {content_type}"
|
||||
);
|
||||
assert!(body.contains(r#"<div id="root">"#), "应返回前端入口页");
|
||||
}
|
||||
|
||||
/// 条目详情地址(含分组)同样回落:`/c/` 前缀下的任意层级都是前端路由。
|
||||
#[tokio::test]
|
||||
async fn item_routes_fall_back_to_the_embedded_index() {
|
||||
let (status, _, body) = get("/c/apps/common/i/github").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains(r#"<div id="root">"#));
|
||||
|
||||
// 本地项目:条目 id 是项目名,可能带空格 / 中文,照样要能落到入口页
|
||||
let (status, _, _) = get("/c/local/local-all/i/demo").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
/// 托管目录里的真实文件优先于回落,不能被前端路由吞掉。
|
||||
#[tokio::test]
|
||||
async fn hosted_files_still_win_over_the_spa_fallback() {
|
||||
let (status, _, body) = get("/demo/").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body.contains("<title>demo</title>"),
|
||||
"应直接返回项目入口页: {body}"
|
||||
);
|
||||
|
||||
let (status, _, body) = get("/note.txt").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, "plain file");
|
||||
}
|
||||
|
||||
/// 不是前端路由的路径仍然返回 404 页面(带请求路径,便于排错)。
|
||||
#[tokio::test]
|
||||
async fn unknown_paths_still_return_the_404_page() {
|
||||
let (status, content_type, body) = get("/missing.txt").await;
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert!(content_type.starts_with("text/html"));
|
||||
assert!(body.contains("404"));
|
||||
assert!(body.contains("/missing.txt"), "404 页面应显示请求路径");
|
||||
}
|
||||
|
||||
/// API 不能被回落影响。
|
||||
#[tokio::test]
|
||||
async fn api_routes_are_not_swallowed_by_the_fallback() {
|
||||
let (status, content_type, body) = get("/api/health").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(content_type.starts_with("application/json"));
|
||||
assert!(body.contains("\"status\":\"ok\""));
|
||||
}
|
||||
|
||||
/// 只接管登记过的前缀:`/c` 本身与 `/css/...` 都还是普通路径。
|
||||
#[test]
|
||||
fn only_registered_prefixes_fall_back() {
|
||||
assert!(is_spa_path("/c/apps"));
|
||||
assert!(is_spa_path("/c/"));
|
||||
assert!(!is_spa_path("/c"));
|
||||
assert!(!is_spa_path("/css/app.css"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user