- 前台列表/详情/标签页标签统一 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
141 lines
4.0 KiB
Go
141 lines
4.0 KiB
Go
// 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())
|
||
}
|