Files
pure-note/internal/webui/webui.go
T
wangairnan 5bac272f6d feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选
  (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条,
  标签入口收敛至顶部「标签」页
- 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef),
  首页条目左侧显示封面缩略图,无图条目保持纯文字
- favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html,
  前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效;
  设置页「重置默认」清空 Logo 后回退内置 /favicon.svg
- 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
2026-09-09 11:51:06 +08:00

141 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package webui go:embed 前端产物 + SPA fallback + index.html 元信息注入。
// embed 只能引用本包目录树内文件:产物由 Makefile `sync-assets` 拷贝至
// internal/webui/dist(§8.3-1)。
package webui
import (
"bytes"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
// Meta index.html 模板数据。所有字段由服务端填充(含默认回退值),
// 经 html/template 自动转义注入(§8.3-4,防标题内容打断标签结构)。
type Meta struct {
Title string
Description string
OGTitle string
OGDescription string
OGType string
OGURL string
OGImage string
SiteName string
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
}
// UI 静态资源服务。
type UI struct {
assets fs.FS
indexTmpl *template.Template
hasIndex bool
}
// New 从内嵌产物构造 UI。dist 缺 index.html(M0 占位)时仍可构造,
// HTML 路径回退到占位提示页。
func New() (*UI, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, err
}
return newFromFS(sub)
}
// newFromFS 供测试注入任意 FS。
func newFromFS(fsys fs.FS) (*UI, error) {
u := &UI{assets: fsys}
index, err := fs.ReadFile(fsys, "index.html")
if err == nil {
tmpl, err := template.New("index").Parse(string(index))
if err != nil {
return nil, fmt.Errorf("解析 index.html 模板失败: %w", err)
}
u.indexTmpl = tmpl
u.hasIndex = true
}
return u, nil
}
var placeholderTmpl = template.Must(template.New("ph").Parse(
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>Pure Note</title></head>
<body><h1>Pure Note</h1><p>前端静态资源尚未构建:请在仓库根目录执行 <code>make build</code>(会先构建 web/dist 并拷贝到 internal/webui/dist)。</p></body></html>`))
// Handler 返回 SPA 资源服务:
// 命中文件 → 按指纹长缓存;未命中且无扩展名 → index.html(注入 meta)。
func (u *UI) Handler(meta func(*http.Request) Meta) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if p == "" || p == "." {
// 站点根 → index.html
u.serveIndex(w, r, meta)
return
}
if st, err := fs.Stat(u.assets, p); err == nil && !st.IsDir() {
// index.html 直接命中(显式请求)也走模板渲染
if p == "index.html" {
u.serveIndex(w, r, meta)
return
}
f, err := u.assets.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
rs, ok := f.(io.ReadSeeker)
if !ok {
http.NotFound(w, r)
return
}
// Vite 产物按内容 hash 命名 → 指纹天然隔离新旧版本(§8.3-5)
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
http.ServeContent(w, r, path.Base(p), time.Time{}, rs)
return
}
// 带扩展名的未命中路径(如 /assets/missing.js)→ 404,不回退 HTML
if strings.Contains(path.Base(p), ".") {
http.NotFound(w, r)
return
}
// SPA 深链(/notes/:slug 等)→ index.html
u.serveIndex(w, r, meta)
})
}
func (u *UI) serveIndex(w http.ResponseWriter, r *http.Request, metaFn func(*http.Request) Meta) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if !u.hasIndex {
w.WriteHeader(http.StatusServiceUnavailable)
_ = placeholderTmpl.Execute(w, nil)
return
}
var m Meta
if metaFn != nil {
m = metaFn(r)
}
var buf bytes.Buffer
if err := u.indexTmpl.Execute(&buf, m); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_, _ = w.Write(buf.Bytes())
}