feat(docs): 文档站重制 — 参照 opsxapi/opsxshell 体系,服务端渲染三栏布局
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

- 后端 goldmark 渲染管线:/docs/:slug 页面 + /docs/search-data.json 搜索索引,
  manifest.json 驱动分组导航,正文剥 H1 避免与页面头重复
- 三栏布局(分组侧栏/内容/本页目录)+ 顶栏(品牌/版本徽章/搜索面板/官网入口),
  docs.css 品牌绿版 + docs.js(代码复制/轻量高亮/提示块/TOC 滚动高亮/移动端抽屉)
- 替换 docsify;无上一页时空占位无边框;visual-judge 两轮评审(初版 fail → 修复后 pass)
This commit is contained in:
opsx committed 2026-10-29 15:30:25 +08:00
1 parent c1f822fb10
commit 437ff56992
11 files changed
+854 -43

No files matched your search

+110
View File
@@ -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(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Page.Title}} · LokiStack 文档</title>
<meta name="description" content="LokiStack 用户手册 — Loki 日志管理平台:向导式部署、采集策略、日志查询与分析、告警通知。">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23059669'/%3E%3Ctext x='32' y='44' font-size='34' font-weight='800' fill='%23fff' text-anchor='middle' font-family='sans-serif'%3EL%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/docs/assets/docs.css">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<button class="menu-btn" id="menu-btn" aria-label="打开导航"><svg class="i" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg></button>
<a class="brand" href="/docs">
<span class="brand-logo">L</span>
<span class="brand-name">LokiStack <em>文档</em></span>
</a>
<span class="ver-badge">v{{.Version}}</span>
<div class="search">
<input id="search-input" type="search" placeholder="搜索文档…" autocomplete="off" spellcheck="false">
<div class="search-panel" id="search-panel" hidden></div>
</div>
<nav class="top-links">
<a href="/homepage">官网</a>
</nav>
</div>
</header>
<div class="layout" id="layout">
<aside class="sidebar" id="sidebar">
{{range .Manifest.Groups}}
<div class="nav-group">
<div class="nav-group-title">{{.Name}}</div>
{{range .Pages}}
<a class="nav-link{{if eq .Slug $.Page.Slug}} active{{end}}" href="/docs/{{.Slug}}">{{.Title}}</a>
{{end}}
</div>
{{end}}
<div class="sidebar-foot">© 2026 LokiStack · Loki 日志管理平台</div>
</aside>
<main class="content">
<div class="breadcrumb">文档 <span>/</span> {{.Page.Group}} <span>/</span> {{.Page.Title}}</div>
<h1 class="page-title">{{.Page.Title}}</h1>
{{if .Page.Updated}}<div class="page-meta">更新于 {{.Page.Updated}}</div>{{end}}
<article class="md">
{{.Page.Content}}
</article>
<div class="pn-nav">
{{with .Page.Prev}}
<a class="pn prev" href="/docs/{{.Slug}}"><span class="pn-label">← 上一页</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn-empty"></span>{{end}}
{{with .Page.Next}}
<a class="pn next" href="/docs/{{.Slug}}"><span class="pn-label">下一页 →</span><span class="pn-title">{{.Title}}</span></a>
{{else}}<span class="pn-empty"></span>{{end}}
</div>
</main>
<aside class="toc">
<div class="toc-title">本页目录</div>
<nav class="toc-nav">
{{range .Page.TOC}}<a class="toc-l{{.Level}}" href="#{{.ID}}">{{.Text}}</a>
{{end}}
</nav>
</aside>
</div>
<script src="/docs/assets/docs.js" defer></script>
</body>
</html>`))
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)
}
-2
View File
@@ -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())
}
+1
View File
@@ -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
+2
View File
@@ -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=
+8 -1
View File
@@ -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
+208
View File
@@ -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(`<h([23]) id="([^"]+)"[^>]*>([^<]+)</h[23]>`)
)
// 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, "<h1"); i >= 0 {
if j := strings.Index(html[i:], "</h1>"); j >= 0 {
html = html[:i] + html[i+j+len("</h1>"):]
}
}
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
}