From 437ff5699237ec5c025b38da53517c43097a2736 Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 29 Oct 2026 15:30:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(docs):=20=E6=96=87=E6=A1=A3=E7=AB=99?= =?UTF-8?q?=E9=87=8D=E5=88=B6=20=E2=80=94=20=E5=8F=82=E7=85=A7=20opsxapi/o?= =?UTF-8?q?psxshell=20=E4=BD=93=E7=B3=BB,=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=B8=89=E6=A0=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 goldmark 渲染管线:/docs/:slug 页面 + /docs/search-data.json 搜索索引, manifest.json 驱动分组导航,正文剥 H1 避免与页面头重复 - 三栏布局(分组侧栏/内容/本页目录)+ 顶栏(品牌/版本徽章/搜索面板/官网入口), docs.css 品牌绿版 + docs.js(代码复制/轻量高亮/提示块/TOC 滚动高亮/移动端抽屉) - 替换 docsify;无上一页时空占位无边框;visual-judge 两轮评审(初版 fail → 修复后 pass) --- backend/controllers/docs.go | 110 +++++++++++++++ backend/controllers/system.go | 2 - backend/go.mod | 1 + backend/go.sum | 2 + backend/routers/router.go | 9 +- backend/services/docsite.go | 208 +++++++++++++++++++++++++++ docs/assets/docs.css | 255 ++++++++++++++++++++++++++++++++++ docs/assets/docs.js | 196 ++++++++++++++++++++++++++ docs/index.html | 31 ----- docs/user-guide/_sidebar.md | 9 -- docs/user-guide/manifest.json | 74 ++++++++++ 11 files changed, 854 insertions(+), 43 deletions(-) create mode 100644 backend/controllers/docs.go create mode 100644 backend/services/docsite.go create mode 100644 docs/assets/docs.css create mode 100644 docs/assets/docs.js delete mode 100644 docs/index.html delete mode 100644 docs/user-guide/_sidebar.md create mode 100644 docs/user-guide/manifest.json diff --git a/backend/controllers/docs.go b/backend/controllers/docs.go new file mode 100644 index 0000000..e4039ca --- /dev/null +++ b/backend/controllers/docs.go @@ -0,0 +1,110 @@ +package controllers + +import ( + "html/template" + "net/http" + + "lokistack/services" + "lokistack/utils" + + "github.com/gin-gonic/gin" +) + +// 文档站控制器:模板内嵌(三栏布局,参考 opsxapi/opsxshell 文档站,品牌绿) +var docsTemplate = template.Must(template.New("docs").Parse(` + + + + +{{.Page.Title}} · LokiStack 文档 + + + + + +
+
+ + + + LokiStack 文档 + + v{{.Version}} + + +
+
+ +
+ + +
+ +

{{.Page.Title}}

