feat(docs): add README.md button opening shared doc drawer

Reuses the existing API.md drawer for README; switching between
docs reloads content and updates the drawer title. Backend serves
README.md via /api/docs/readme-md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-26 16:27:51 +08:00
parent c2cac19f7e
commit 45b2317ee8
7 changed files with 42 additions and 10 deletions

View File

@ -21,3 +21,20 @@ pub async fn get_api_md() -> Result<impl IntoResponse, ApiErr> {
Ok((StatusCode::OK, headers, content)) Ok((StatusCode::OK, headers, content))
} }
pub async fn get_readme_md() -> Result<impl IntoResponse, ApiErr> {
let content = tokio::fs::read_to_string("README.md")
.await
.map_err(|err| {
tracing::error!("Failed to read README.md: {}", err);
ApiErr::NotFound("README.md not found".to_string(), None)
})?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
Ok((StatusCode::OK, headers, content))
}

View File

@ -280,7 +280,8 @@ fn build_router(state: AppState) -> Router {
) )
.route("/api/logs", get(handler::log::get_logs)) .route("/api/logs", get(handler::log::get_logs))
.route("/api/logs/stream", get(handler::log::stream_logs)) .route("/api/logs/stream", get(handler::log::stream_logs))
.route("/api/docs/api-md", get(handler::doc::get_api_md)); .route("/api/docs/api-md", get(handler::doc::get_api_md))
.route("/api/docs/readme-md", get(handler::doc::get_readme_md));
Router::new() Router::new()
.merge(all_route) .merge(all_route)

View File

@ -5,6 +5,7 @@
<button type="button" class="tab-btn" id="tabConfig">配置</button> <button type="button" class="tab-btn" id="tabConfig">配置</button>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button type="button" class="secondary" id="openReadmeDoc">README.md</button>
<button type="button" class="secondary" id="openApiDoc">API.md</button> <button type="button" class="secondary" id="openApiDoc">API.md</button>
<div class="status" id="statusText"> <div class="status" id="statusText">
<span class="ws-dot" id="wsDot"></span> <span class="ws-dot" id="wsDot"></span>

View File

@ -1,7 +1,7 @@
import { withStatus } from "./api.js"; import { withStatus } from "./api.js";
import { openChart, renderChart } from "./chart.js"; import { openChart, renderChart } from "./chart.js";
import { dom } from "./dom.js"; import { dom } from "./dom.js";
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js"; import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js";
import { loadEvents } from "./events.js"; import { loadEvents } from "./events.js";
import { import {
applyBatchEquipmentUnit, applyBatchEquipmentUnit,
@ -130,6 +130,7 @@ function bindEvents() {
}); });
}); });
dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer())); dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer); dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents())); dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));

View File

@ -82,11 +82,11 @@ function parseMarkdown(text) {
return { html: blocks.join(""), headings }; return { html: blocks.join(""), headings };
} }
export async function loadApiDoc() { async function loadDoc(url, emptyMessage) {
const text = await apiFetch("/api/docs/api-md"); const text = await apiFetch(url);
const { html, headings } = parseMarkdown(text || ""); const { html, headings } = parseMarkdown(text || "");
dom.apiDocContent.innerHTML = html || "<p>API.md 为空</p>"; dom.apiDocContent.innerHTML = html || `<p>${emptyMessage}</p>`;
dom.apiDocToc.innerHTML = headings.length dom.apiDocToc.innerHTML = headings.length
? headings ? headings
.map( .map(
@ -110,14 +110,25 @@ export async function loadApiDoc() {
} }
}); });
}); });
state.apiDocLoaded = true;
} }
export async function openApiDocDrawer() { export async function openApiDocDrawer() {
const title = dom.apiDocDrawer.querySelector("h3");
if (title) title.textContent = "API.md";
dom.apiDocDrawer.classList.remove("hidden"); dom.apiDocDrawer.classList.remove("hidden");
if (!state.apiDocLoaded) { if (state.docDrawerSource !== "api") {
await loadApiDoc(); state.docDrawerSource = "api";
await loadDoc("/api/docs/api-md", "API.md 为空");
}
}
export async function openReadmeDrawer() {
const title = dom.apiDocDrawer.querySelector("h3");
if (title) title.textContent = "README.md";
dom.apiDocDrawer.classList.remove("hidden");
if (state.docDrawerSource !== "readme") {
state.docDrawerSource = "readme";
await loadDoc("/api/docs/readme-md", "README.md 为空");
} }
} }

View File

@ -81,6 +81,7 @@ export const dom = {
batchBindingSignalRole: byId("batchBindingSignalRole"), batchBindingSignalRole: byId("batchBindingSignalRole"),
apiDocToc: byId("apiDocToc"), apiDocToc: byId("apiDocToc"),
apiDocContent: byId("apiDocContent"), apiDocContent: byId("apiDocContent"),
openReadmeDocBtn: byId("openReadmeDoc"),
openApiDocBtn: byId("openApiDoc"), openApiDocBtn: byId("openApiDoc"),
closeApiDocBtn: byId("closeApiDoc"), closeApiDocBtn: byId("closeApiDoc"),
refreshChartBtn: byId("refreshChart"), refreshChartBtn: byId("refreshChart"),

View File

@ -19,7 +19,7 @@ export const state = {
chartPointName: "", chartPointName: "",
chartData: [], chartData: [],
pointSocket: null, pointSocket: null,
apiDocLoaded: false, docDrawerSource: null, // null | "api" | "readme"
runtimes: new Map(), // unit_id -> UnitRuntime runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config" activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? } opsPointEls: new Map(), // point_id -> { pillEl, syncBtns? }