feat(docs): 文档站重制 — 参照 opsxapi/opsxshell 体系,服务端渲染三栏布局
- 后端 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:
1 parent
c1f822fb10
commit
437ff56992
11 files changed
+854
-43
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user