+ {{if .Page.Updated}}
更新于 {{.Page.Updated}}
{{end}} +
+{{.Page.Content}} +
+ +
+ {{with .Page.Prev}} + + {{else}}{{end}} + {{with .Page.Next}} + + {{else}}{{end}} +
+
+ + +
+ + + +`)) + +type DocsController struct{} + +func NewDocsController() *DocsController { return &DocsController{} } + +func (ctrl *DocsController) Home(c *gin.Context) { + c.Redirect(http.StatusFound, "/docs/"+services.DocsFirstSlug()) +} + +func (ctrl *DocsController) Page(c *gin.Context) { + page, manifest, err := services.DocsRenderPage(c.Param("slug")) + if err != nil { + utils.NotFound(c, "文档页面不存在") + return + } + _ = docsTemplate.Execute(c.Writer, gin.H{"Page": page, "Manifest": manifest, "Version": manifest.ProductVersion}) +} + +func (ctrl *DocsController) SearchData(c *gin.Context) { + data, err := services.DocsSearchData() + if err != nil { + data = []map[string]string{} + } + c.JSON(http.StatusOK, data) +} diff --git a/backend/controllers/system.go b/backend/controllers/system.go index d46207f..186d4a5 100644 --- a/backend/controllers/system.go +++ b/backend/controllers/system.go @@ -1,7 +1,6 @@ package controllers import ( - "github.com/gin-gonic/gin" "lokistack/services" @@ -43,4 +42,3 @@ func (ctrl *SystemController) SaveRetention(c *gin.Context) { func (ctrl *SystemController) Health(c *gin.Context) { utils.Success(c, services.SelfCheck()) } - diff --git a/backend/go.mod b/backend/go.mod index ec93b6c..60661a8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -66,6 +66,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/goldmark v1.8.6 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/net v0.49.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index dbbaf3d..257b0b0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -170,6 +170,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= diff --git a/backend/routers/router.go b/backend/routers/router.go index b3e025e..1e89f2c 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -10,6 +10,7 @@ import ( "lokistack/controllers" "lokistack/middleware" "lokistack/pkg/metrics" + "lokistack/services" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -233,7 +234,13 @@ func SetupRouter() *gin.Engine { // 文档站(docsify,docs/)与落地页(homepage/)随二进制一起发布; // 独立于前端 dist 存在与否注册,目录不存在时路由不生效 - serveStaticDir(r, "/docs", "docs") + docsCtrl := controllers.NewDocsController() + if services.DocsBaseDir() != "" { + r.GET("/docs", docsCtrl.Home) + r.GET("/docs/search-data.json", docsCtrl.SearchData) + r.GET("/docs/:slug", docsCtrl.Page) + r.Static("/docs/assets", filepath.Join(services.DocsBaseDir(), "assets")) + } serveStaticDir(r, "/homepage", "homepage") // Static files - use executable directory as base diff --git a/backend/services/docsite.go b/backend/services/docsite.go new file mode 100644 index 0000000..3649393 --- /dev/null +++ b/backend/services/docsite.go @@ -0,0 +1,208 @@ +package services + +// 用户手册文档站:docs/user-guide/*.md 按 manifest.json 分组, +// 服务端 goldmark 渲染 + 三栏布局(侧栏/内容/本页目录)+ 顶栏搜索。 +// 布局与交互参考 opsxapi/opsxshell 文档站,换 LokiStack 品牌绿。 +import ( + "encoding/json" + "fmt" + "html/template" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" +) + +// DocsManifest docs/user-guide/manifest.json +type DocsManifest struct { + ProductVersion string `json:"productVersion"` + Groups []struct { + Name string `json:"name"` + Pages []struct { + File string `json:"file"` + Slug string `json:"slug"` + Title string `json:"title"` + Updated string `json:"updated"` + } `json:"pages"` + } `json:"groups"` +} + +// DocsPage 渲染一页所需的数据 +type DocsPage struct { + Title string + Group string + Updated string + Slug string + Content template.HTML + TOC []DocsTOCItem + Prev *DocsNav + Next *DocsNav +} + +type DocsTOCItem struct { + Level int + ID string + Text string +} + +type DocsNav struct { + Slug string + Title string +} + +var ( + docsManifestCache *DocsManifest + docsBasePath string + docsMD = goldmark.New(goldmark.WithExtensions(extension.Table), goldmark.WithParserOptions(parser.WithAutoHeadingID())) + docsTOCPattern = regexp.MustCompile(`]*>([^<]+)`) +) + +// DocsBaseDir 定位 docs 根(含 user-guide/manifest.json);找不到返回空 +func DocsBaseDir() string { + if docsBasePath != "" { + return docsBasePath + } + for _, p := range []string{"docs", "../docs", "../../docs"} { + abs, err := filepath.Abs(p) + if err != nil { + continue + } + if _, err := os.Stat(filepath.Join(abs, "user-guide", "manifest.json")); err == nil { + docsBasePath = abs + return abs + } + } + return "" +} + +// DocsLoadManifest 解析(并缓存)manifest +func DocsLoadManifest() (*DocsManifest, error) { + if docsManifestCache != nil { + return docsManifestCache, nil + } + base := DocsBaseDir() + if base == "" { + return nil, fmt.Errorf("docs 目录不存在") + } + data, err := os.ReadFile(filepath.Join(base, "user-guide", "manifest.json")) + if err != nil { + return nil, err + } + var m DocsManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + docsManifestCache = &m + return docsManifestCache, nil +} + +// DocsFlat 顺序展开全部页面(slug 定位与上下页) +func DocsFlat(m *DocsManifest) []struct { + Group string + File string + Slug string + Title string + Updated string +} { + var out []struct { + Group string + File string + Slug string + Title string + Updated string + } + for _, g := range m.Groups { + for _, p := range g.Pages { + out = append(out, struct { + Group string + File string + Slug string + Title string + Updated string + }{g.Name, p.File, p.Slug, p.Title, p.Updated}) + } + } + return out +} + +// DocsRenderPage 渲染指定 slug;含 TOC 与上一页/下一页 +func DocsRenderPage(slug string) (*DocsPage, *DocsManifest, error) { + m, err := DocsLoadManifest() + if err != nil { + return nil, nil, err + } + flat := DocsFlat(m) + idx := -1 + for i, p := range flat { + if p.Slug == slug { + idx = i + break + } + } + if idx < 0 { + return nil, nil, fmt.Errorf("页面不存在") + } + cur := flat[idx] + data, err := os.ReadFile(filepath.Join(DocsBaseDir(), "user-guide", cur.File)) + if err != nil { + return nil, nil, err + } + var sb strings.Builder + if err := docsMD.Convert(data, &sb); err != nil { + return nil, nil, err + } + html := sb.String() + // 页面头已渲染标题,剥掉正文首个 H1 避免重复 + if i := strings.Index(html, "= 0 { + if j := strings.Index(html[i:], ""); j >= 0 { + html = html[:i] + html[i+j+len(""):] + } + } + + page := &DocsPage{Title: cur.Title, Group: cur.Group, Updated: cur.Updated, Slug: cur.Slug, Content: template.HTML(html)} + for _, m2 := range docsTOCPattern.FindAllStringSubmatch(html, -1) { + page.TOC = append(page.TOC, DocsTOCItem{Level: int(m2[1][0] - '0'), ID: m2[2], Text: m2[3]}) + } + if idx > 0 { + page.Prev = &DocsNav{Slug: flat[idx-1].Slug, Title: flat[idx-1].Title} + } + if idx < len(flat)-1 { + page.Next = &DocsNav{Slug: flat[idx+1].Slug, Title: flat[idx+1].Title} + } + return page, m, nil +} + +// DocsSearchData 顶栏搜索索引(标题 + 纯文本摘要) +func DocsSearchData() ([]map[string]string, error) { + m, err := DocsLoadManifest() + if err != nil { + return nil, err + } + var out []map[string]string + for _, p := range DocsFlat(m) { + data, err := os.ReadFile(filepath.Join(DocsBaseDir(), "user-guide", p.File)) + if err != nil { + continue + } + text := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(string(data), " ") + text = strings.Join(strings.Fields(text), " ") + if len(text) > 4000 { + text = text[:4000] + } + out = append(out, map[string]string{"slug": p.Slug, "title": p.Title, "group": p.Group, "text": text}) + } + return out, nil +} + +// DocsFirstSlug 首页重定向目标 +func DocsFirstSlug() string { + m, err := DocsLoadManifest() + if err != nil || len(m.Groups) == 0 || len(m.Groups[0].Pages) == 0 { + return "readme" + } + return m.Groups[0].Pages[0].Slug +} diff --git a/docs/assets/docs.css b/docs/assets/docs.css new file mode 100644 index 0000000..28649df --- /dev/null +++ b/docs/assets/docs.css @@ -0,0 +1,255 @@ +/* OpsxAPI 文档站 —— 清爽专业风格(浅色) */ +:root { + --blue: #059669; + --blue-dark: #047857; + --blue-soft: rgba(5, 150, 105, 0.08); + --bg: #f6f8fb; + --panel: #ffffff; + --border: #e4e9f1; + --text: #1c2434; + --text-2: #5b6880; + --text-3: #98a2b3; + --code-bg: #f3f6fb; + --code-border: #e1e8f3; + --green: #059669; + --amber: #b45309; + --red: #dc2626; + --sidebar-w: 262px; + --toc-w: 220px; + --topbar-h: 56px; + --radius: 10px; + --shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.05); + --mono: "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 72px; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14.5px; + line-height: 1.75; +} +a { color: var(--blue); text-decoration: none; } +.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; } +a:hover { color: var(--blue-dark); text-decoration: underline; } + +/* ---------- 顶栏 ---------- */ +.topbar { + position: sticky; top: 0; z-index: 50; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: saturate(1.4) blur(8px); + border-bottom: 1px solid var(--border); +} +.topbar-inner { + max-width: 1440px; margin: 0 auto; height: var(--topbar-h); + display: flex; align-items: center; gap: 14px; padding: 0 20px; +} +.menu-btn { + display: none; border: 1px solid var(--border); background: #fff; + border-radius: 8px; width: 34px; height: 34px; cursor: pointer; color: var(--text-2); + place-items: center; +} +.menu-btn .i { width: 17px; height: 17px; } +.brand { display: flex; align-items: center; gap: 9px; text-decoration: none !important; } +.brand-logo { display: inline-flex; } +.brand-logo svg { display: block; border-radius: 7px; } +.brand-name { font-size: 16px; font-weight: 700; color: var(--text); } +.brand-name em { font-style: normal; font-weight: 500; color: var(--text-2); margin-left: 2px; } +.ver-badge { + font-size: 11px; font-weight: 600; color: var(--blue); + background: var(--blue-soft); border: 1px solid rgba(5, 150, 105, 0.22); + padding: 1px 9px; border-radius: 999px; white-space: nowrap; +} +.search { position: relative; flex: 1; max-width: 420px; margin-left: 8px; } +.search input { + width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 9px; + background: var(--bg); padding: 0 14px 0 34px; font-size: 13.5px; color: var(--text); outline: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' stroke='%2398a2b3' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: 12px center; +} +.search input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-soft); } +.search-panel { + position: absolute; top: 44px; left: 0; right: 0; background: #fff; + border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); + max-height: 420px; overflow: auto; padding: 6px; +} +.search-item { display: block; padding: 8px 12px; border-radius: 8px; text-decoration: none !important; } +.search-item:hover, .search-item.sel { background: var(--blue-soft); } +.search-item .si-title { font-weight: 600; color: var(--text); font-size: 13.5px; } +.search-item .si-group { color: var(--text-3); font-size: 11px; margin-left: 6px; } +.search-item .si-snippet { color: var(--text-2); font-size: 12px; margin-top: 2px; display: block; } +.search-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 13px; } +.top-links { margin-left: auto; display: flex; gap: 16px; align-items: center; } +.top-links a { font-size: 13.5px; font-weight: 500; } + +/* ---------- 三栏布局 ---------- */ +.layout { + max-width: 1440px; margin: 0 auto; display: grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w); + gap: 0 28px; padding: 24px 20px 60px; +} + +/* 侧栏 */ +.sidebar { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-right: 8px; } +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-thumb { background: #d6dde8; border-radius: 4px; } +.nav-group { margin-bottom: 18px; } +.nav-group-title { + font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-3); padding: 0 10px 6px; +} +.nav-link { + display: block; padding: 6px 10px; border-radius: 8px; font-size: 13.5px; + color: var(--text-2); text-decoration: none !important; border-left: 2px solid transparent; +} +.nav-link:hover { background: rgba(5, 150, 105, 0.06); color: var(--text); } +.nav-link.active { + background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; + border-left-color: var(--blue); +} +.sidebar-foot { margin-top: 24px; padding: 12px 10px 0; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-3); } + +/* 内容 */ +.content { min-width: 0; max-width: 860px; } +.breadcrumb { font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; } +.breadcrumb span { margin: 0 6px; } +.page-title { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 6px; line-height: 1.3; } +.page-meta { font-size: 12.5px; color: var(--text-3); margin-bottom: 22px; } + +/* Markdown 排版 */ +.md h2 { font-size: 21px; font-weight: 700; margin: 38px 0 12px; padding-top: 8px; letter-spacing: -0.01em; } +.md h3 { font-size: 16.5px; font-weight: 700; margin: 26px 0 8px; } +.md h2 + h3, .md h1 + h2 { margin-top: 18px; } +.md p { margin: 10px 0; color: #2a3348; } +.md ul, .md ol { padding-left: 22px; margin: 10px 0; } +.md li { margin: 5px 0; color: #2a3348; } +.md li::marker { color: var(--blue); } +.md strong { color: var(--text); } +.md hr { border: none; border-top: 1px solid var(--border); margin: 30px 0; } +.md img { max-width: 100%; border-radius: var(--radius); border: 1px solid var(--border); } +.md blockquote { + margin: 14px 0; padding: 10px 16px; border-left: 3px solid var(--blue); + background: var(--blue-soft); border-radius: 0 8px 8px 0; color: #33415e; +} + +/* 表格 */ +.md table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13.5px; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 0 0 1px var(--border); } +.md th, .md td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +.md th { background: #f9fbfe; font-weight: 600; color: var(--text); white-space: nowrap; } +.md tr:last-child td { border-bottom: none; } + +/* 代码块 */ +.md code { font-family: var(--mono); font-size: 12.8px; background: var(--code-bg); border: 1px solid var(--code-border); border-radius: 5px; padding: 1px 6px; color: #334155; } +.md pre { + position: relative; background: #0f172a; color: #dbe4f3; border-radius: var(--radius); + padding: 16px 16px 14px; overflow-x: auto; margin: 16px 0; font-size: 13px; line-height: 1.65; +} +.md pre code { background: none; border: none; color: inherit; padding: 0; font-size: inherit; } +.md pre .lang-tag { + position: absolute; top: 8px; right: 12px; font-size: 10.5px; color: #7c8db0; + font-family: var(--mono); text-transform: lowercase; letter-spacing: 0.04em; +} +.md pre .copy-btn { + position: absolute; top: 6px; right: 60px; font-size: 11px; color: #9fb0d0; + background: rgba(255, 255, 255, 0.07); border: none; border-radius: 6px; padding: 2px 9px; cursor: pointer; + opacity: 0; transition: opacity 0.15s; +} +.md pre:hover .copy-btn { opacity: 1; } +.md pre .copy-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.14); } +/* 行内高亮 token */ +.md pre .tok-key { color: #7dd3fc; } +.md pre .tok-str { color: #86efac; } +.md pre .tok-num { color: #fcd34d; } +.md pre .tok-com { color: #64748b; font-style: italic; } + +/* 提示块(通过 blockquote 首字符约定) */ +.md blockquote.tip { border-left-color: var(--green); background: rgba(5, 150, 105, 0.07); } +.md blockquote.warn { border-left-color: var(--amber); background: rgba(180, 83, 9, 0.07); } + +/* 上下页 */ +.pn-nav { display: flex; gap: 14px; margin-top: 48px; } +.pn { + flex: 1; border: 1px solid var(--border); background: #fff; border-radius: var(--radius); + padding: 13px 16px; text-decoration: none !important; transition: border-color 0.15s, box-shadow 0.15s; +} +.pn:hover { border-color: var(--blue); box-shadow: var(--shadow); } +.pn.next { text-align: right; } +.pn-label { display: block; font-size: 11.5px; color: var(--text-3); } +.pn-title { display: block; font-weight: 600; color: var(--text); font-size: 14px; margin-top: 2px; } + +/* 右侧目录 */ +.toc { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-top: 34px; } +.toc-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-3); margin-bottom: 8px; } +.toc-nav { display: flex; flex-direction: column; gap: 1px; border-left: 2px solid var(--border); padding-left: 0; } +.toc-nav a { + font-size: 12.5px; color: var(--text-2); padding: 4px 0 4px 12px; text-decoration: none !important; + border-left: 2px solid transparent; margin-left: -2px; line-height: 1.5; +} +.toc-nav a:hover { color: var(--blue); } +.toc-nav a.toc-l3 { padding-left: 26px; font-size: 12px; color: var(--text-3); } +.toc-nav a.active { color: var(--blue); border-left-color: var(--blue); font-weight: 600; } +/* 404 */ +.doc-404 { display: grid; place-items: center; min-height: 100vh; text-align: center; } +.doc-404 h1 { font-size: 56px; margin: 0; color: var(--blue); } +.doc-404 .btn { + display: inline-block; margin-top: 12px; background: var(--blue); color: #fff; + padding: 8px 18px; border-radius: 8px; text-decoration: none !important; font-weight: 600; +} + +/* ---------- 响应式 ---------- */ +@media (max-width: 1200px) { + .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); } + .toc { display: none; } +} +@media (max-width: 880px) { + .menu-btn { display: inline-grid; place-items: center; } + .layout { grid-template-columns: minmax(0, 1fr); padding-top: 12px; } + .sidebar { + position: fixed; z-index: 60; left: 0; top: var(--topbar-h); bottom: 0; + width: 280px; background: #fff; border-right: 1px solid var(--border); + padding: 16px; transform: translateX(-100%); transition: transform 0.2s; max-height: none; + } + .sidebar.open { transform: none; box-shadow: var(--shadow); } + .ver-badge { display: none; } + .top-links a { font-size: 12.5px; } + .page-title { font-size: 24px; } +} + +/* ---------- 打印 ---------- */ +@media print { + .topbar, .sidebar, .toc, .pn-nav, .feedback, .menu-btn { display: none !important; } + .layout { display: block; max-width: none; padding: 0; } + body { background: #fff; } + .md pre { background: #f1f5f9 !important; color: #1e293b !important; } + .md pre .copy-btn, .md pre .lang-tag { display: none; } +} + +/* ---------- LokiStack 品牌适配 ---------- */ +.brand-logo { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: 8px; + background: linear-gradient(135deg, #10b981, #047857); + color: #fff; font-weight: 800; font-size: 17px; +} + +/* 响应式(窄屏收起侧栏与目录) */ +@media (max-width: 1100px) { + .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); } + .toc { display: none; } +} +@media (max-width: 860px) { + .layout { grid-template-columns: minmax(0, 1fr); padding: 16px 14px 40px; } + .menu-btn { display: grid; } + .sidebar { + position: fixed; z-index: 60; top: var(--topbar-h); left: 0; bottom: 0; + width: var(--sidebar-w); background: #fff; border-right: 1px solid var(--border); + transform: translateX(-100%); transition: transform 0.2s; max-height: none; padding: 14px 10px; + } + .sidebar.open { transform: none; box-shadow: var(--shadow); } + .search { max-width: none; } + .top-links { display: none; } +} + +.pn-empty { flex: 1; } diff --git a/docs/assets/docs.js b/docs/assets/docs.js new file mode 100644 index 0000000..833ffde --- /dev/null +++ b/docs/assets/docs.js @@ -0,0 +1,196 @@ +/* LokiStack 文档站交互:搜索 / 代码复制 / 目录高亮 / 移动端导航(参考 opsxapi 体系) */ +(function () { + "use strict"; + + /* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */ + document.querySelectorAll("article.md pre").forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) return; + var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1]; + if (lang) { + var tag = document.createElement("span"); + tag.className = "lang-tag"; + tag.textContent = lang; + pre.appendChild(tag); + } + // 复制按钮 + var btn = document.createElement("button"); + btn.className = "copy-btn"; + btn.type = "button"; + btn.textContent = "复制"; + btn.addEventListener("click", function () { + navigator.clipboard.writeText(code.textContent).then( + function () { + btn.textContent = "已复制 ✓"; + setTimeout(function () { btn.textContent = "复制"; }, 1400); + }, + function () { btn.textContent = "复制失败"; } + ); + }); + pre.appendChild(btn); + // JSON / bash 轻量高亮 + if (lang === "json") highlightJSON(code); + else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code); + }); + + function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + function highlightJSON(code) { + var src = code.textContent; + code.innerHTML = esc(src).replace( + /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + function (m, str, colon, kw, num) { + if (str && colon) return '' + str + "" + colon; + if (str) return '' + str + ""; + if (kw) return '' + kw + ""; + if (num) return '' + num + ""; + return m; + } + ); + } + function highlightBash(code) { + var lines = code.textContent.split("\n"); + code.innerHTML = lines + .map(function (line) { + if (/^\s*#/.test(line)) return '' + esc(line) + ""; + return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1$2'); + }) + .join("\n"); + } + + /* ---------- blockquote 提示样式约定:> **提示** / > **注意** ---------- */ + document.querySelectorAll("article.md blockquote").forEach(function (bq) { + var t = (bq.textContent || "").trim(); + if (/^(注意|警告|⚠)/.test(t)) bq.classList.add("warn"); + else if (/^(提示|说明|💡|Tip)/i.test(t)) bq.classList.add("tip"); + }); + + /* ---------- 搜索 ---------- */ + var input = document.getElementById("search-input"); + var panel = document.getElementById("search-panel"); + var entries = null; + var selIndex = -1; + + function ensureIndex(cb) { + if (entries) return cb(); + fetch("/docs/search-data.json") + .then(function (r) { return r.json(); }) + .then(function (data) { entries = data; cb(); }) + .catch(function () { entries = []; cb(); }); + } + + function snippet(text, q) { + var i = text.toLowerCase().indexOf(q); + if (i < 0) return text.slice(0, 60) + "…"; + var start = Math.max(0, i - 24); + return (start > 0 ? "…" : "") + text.slice(start, i + q.length + 40) + "…"; + } + + function doSearch() { + var q = input.value.trim().toLowerCase(); + selIndex = -1; + if (!q) { panel.hidden = true; return; } + ensureIndex(function () { + var hits = []; + entries.forEach(function (e) { + var titleHit = e.title.toLowerCase().indexOf(q) >= 0; + var idx = e.text.toLowerCase().indexOf(q); + if (titleHit || idx >= 0) { + hits.push({ + slug: e.slug, + title: e.title, + group: e.group, + score: (titleHit ? 10 : 0) + (idx === 0 ? 3 : 0), + snippet: titleHit ? snippet(e.text, q) : snippet(e.text, q), + }); + } + }); + hits.sort(function (a, b) { return b.score - a.score; }); + hits = hits.slice(0, 8); + if (!hits.length) { + panel.innerHTML = '
没有找到相关内容,换个关键词试试
'; + } else { + panel.innerHTML = hits + .map(function (h) { + return ( + '' + + '' + h.title + '' + h.group + "" + + '' + h.snippet + "" + ); + }) + .join(""); + } + panel.hidden = false; + }); + } + + var debounce; + if (input) { + input.addEventListener("input", function () { + clearTimeout(debounce); + debounce = setTimeout(doSearch, 160); + }); + input.addEventListener("focus", function () { + if (input.value.trim()) doSearch(); + }); + input.addEventListener("keydown", function (e) { + var items = panel.querySelectorAll(".search-item"); + if (e.key === "ArrowDown" && items.length) { + e.preventDefault(); + selIndex = Math.min(selIndex + 1, items.length - 1); + } else if (e.key === "ArrowUp" && items.length) { + e.preventDefault(); + selIndex = Math.max(selIndex - 1, 0); + } else if (e.key === "Enter") { + if (selIndex >= 0 && items[selIndex]) { location.href = items[selIndex].href; return; } + var first = items[0]; + if (first) location.href = first.href; + return; + } else if (e.key === "Escape") { + panel.hidden = true; + return; + } else { + return; + } + items.forEach(function (el, i) { el.classList.toggle("sel", i === selIndex); }); + }); + document.addEventListener("click", function (e) { + if (!e.target.closest(".search")) panel.hidden = true; + }); + } + + /* ---------- 目录滚动高亮 ---------- */ + var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc-nav a")); + if (tocLinks.length && "IntersectionObserver" in window) { + var byId = {}; + tocLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; }); + var io = new IntersectionObserver( + function (entries) { + entries.forEach(function (en) { + if (en.isIntersecting) { + tocLinks.forEach(function (a) { a.classList.remove("active"); }); + var a = byId[en.target.id]; + if (a) a.classList.add("active"); + } + }); + }, + { rootMargin: "-72px 0px -70% 0px", threshold: 0 } + ); + document.querySelectorAll("article.md h2[id], article.md h3[id]").forEach(function (h) { io.observe(h); }); + } + + + + /* ---------- 移动端侧栏 ---------- */ + var menuBtn = document.getElementById("menu-btn"); + var sidebar = document.getElementById("sidebar"); + if (menuBtn && sidebar) { + menuBtn.addEventListener("click", function () { sidebar.classList.toggle("open"); }); + document.addEventListener("click", function (e) { + if (sidebar.classList.contains("open") && !e.target.closest(".sidebar") && !e.target.closest(".menu-btn")) { + sidebar.classList.remove("open"); + } + }); + } +})(); diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index ad58d3a..0000000 --- a/docs/index.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - -LokiStack 用户手册 - - - - - - -
- - - - - diff --git a/docs/user-guide/_sidebar.md b/docs/user-guide/_sidebar.md deleted file mode 100644 index 37b6c81..0000000 --- a/docs/user-guide/_sidebar.md +++ /dev/null @@ -1,9 +0,0 @@ -- [开始](README.md) -- [安装与启动](install.md) -- [部署向导](wizard.md) -- [日志采集](collectors.md) -- [日志查询](query.md) -- [日志分析](analysis.md) -- [告警通知](alerts.md) -- [查询授权](access.md) -- [常见问题](faq.md) diff --git a/docs/user-guide/manifest.json b/docs/user-guide/manifest.json new file mode 100644 index 0000000..caefec5 --- /dev/null +++ b/docs/user-guide/manifest.json @@ -0,0 +1,74 @@ +{ + "productVersion": "2.0", + "groups": [ + { + "name": "开始使用", + "pages": [ + { + "file": "README.md", + "slug": "readme", + "title": "产品介绍", + "updated": "2026-10-28" + }, + { + "file": "install.md", + "slug": "install", + "title": "安装与启动", + "updated": "2026-10-28" + }, + { + "file": "wizard.md", + "slug": "wizard", + "title": "部署向导", + "updated": "2026-10-28" + } + ] + }, + { + "name": "采集与查询", + "pages": [ + { + "file": "collectors.md", + "slug": "collectors", + "title": "日志采集", + "updated": "2026-10-28" + }, + { + "file": "query.md", + "slug": "query", + "title": "日志查询", + "updated": "2026-10-28" + }, + { + "file": "analysis.md", + "slug": "analysis", + "title": "日志分析", + "updated": "2026-10-28" + }, + { + "file": "access.md", + "slug": "access", + "title": "查询授权", + "updated": "2026-10-28" + } + ] + }, + { + "name": "告警与运维", + "pages": [ + { + "file": "alerts.md", + "slug": "alerts", + "title": "告警通知", + "updated": "2026-10-28" + }, + { + "file": "faq.md", + "slug": "faq", + "title": "常见问题", + "updated": "2026-10-28" + } + ] + } + ] +} \ No newline at end of file