refactor: Go 端收缩,移除 SPA 服务与 embed_static
- 删除 embed_static(内嵌 SPA 静态资源)与 handler/ssr_home.go、ssr_icons.go - router 移除前端页面服务,NoRoute 统一返回 404 JSON - seo.go 仅保留 robots.txt / sitemap.xml / favicon.ico - 数据聚合逻辑迁移到 handler/home_boot.go(/api/home-boot) - 启动日志改为提示前端由 Node 渲染服务器提供
This commit is contained in:
@@ -98,7 +98,11 @@ func (p *program) setup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
if cfg.DevMode {
|
||||||
|
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
|
||||||
|
} else {
|
||||||
|
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
|
||||||
|
}
|
||||||
log.Printf("工作目录: %s", cfg.WorkPath)
|
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||||
log.Printf("配置文件: %s", cfg.ConfigFile)
|
log.Printf("配置文件: %s", cfg.ConfigFile)
|
||||||
log.Printf("数据目录: %s", cfg.DataDir)
|
log.Printf("数据目录: %s", cfg.DataDir)
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
package embed_static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed static/*
|
|
||||||
var staticFS embed.FS
|
|
||||||
|
|
||||||
var (
|
|
||||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
|
||||||
spaBrandTitleFn func() string
|
|
||||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
|
||||||
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
|
||||||
func SetSPADocumentTitle(fn func() string) {
|
|
||||||
spaBrandTitleFn = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
|
||||||
func SetSPABrandingJSON(fn func() []byte) {
|
|
||||||
spaBrandJSONFn = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
|
||||||
func SetSPAFaviconURL(fn func() string) {
|
|
||||||
spaBrandFaviconFn = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
|
||||||
func SetupEmbed(r *gin.Engine) error {
|
|
||||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
|
||||||
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
|
||||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
|
||||||
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
|
||||||
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
|
||||||
c.Writer = w
|
|
||||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
|
|
||||||
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
|
|
||||||
r.GET("/stickers/*filepath", func(c *gin.Context) {
|
|
||||||
// stickers 不含哈希指纹,设置适中的缓存
|
|
||||||
c.Header("Cache-Control", "public, max-age=86400")
|
|
||||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
|
|
||||||
type cacheOnOKWriter struct {
|
|
||||||
gin.ResponseWriter
|
|
||||||
cacheControl string
|
|
||||||
wroteHeader bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *cacheOnOKWriter) WriteHeader(code int) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.wroteHeader = true
|
|
||||||
if code == http.StatusOK {
|
|
||||||
w.Header().Set("Cache-Control", w.cacheControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
|
||||||
func ServeSPA(c *gin.Context) {
|
|
||||||
ServeSPAWithMeta(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
|
|
||||||
func ServeSPANoIndex(c *gin.Context) {
|
|
||||||
title := ""
|
|
||||||
if spaBrandTitleFn != nil {
|
|
||||||
title = strings.TrimSpace(spaBrandTitleFn())
|
|
||||||
}
|
|
||||||
ServeSPAWithMeta(c, &SPAPageMeta{
|
|
||||||
Title: title,
|
|
||||||
Robots: "noindex,nofollow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSPARoute 判断是否应由 SPA 处理
|
|
||||||
func IsSPARoute(path string) bool {
|
|
||||||
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(path, "/api") ||
|
|
||||||
strings.HasPrefix(path, "/admin") ||
|
|
||||||
strings.HasPrefix(path, "/uploads") ||
|
|
||||||
strings.HasPrefix(path, "/media") ||
|
|
||||||
strings.HasPrefix(path, "/assets") ||
|
|
||||||
strings.HasPrefix(path, "/stickers") ||
|
|
||||||
strings.HasPrefix(path, "/oauth") ||
|
|
||||||
strings.HasPrefix(path, "/.well-known") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package embed_static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
|
|
||||||
|
|
||||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
|
|
||||||
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
|
|
||||||
type SPAPageMeta struct {
|
|
||||||
Title string // 完整 <title>
|
|
||||||
Description string
|
|
||||||
Keywords string // meta keywords
|
|
||||||
Canonical string
|
|
||||||
OGType string // 默认 website
|
|
||||||
OGImage string
|
|
||||||
SiteName string // og:site_name
|
|
||||||
Locale string // og:locale,默认 zh_CN
|
|
||||||
Robots string // 如 noindex,nofollow
|
|
||||||
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
|
||||||
Status int // HTTP 状态码,0 视为 200
|
|
||||||
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
|
||||||
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
|
||||||
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
|
||||||
status := http.StatusOK
|
|
||||||
if meta != nil && meta.Status != 0 {
|
|
||||||
status = meta.Status
|
|
||||||
}
|
|
||||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
|
||||||
if err != nil {
|
|
||||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data = applySPAPageMeta(data, meta)
|
|
||||||
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
|
|
||||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
c.Header("Pragma", "no-cache")
|
|
||||||
c.Data(status, "text/html; charset=utf-8", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
|
||||||
if meta == nil {
|
|
||||||
meta = &SPAPageMeta{}
|
|
||||||
}
|
|
||||||
|
|
||||||
title := strings.TrimSpace(meta.Title)
|
|
||||||
if title == "" && spaBrandTitleFn != nil {
|
|
||||||
title = strings.TrimSpace(spaBrandTitleFn())
|
|
||||||
}
|
|
||||||
if title != "" {
|
|
||||||
escaped := html.EscapeString(title)
|
|
||||||
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
|
||||||
var seo strings.Builder
|
|
||||||
writeMeta(&seo, "description", meta.Description)
|
|
||||||
writeMeta(&seo, "keywords", meta.Keywords)
|
|
||||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
|
||||||
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
|
||||||
}
|
|
||||||
if favicon := spaFaviconHref(); favicon != "" {
|
|
||||||
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
|
|
||||||
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
|
|
||||||
}
|
|
||||||
robots := strings.TrimSpace(meta.Robots)
|
|
||||||
if robots != "" {
|
|
||||||
writeMeta(&seo, "robots", robots)
|
|
||||||
}
|
|
||||||
|
|
||||||
ogType := strings.TrimSpace(meta.OGType)
|
|
||||||
if ogType == "" {
|
|
||||||
ogType = "website"
|
|
||||||
}
|
|
||||||
locale := strings.TrimSpace(meta.Locale)
|
|
||||||
if locale == "" {
|
|
||||||
locale = "zh_CN"
|
|
||||||
}
|
|
||||||
writeProp(&seo, "og:type", ogType)
|
|
||||||
writeProp(&seo, "og:site_name", meta.SiteName)
|
|
||||||
writeProp(&seo, "og:locale", locale)
|
|
||||||
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
|
||||||
writeProp(&seo, "og:description", meta.Description)
|
|
||||||
writeProp(&seo, "og:url", meta.Canonical)
|
|
||||||
writeProp(&seo, "og:image", meta.OGImage)
|
|
||||||
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
|
||||||
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
|
||||||
writeMetaName(&seo, "twitter:description", meta.Description)
|
|
||||||
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
|
||||||
|
|
||||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
|
||||||
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
|
||||||
seo.WriteString(`<script type="application/ld+json">`)
|
|
||||||
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
|
||||||
seo.WriteString(`</script>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if seo.Len() > 0 {
|
|
||||||
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
|
||||||
var boot strings.Builder
|
|
||||||
if s := spaBrandingBootScript(); s != "" {
|
|
||||||
boot.WriteString(s)
|
|
||||||
}
|
|
||||||
if s := spaHomeBootScript(meta.BootJSON); s != "" {
|
|
||||||
boot.WriteString(s)
|
|
||||||
}
|
|
||||||
if boot.Len() > 0 {
|
|
||||||
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if root := strings.TrimSpace(meta.RootHTML); root != "" {
|
|
||||||
data = injectSPARootHTML(data, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
|
|
||||||
func spaHomeBootScript(raw []byte) string {
|
|
||||||
raw = bytes.TrimSpace(raw)
|
|
||||||
if len(raw) == 0 || !json.Valid(raw) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
|
||||||
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
|
|
||||||
func injectSPARootHTML(data []byte, rootHTML string) []byte {
|
|
||||||
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
|
||||||
func spaBrandingBootScript() string {
|
|
||||||
if spaBrandJSONFn == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw := bytes.TrimSpace(spaBrandJSONFn())
|
|
||||||
if len(raw) == 0 || !json.Valid(raw) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
|
||||||
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMeta(b *strings.Builder, name, content string) {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMetaName(b *strings.Builder, name, content string) {
|
|
||||||
writeMeta(b, name, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeProp(b *strings.Builder, prop, content string) {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twitterCard(ogImage string) string {
|
|
||||||
if strings.TrimSpace(ogImage) != "" {
|
|
||||||
return "summary_large_image"
|
|
||||||
}
|
|
||||||
return "summary"
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmpty(vals ...string) string {
|
|
||||||
for _, v := range vals {
|
|
||||||
if s := strings.TrimSpace(v); s != "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
|
||||||
func spaFaviconHref() string {
|
|
||||||
if spaBrandFaviconFn == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(spaBrandFaviconFn())
|
|
||||||
}
|
|
||||||
443
handler/seo.go
443
handler/seo.go
@@ -1,28 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
const seoSitemapLimit = 5000
|
||||||
seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
seoDescMax = 160
|
|
||||||
seoPrerenderMax = 4000
|
|
||||||
seoSitemapLimit = 5000
|
|
||||||
)
|
|
||||||
|
|
||||||
// RobotsTxt 搜索引擎抓取规则
|
// RobotsTxt 搜索引擎抓取规则
|
||||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||||
@@ -157,342 +144,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServePublicSPA 公开页入口:人机统一响应,不再按 User-Agent 分叉。
|
|
||||||
// - 首页 / 板块:完整首屏 SSR(serveFeedDocument)
|
|
||||||
// - 其余公开页:干净 SPA + <head> meta
|
|
||||||
// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
|
|
||||||
func (h *Handlers) ServePublicSPA(c *gin.Context) {
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
|
|
||||||
brand := h.Settings.SiteBranding()
|
|
||||||
base := h.publicBaseURL(c)
|
|
||||||
siteName := strings.TrimSpace(brand.Name)
|
|
||||||
if siteName == "" {
|
|
||||||
siteName = "姜十三论坛"
|
|
||||||
}
|
|
||||||
defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage())
|
|
||||||
siteKeywords := brand.MetaKeywords()
|
|
||||||
permalink := h.Settings.Permalink()
|
|
||||||
|
|
||||||
// 旧版 /?board=id → 规范板块路径
|
|
||||||
if path == "/" || path == "" {
|
|
||||||
if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
|
|
||||||
target := service.QueryBoardHome(uint(boardID), permalink)
|
|
||||||
if q := c.Request.URL.RawQuery; q != "" {
|
|
||||||
// 保留 sort/keyword 等 query,去掉 board
|
|
||||||
vals := c.Request.URL.Query()
|
|
||||||
vals.Del("board")
|
|
||||||
if rest := vals.Encode(); rest != "" {
|
|
||||||
target += "?" + rest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusMovedPermanently, target)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 板块首页(含可选伪静态后缀)
|
|
||||||
if bm := permalink.MatchBoardPath(path); bm.OK {
|
|
||||||
if bm.NeedsCanonicalRedirect(path) {
|
|
||||||
c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
board, err := h.Board.GetByID(bm.ID)
|
|
||||||
if err != nil {
|
|
||||||
h.serveNotFound(c, base, siteName, siteKeywords, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
desc := strings.TrimSpace(board.Description)
|
|
||||||
if desc == "" {
|
|
||||||
desc = brand.MetaDescription()
|
|
||||||
}
|
|
||||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
|
||||||
Title: pageTitle(board.Name, siteName),
|
|
||||||
Description: service.TruncateRunes(desc, seoDescMax),
|
|
||||||
Keywords: service.JoinSEOKeywords(board.Name, siteKeywords),
|
|
||||||
Canonical: service.AbsoluteURL(base, bm.Canonical),
|
|
||||||
OGType: "website",
|
|
||||||
OGImage: defaultImage,
|
|
||||||
}, siteName, siteKeywords)
|
|
||||||
h.serveFeedDocument(c, meta, board.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 帖子详情(含可选伪静态后缀)
|
|
||||||
if pm := permalink.MatchPostPath(path); pm.OK {
|
|
||||||
if pm.NeedsCanonicalRedirect(path) {
|
|
||||||
c.Redirect(http.StatusMovedPermanently, pm.Canonical)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
post, err := h.Post.FindByID(pm.ID)
|
|
||||||
if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
|
|
||||||
h.serveNotFound(c, base, siteName, siteKeywords, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords)
|
|
||||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户主页
|
|
||||||
if um := permalink.MatchUserPath(path); um.OK {
|
|
||||||
if um.NeedsCanonicalRedirect(path) {
|
|
||||||
c.Redirect(http.StatusMovedPermanently, um.Canonical)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.User.GetByID(um.ID)
|
|
||||||
if err != nil || user.Banned {
|
|
||||||
h.serveNotFound(c, base, siteName, siteKeywords, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义单页
|
|
||||||
if pg := permalink.MatchPagePath(path); pg.OK {
|
|
||||||
if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") {
|
|
||||||
c.Redirect(http.StatusMovedPermanently, pg.Canonical)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
|
|
||||||
if err != nil {
|
|
||||||
h.serveNotFound(c, base, siteName, siteKeywords, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
desc := service.ExcerptFromHTML(page.Content, seoDescMax)
|
|
||||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
|
||||||
Title: pageTitle(page.Title, siteName),
|
|
||||||
Description: desc,
|
|
||||||
Keywords: service.JoinSEOKeywords(page.Title, siteKeywords),
|
|
||||||
Canonical: service.AbsoluteURL(base, pg.Canonical),
|
|
||||||
OGType: "article",
|
|
||||||
OGImage: defaultImage,
|
|
||||||
}, siteName, siteKeywords)
|
|
||||||
embed_static.ServeSPAWithMeta(c, meta)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未知路径 → 404
|
|
||||||
if !isKnownPublicPath(path) {
|
|
||||||
h.serveNotFound(c, base, siteName, siteKeywords, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其余已知路由:SPA + head meta;首页/Feed 返回完整首屏 HTML
|
|
||||||
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
|
|
||||||
if path == "/" || path == "" {
|
|
||||||
h.serveFeedDocument(c, meta, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
embed_static.ServeSPAWithMeta(c, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string) {
|
|
||||||
embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPageMeta {
|
|
||||||
return attachSiteSEO(&embed_static.SPAPageMeta{
|
|
||||||
Title: pageTitle("页面不存在", siteName),
|
|
||||||
Description: "您访问的页面不存在或已删除",
|
|
||||||
Canonical: service.AbsoluteURL(base, path),
|
|
||||||
OGType: "website",
|
|
||||||
Robots: "noindex,follow",
|
|
||||||
Status: http.StatusNotFound,
|
|
||||||
}, siteName, keywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
|
|
||||||
func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *embed_static.SPAPageMeta {
|
|
||||||
if meta == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
meta.SiteName = strings.TrimSpace(siteName)
|
|
||||||
if strings.TrimSpace(meta.Keywords) == "" {
|
|
||||||
meta.Keywords = strings.TrimSpace(keywords)
|
|
||||||
}
|
|
||||||
meta.Locale = "zh_CN"
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKnownPublicPath(path string) bool {
|
|
||||||
switch path {
|
|
||||||
case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if seoPostEditRe.MatchString(path) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
permalink := service.PermalinkConfig{}
|
|
||||||
if permalink.MatchBoardPath(path).OK {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if permalink.MatchPagePath(path).OK {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.SiteBranding, base, siteName, defaultImage string) *embed_static.SPAPageMeta {
|
|
||||||
siteTitle := brand.DocumentTitle()
|
|
||||||
homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax)
|
|
||||||
siteKeywords := brand.MetaKeywords()
|
|
||||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
|
||||||
Title: siteTitle,
|
|
||||||
Description: homeDesc,
|
|
||||||
Keywords: siteKeywords,
|
|
||||||
Canonical: service.AbsoluteURL(base, pathWithQuery(c)),
|
|
||||||
OGType: "website",
|
|
||||||
OGImage: defaultImage,
|
|
||||||
}, siteName, siteKeywords)
|
|
||||||
|
|
||||||
if isNoIndexPath(path) {
|
|
||||||
meta.Robots = "noindex,nofollow"
|
|
||||||
meta.Title = pageTitle(pathLabel(path), siteName)
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == "/" || path == "" {
|
|
||||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
|
||||||
if boardID > 0 {
|
|
||||||
if board, err := h.Board.GetByID(uint(boardID)); err == nil {
|
|
||||||
desc := strings.TrimSpace(board.Description)
|
|
||||||
if desc == "" {
|
|
||||||
desc = brand.MetaDescription()
|
|
||||||
}
|
|
||||||
meta.Title = pageTitle(board.Name, siteName)
|
|
||||||
meta.Description = service.TruncateRunes(desc, seoDescMax)
|
|
||||||
meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink()))
|
|
||||||
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords)
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
// 无效板块 id:仍显示首页,但可标记 noindex
|
|
||||||
meta.Robots = "noindex,follow"
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
meta.JSONLD = mustJSON(map[string]any{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
"name": siteName,
|
|
||||||
"description": meta.Description,
|
|
||||||
"url": service.AbsoluteURL(base, "/"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == "/projects" {
|
|
||||||
meta.Title = pageTitle("项目", siteName)
|
|
||||||
meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
|
|
||||||
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == "/links" {
|
|
||||||
meta.Title = pageTitle("友情链接", siteName)
|
|
||||||
meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
|
|
||||||
meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta {
|
|
||||||
permalink := h.Settings.Permalink()
|
|
||||||
content := service.RedactGatedPostHTML(post.Content)
|
|
||||||
plain := post.ContentPlain
|
|
||||||
if plain == "" {
|
|
||||||
plain = service.StripHTMLForSearch(content)
|
|
||||||
}
|
|
||||||
desc := service.TruncateRunes(plain, seoDescMax)
|
|
||||||
author := service.DisplayName(&post.User)
|
|
||||||
canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID))
|
|
||||||
ogImage := service.AbsoluteURL(base, service.FirstImageURL(content))
|
|
||||||
if ogImage == "" {
|
|
||||||
ogImage = service.AbsoluteURL(base, post.User.Avatar)
|
|
||||||
}
|
|
||||||
if ogImage == "" {
|
|
||||||
ogImage = defaultImage
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonld := map[string]any{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "DiscussionForumPosting",
|
|
||||||
"headline": post.Title,
|
|
||||||
"description": desc,
|
|
||||||
"datePublished": post.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
"dateModified": post.UpdatedAt.UTC().Format(time.RFC3339),
|
|
||||||
"url": canonical,
|
|
||||||
"mainEntityOfPage": canonical,
|
|
||||||
"author": map[string]any{
|
|
||||||
"@type": "Person",
|
|
||||||
"name": author,
|
|
||||||
"url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)),
|
|
||||||
},
|
|
||||||
"interactionStatistic": map[string]any{
|
|
||||||
"@type": "InteractionCounter",
|
|
||||||
"interactionType": "https://schema.org/ViewAction",
|
|
||||||
"userInteractionCount": post.ViewCount,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if post.Board.Name != "" {
|
|
||||||
jsonld["articleSection"] = post.Board.Name
|
|
||||||
}
|
|
||||||
if ogImage != "" {
|
|
||||||
jsonld["image"] = []string{ogImage}
|
|
||||||
}
|
|
||||||
body := service.TruncateRunes(plain, seoPrerenderMax)
|
|
||||||
if body != "" {
|
|
||||||
jsonld["articleBody"] = body
|
|
||||||
}
|
|
||||||
|
|
||||||
return &embed_static.SPAPageMeta{
|
|
||||||
Title: pageTitle(post.Title, siteName),
|
|
||||||
Description: desc,
|
|
||||||
Canonical: canonical,
|
|
||||||
OGType: "article",
|
|
||||||
OGImage: ogImage,
|
|
||||||
JSONLD: mustJSON(jsonld),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model.User) *embed_static.SPAPageMeta {
|
|
||||||
permalink := h.Settings.Permalink()
|
|
||||||
name := service.DisplayName(user)
|
|
||||||
desc := strings.TrimSpace(user.Signature)
|
|
||||||
if desc == "" {
|
|
||||||
desc = name + " 的主页"
|
|
||||||
}
|
|
||||||
desc = service.TruncateRunes(desc, seoDescMax)
|
|
||||||
canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID))
|
|
||||||
ogImage := service.AbsoluteURL(base, user.Avatar)
|
|
||||||
if ogImage == "" {
|
|
||||||
ogImage = defaultImage
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonld := map[string]any{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "ProfilePage",
|
|
||||||
"url": canonical,
|
|
||||||
"mainEntity": map[string]any{
|
|
||||||
"@type": "Person",
|
|
||||||
"name": name,
|
|
||||||
"description": desc,
|
|
||||||
"url": canonical,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if ogImage != "" {
|
|
||||||
jsonld["mainEntity"].(map[string]any)["image"] = ogImage
|
|
||||||
}
|
|
||||||
|
|
||||||
return &embed_static.SPAPageMeta{
|
|
||||||
Title: pageTitle(name+" 的主页", siteName),
|
|
||||||
Description: desc,
|
|
||||||
Canonical: canonical,
|
|
||||||
OGType: "profile",
|
|
||||||
OGImage: ogImage,
|
|
||||||
JSONLD: mustJSON(jsonld),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
||||||
return h.Settings.SitePublicBaseURL(requestOrigin(c))
|
return h.Settings.SitePublicBaseURL(requestOrigin(c))
|
||||||
}
|
}
|
||||||
@@ -516,98 +167,6 @@ func requestOrigin(c *gin.Context) string {
|
|||||||
return proto + "://" + host
|
return proto + "://" + host
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathWithQuery(c *gin.Context) string {
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
|
|
||||||
if q := c.Request.URL.RawQuery; q != "" {
|
|
||||||
if path == "/" {
|
|
||||||
board := c.Query("board")
|
|
||||||
if board != "" {
|
|
||||||
_ = permalink
|
|
||||||
return service.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
|
|
||||||
}
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return path + "?" + q
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func preserveQueryExceptBoard(c *gin.Context) string {
|
|
||||||
vals := c.Request.URL.Query()
|
|
||||||
vals.Del("board")
|
|
||||||
if rest := vals.Encode(); rest != "" {
|
|
||||||
return "?" + rest
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUintOrZero(s string) uint64 {
|
|
||||||
n, _ := strconv.ParseUint(s, 10, 64)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNoIndexPath(path string) bool {
|
|
||||||
switch {
|
|
||||||
case path == "/login", path == "/register", path == "/compose",
|
|
||||||
path == "/profile", path == "/favorites":
|
|
||||||
return true
|
|
||||||
case strings.HasPrefix(path, "/admin"):
|
|
||||||
return true
|
|
||||||
case strings.HasSuffix(path, "/edit"):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathLabel(path string) string {
|
|
||||||
switch {
|
|
||||||
case path == "/login":
|
|
||||||
return "登录"
|
|
||||||
case path == "/register":
|
|
||||||
return "注册"
|
|
||||||
case path == "/compose":
|
|
||||||
return "发帖"
|
|
||||||
case path == "/profile":
|
|
||||||
return "个人中心"
|
|
||||||
case path == "/favorites":
|
|
||||||
return "我的收藏"
|
|
||||||
case strings.HasSuffix(path, "/edit"):
|
|
||||||
return "编辑帖子"
|
|
||||||
case strings.HasPrefix(path, "/admin"):
|
|
||||||
return "管理后台"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pageTitle(page, siteName string) string {
|
|
||||||
page = strings.TrimSpace(page)
|
|
||||||
siteName = strings.TrimSpace(siteName)
|
|
||||||
switch {
|
|
||||||
case page == "" && siteName == "":
|
|
||||||
return "姜十三论坛"
|
|
||||||
case page == "":
|
|
||||||
return siteName
|
|
||||||
case siteName == "":
|
|
||||||
return page
|
|
||||||
default:
|
|
||||||
return page + " - " + siteName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJSON(v any) string {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func xmlEscape(s string) string {
|
func xmlEscape(s string) string {
|
||||||
r := strings.NewReplacer(
|
r := strings.NewReplacer(
|
||||||
`&`, "&",
|
`&`, "&",
|
||||||
|
|||||||
1100
handler/ssr_home.go
1100
handler/ssr_home.go
File diff suppressed because it is too large
Load Diff
@@ -1,361 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取)
|
|
||||||
|
|
||||||
func ssrSVG(size int, paths ...string) string {
|
|
||||||
return ssrSVGWithClass(size, "", paths...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrSVGWithClass(size int, className string, paths ...string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
|
|
||||||
b.WriteString(strconv.Itoa(size))
|
|
||||||
b.WriteString(`" height="`)
|
|
||||||
b.WriteString(strconv.Itoa(size))
|
|
||||||
b.WriteString(`" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`)
|
|
||||||
if className != "" {
|
|
||||||
b.WriteString(` class="`)
|
|
||||||
b.WriteString(className)
|
|
||||||
b.WriteString(`"`)
|
|
||||||
}
|
|
||||||
b.WriteString(` aria-hidden="true">`)
|
|
||||||
for _, p := range paths {
|
|
||||||
b.WriteString(p)
|
|
||||||
}
|
|
||||||
b.WriteString(`</svg>`)
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconSearch() string {
|
|
||||||
return ssrSVGWithClass(16, "header-search-icon",
|
|
||||||
`<path d="m21 21-4.34-4.34"/>`,
|
|
||||||
`<circle cx="11" cy="11" r="8"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconSliders() string {
|
|
||||||
return ssrSVG(15,
|
|
||||||
`<path d="M10 5H3"/>`,
|
|
||||||
`<path d="M12 19H3"/>`,
|
|
||||||
`<path d="M14 3v4"/>`,
|
|
||||||
`<path d="M16 17v4"/>`,
|
|
||||||
`<path d="M21 12h-9"/>`,
|
|
||||||
`<path d="M21 19h-5"/>`,
|
|
||||||
`<path d="M21 5h-7"/>`,
|
|
||||||
`<path d="M8 10v4"/>`,
|
|
||||||
`<path d="M8 12H3"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconPlus() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<path d="M5 12h14"/>`,
|
|
||||||
`<path d="M12 5v14"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconMenu() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M4 5h16"/>`,
|
|
||||||
`<path d="M4 12h16"/>`,
|
|
||||||
`<path d="M4 19h16"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconMoon() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconSun() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<circle cx="12" cy="12" r="4"/>`,
|
|
||||||
`<path d="M12 2v2"/>`,
|
|
||||||
`<path d="M12 20v2"/>`,
|
|
||||||
`<path d="m4.93 4.93 1.41 1.41"/>`,
|
|
||||||
`<path d="m17.66 17.66 1.41 1.41"/>`,
|
|
||||||
`<path d="M2 12h2"/>`,
|
|
||||||
`<path d="M20 12h2"/>`,
|
|
||||||
`<path d="m6.34 17.66-1.41 1.41"/>`,
|
|
||||||
`<path d="m19.07 4.93-1.41 1.41"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconMail() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/>`,
|
|
||||||
`<rect x="2" y="4" width="20" height="16" rx="2"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconHome() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>`,
|
|
||||||
`<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconStar() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconFolderGit() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M18 19a5 5 0 0 1-5-5v8"/>`,
|
|
||||||
`<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/>`,
|
|
||||||
`<circle cx="13" cy="12" r="2"/>`,
|
|
||||||
`<circle cx="20" cy="19" r="2"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconLink2() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M9 17H7A5 5 0 0 1 7 7h2"/>`,
|
|
||||||
`<path d="M15 7h2a5 5 0 1 1 0 10h-2"/>`,
|
|
||||||
`<line x1="8" x2="16" y1="12" y2="12"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrWidgetIconLink2() string {
|
|
||||||
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--links",
|
|
||||||
`<path d="M9 17H7A5 5 0 0 1 7 7h2"/>`,
|
|
||||||
`<path d="M15 7h2a5 5 0 1 1 0 10h-2"/>`,
|
|
||||||
`<line x1="8" x2="16" y1="12" y2="12"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconEarth() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/>`,
|
|
||||||
`<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/>`,
|
|
||||||
`<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/>`,
|
|
||||||
`<circle cx="12" cy="12" r="10"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrWidgetIconEarth() string {
|
|
||||||
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--showcase",
|
|
||||||
`<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/>`,
|
|
||||||
`<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/>`,
|
|
||||||
`<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/>`,
|
|
||||||
`<circle cx="12" cy="12" r="10"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconFileText() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>`,
|
|
||||||
`<path d="M14 2v5a1 1 0 0 0 1 1h5"/>`,
|
|
||||||
`<path d="M10 9H8"/>`,
|
|
||||||
`<path d="M16 13H8"/>`,
|
|
||||||
`<path d="M16 17H8"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconLayoutDashboard() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<rect width="7" height="9" x="3" y="3" rx="1"/>`,
|
|
||||||
`<rect width="7" height="5" x="14" y="3" rx="1"/>`,
|
|
||||||
`<rect width="7" height="9" x="14" y="12" rx="1"/>`,
|
|
||||||
`<rect width="7" height="5" x="3" y="16" rx="1"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconMessageCircle() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrWidgetIconMessageCircle() string {
|
|
||||||
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--notice",
|
|
||||||
`<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconClock() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<circle cx="12" cy="12" r="10"/>`,
|
|
||||||
`<path d="M12 6v6l4 2"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconBadgeCheck() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>`,
|
|
||||||
`<path d="m9 12 2 2 4-4"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconTags() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z"/>`,
|
|
||||||
`<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193"/>`,
|
|
||||||
`<circle cx="10.5" cy="6.5" r=".5" fill="currentColor"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrWidgetIconTags() string {
|
|
||||||
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--tags",
|
|
||||||
`<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z"/>`,
|
|
||||||
`<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193"/>`,
|
|
||||||
`<circle cx="10.5" cy="6.5" r=".5" fill="currentColor"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconUserPlus() string {
|
|
||||||
return ssrSVG(16,
|
|
||||||
`<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>`,
|
|
||||||
`<circle cx="9" cy="7" r="4"/>`,
|
|
||||||
`<line x1="19" x2="19" y1="8" y2="14"/>`,
|
|
||||||
`<line x1="22" x2="16" y1="11" y2="11"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrWidgetIconUserPlus() string {
|
|
||||||
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--users",
|
|
||||||
`<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>`,
|
|
||||||
`<circle cx="9" cy="7" r="4"/>`,
|
|
||||||
`<line x1="19" x2="19" y1="8" y2="14"/>`,
|
|
||||||
`<line x1="22" x2="16" y1="11" y2="11"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconCalendarCheck() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M8 2v4"/>`,
|
|
||||||
`<path d="M16 2v4"/>`,
|
|
||||||
`<rect width="18" height="18" x="3" y="4" rx="2"/>`,
|
|
||||||
`<path d="M3 10h18"/>`,
|
|
||||||
`<path d="m9 16 2 2 4-4"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconCheck() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<path d="M20 6 9 17l-5-5"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconGift() string {
|
|
||||||
return ssrSVG(15,
|
|
||||||
`<path d="M12 7v14"/>`,
|
|
||||||
`<path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8"/>`,
|
|
||||||
`<path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5"/>`,
|
|
||||||
`<rect x="3" y="7" width="18" height="4" rx="1"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrIconPanelRight() string {
|
|
||||||
return ssrSVG(18,
|
|
||||||
`<rect width="18" height="18" x="3" y="3" rx="2"/>`,
|
|
||||||
`<path d="M15 3v18"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons)
|
|
||||||
var ssrBoardIconInner = map[string]string{
|
|
||||||
"code-2": `<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>`,
|
|
||||||
"coffee": `<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>`,
|
|
||||||
"help-circle": `<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`,
|
|
||||||
"message-square": `<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>`,
|
|
||||||
"lightbulb": `<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>`,
|
|
||||||
"book-open": `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`,
|
|
||||||
"gamepad-2": `<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>`,
|
|
||||||
"palette": `<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>`,
|
|
||||||
"music": `<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>`,
|
|
||||||
"camera": `<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>`,
|
|
||||||
"heart": `<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>`,
|
|
||||||
"zap": `<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>`,
|
|
||||||
"globe": `<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>`,
|
|
||||||
"users": `<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>`,
|
|
||||||
"briefcase": `<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>`,
|
|
||||||
"graduation-cap": `<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>`,
|
|
||||||
"shopping-bag": `<path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/>`,
|
|
||||||
"map-pin": `<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>`,
|
|
||||||
"megaphone": `<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/><path d="M8 6v8"/>`,
|
|
||||||
"flame": `<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>`,
|
|
||||||
"star": `<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
|
|
||||||
"folder": `<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>`,
|
|
||||||
"wrench": `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>`,
|
|
||||||
"cpu": `<path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)
|
|
||||||
var ssrBoardDefaultIcons = []string{
|
|
||||||
"code-2",
|
|
||||||
"coffee",
|
|
||||||
"help-circle",
|
|
||||||
"message-square",
|
|
||||||
"lightbulb",
|
|
||||||
"book-open",
|
|
||||||
"gamepad-2",
|
|
||||||
"palette",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致)
|
|
||||||
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
|
|
||||||
key := strings.TrimSpace(strings.ToLower(icon))
|
|
||||||
inner, ok := ssrBoardIconInner[key]
|
|
||||||
if !ok || inner == "" {
|
|
||||||
if themeIndex < 0 {
|
|
||||||
themeIndex = 0
|
|
||||||
}
|
|
||||||
key = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
|
|
||||||
inner = ssrBoardIconInner[key]
|
|
||||||
}
|
|
||||||
return ssrSVGWithClass(18, className, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRRelativeTime 与前端 formatTime 同规则
|
|
||||||
func formatSSRRelativeTime(t time.Time) string {
|
|
||||||
if t.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
diffSec := now.Sub(t).Seconds()
|
|
||||||
if diffSec < 0 {
|
|
||||||
diffSec = 0
|
|
||||||
}
|
|
||||||
if diffSec < 60 {
|
|
||||||
return "刚刚"
|
|
||||||
}
|
|
||||||
if diffSec < 3600 {
|
|
||||||
return strconv.Itoa(int(diffSec/60)) + "分钟前"
|
|
||||||
}
|
|
||||||
if diffSec < 86400 {
|
|
||||||
return strconv.Itoa(int(diffSec/3600)) + "小时前"
|
|
||||||
}
|
|
||||||
diffDay := int(diffSec / 86400)
|
|
||||||
if diffDay < 30 {
|
|
||||||
return strconv.Itoa(diffDay) + "天前"
|
|
||||||
}
|
|
||||||
if t.Year() == now.Year() {
|
|
||||||
return strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
}
|
|
||||||
return strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm)
|
|
||||||
func formatSSRShortDateTime(t time.Time) string {
|
|
||||||
if t.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
local := t.Local()
|
|
||||||
pad := func(n int) string {
|
|
||||||
if n < 10 {
|
|
||||||
return "0" + strconv.Itoa(n)
|
|
||||||
}
|
|
||||||
return strconv.Itoa(n)
|
|
||||||
}
|
|
||||||
return pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/handler"
|
"git.iioio.com/freefire/jiang13-forum/handler"
|
||||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
@@ -31,13 +29,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
monitorSvc.StartBackground()
|
monitorSvc.StartBackground()
|
||||||
r.Use(middleware.AccessLogMiddleware(monitorSvc))
|
r.Use(middleware.AccessLogMiddleware(monitorSvc))
|
||||||
|
|
||||||
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供
|
// 前端由 Node 渲染服务器提供(对外 :3000),本服务仅提供 API;
|
||||||
// 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000)
|
// dev 模式前端由 Vite 开发服务器(:5173)提供,/api 经 Vite proxy 转发到本服务
|
||||||
if !cfg.DevMode {
|
if cfg.DevMode {
|
||||||
if err := embed_static.SetupEmbed(r); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n")
|
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +40,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
if cfg.CommunityHub {
|
if cfg.CommunityHub {
|
||||||
fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n")
|
fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n")
|
||||||
}
|
}
|
||||||
// SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案
|
|
||||||
embed_static.SetSPADocumentTitle(func() string {
|
|
||||||
return settingsSvc.SiteBranding().DocumentTitle()
|
|
||||||
})
|
|
||||||
embed_static.SetSPABrandingJSON(func() []byte {
|
|
||||||
b, err := json.Marshal(settingsSvc.SiteBranding())
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
})
|
|
||||||
embed_static.SetSPAFaviconURL(func() string {
|
|
||||||
return settingsSvc.SiteBranding().Favicon
|
|
||||||
})
|
|
||||||
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
||||||
userSvc := service.NewUserService(filter, settingsSvc)
|
userSvc := service.NewUserService(filter, settingsSvc)
|
||||||
boardSvc := service.NewBoardService()
|
boardSvc := service.NewBoardService()
|
||||||
@@ -138,6 +118,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/me", h.APIMe)
|
pubAPI.GET("/me", h.APIMe)
|
||||||
pubAPI.GET("/boards", h.APIBoards)
|
pubAPI.GET("/boards", h.APIBoards)
|
||||||
pubAPI.GET("/stats", h.APIStats)
|
pubAPI.GET("/stats", h.APIStats)
|
||||||
|
// SSR 聚合数据(Vike +data 使用,Node 内部调用)
|
||||||
|
pubAPI.GET("/home-boot", h.APIHomeBoot)
|
||||||
pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
|
pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
|
||||||
pubAPI.GET("/community/showcase", h.APICommunityShowcase)
|
pubAPI.GET("/community/showcase", h.APICommunityShowcase)
|
||||||
pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview)
|
pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview)
|
||||||
@@ -300,48 +282,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
|
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台管理页面由 React SPA 渲染(JSON API 见上方 /api/admin)
|
// 非 API 路径由 Node 渲染服务器(生产)或 Vite 开发服务器(dev)处理,
|
||||||
// dev 模式下前端由 Vite 提供,后台页面路由不在此注册
|
// 本服务不再服务前端页面,未知路径一律 404 JSON
|
||||||
if !cfg.DevMode {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
admin := r.Group("/admin")
|
if cfg.DevMode {
|
||||||
{
|
|
||||||
admin.GET("/login", func(c *gin.Context) {
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
|
||||||
})
|
|
||||||
|
|
||||||
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
|
|
||||||
{
|
|
||||||
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
|
||||||
for _, page := range []string{
|
|
||||||
"dashboard", "boards", "pages", "links", "community", "posts",
|
|
||||||
"comments", "reports", "users", "badges", "media", "monitor", "settings",
|
|
||||||
} {
|
|
||||||
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// React SPA 入口
|
|
||||||
// dev 模式:前端由 Vite(:5173) 提供,非 API 请求返回开发提示
|
|
||||||
// 生产模式:注入 SEO meta / JSON-LD / 预渲染摘要
|
|
||||||
if cfg.DevMode {
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
"error": "dev 模式下前端由 Vite 提供,请访问 http://localhost:5173",
|
"error": "dev 模式下前端由 Vite 提供,请访问 http://localhost:5173",
|
||||||
})
|
})
|
||||||
})
|
return
|
||||||
} else {
|
}
|
||||||
// 文档请求也解析登录 cookie,首页 SSR 才能输出头像/签到/收藏等完整壳
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
r.GET("/", authMW.OptionalAuth(), h.ServePublicSPA)
|
})
|
||||||
r.NoRoute(authMW.OptionalAuth(), func(c *gin.Context) {
|
|
||||||
if embed_static.IsSPARoute(c.Request.URL.Path) {
|
|
||||||
h.ServePublicSPA(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user