feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
+209 -34
View File
@@ -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"));
}
}