增加租户官网模块

This commit is contained in:
2026-08-19 23:34:28 +08:00
parent ec5cf96d0b
commit 14f2e8b5c1
28 changed files with 3812 additions and 7 deletions
+201
View File
@@ -0,0 +1,201 @@
package tagengine
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// RenderCtx 渲染上下文,携带租户与当前路由信息
type RenderCtx struct {
Tid uint64 // 租户 ID
ArticleID uint64 // 文章详情页的文章 ID
PagePath string // 单页路由的 path
Page int // 列表分页页码
PageSize int // 列表分页大小(pagelist 与列表标签保持一致)
}
// Provider 循环标签数据提供器:返回若干行数据,每行为 字段名->值
type Provider func(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error)
// SelfCloseHandler 自闭合标签处理器:直接输出 HTML 片段
type SelfCloseHandler func(tid uint64, params map[string]string, ctx *RenderCtx) (string, error)
// GlobalProvider 全局标签提供器:返回 标签名->值 映射
type GlobalProvider func(tid uint64, ctx *RenderCtx) (map[string]string, error)
var (
blockProviders = map[string]Provider{}
selfCloseHandlers = map[string]SelfCloseHandler{}
globalProvider GlobalProvider
)
// RegisterBlock 注册循环标签({yz:name}...{/yz:name})
func RegisterBlock(name string, p Provider) { blockProviders[name] = p }
// RegisterSelfClose 注册自闭合标签({yz:name ... /})
func RegisterSelfClose(name string, h SelfCloseHandler) { selfCloseHandlers[name] = h }
// RegisterGlobal 注册全局标签提供器({yz:xxx} 直接替换)
func RegisterGlobal(p GlobalProvider) { globalProvider = p }
var (
// 最内层块标签(内部不再嵌套块标签),反复匹配直至收敛
reBlock = regexp.MustCompile(`(?s)\{yz:([a-zA-Z]+)((?:\s+[a-zA-Z_][\w-]*\s*=\s*"[^"]*")*)\s*\}((?:(?!\{/?yz:).)*?)\{/yz:\1\}`)
// 自闭合标签 {yz:name attr="v" /}
reSelfClose = regexp.MustCompile(`\{yz:([a-zA-Z]+)((?:\s+[a-zA-Z_][\w-]*\s*=\s*"[^"]*")*)\s*/\}`)
// 全局标签 {yz:word}
reGlobal = regexp.MustCompile(`\{yz:([a-zA-Z_]\w*)\}`)
// 属性解析 key="value"
reAttr = regexp.MustCompile(`([a-zA-Z_][\w-]*)\s*=\s*"([^"]*)"`)
// 循环体内字段占位 [field:name/]
reField = regexp.MustCompile(`\[field:([a-zA-Z_]\w*)/\]`)
// include 文件名校验,仅允许简单文件名
reIncludeFile = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
)
// parseAttrs 解析标签属性串为 map
func parseAttrs(attrStr string) map[string]string {
out := map[string]string{}
for _, m := range reAttr.FindAllStringSubmatch(attrStr, -1) {
out[strings.ToLower(m[1])] = m[2]
}
return out
}
// Render 渲染模板目录下的指定文件
// 流水线:include 展开 → 块标签 → 自闭合标签 → 全局标签
func Render(templateDir, file string, ctx *RenderCtx) (string, error) {
content, err := os.ReadFile(filepath.Join(templateDir, file))
if err != nil {
return "", fmt.Errorf("模板文件不存在: %s", file)
}
out := string(content)
out, err = expandIncludes(templateDir, out, 0)
if err != nil {
return "", err
}
out, err = renderBlocks(out, ctx)
if err != nil {
return "", err
}
out, err = renderSelfClose(out, ctx)
if err != nil {
return "", err
}
out = renderGlobals(out, ctx)
return out, nil
}
// expandIncludes 递归展开 {yz:include file="xxx"/},限制嵌套深度防止死循环
func expandIncludes(dir, content string, depth int) (string, error) {
if depth > 5 {
return content, fmt.Errorf("include 嵌套层级过深")
}
var ferr error
out := reSelfClose.ReplaceAllStringFunc(content, func(m string) string {
sub := reSelfClose.FindStringSubmatch(m)
if sub[1] != "include" {
return m
}
attrs := parseAttrs(sub[2])
f := strings.TrimSpace(attrs["file"])
if f == "" || !reIncludeFile.MatchString(f) {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, f))
if err != nil {
ferr = fmt.Errorf("include 文件不存在: %s", f)
return ""
}
return string(data)
})
if ferr != nil {
return "", ferr
}
if strings.Contains(out, "{yz:include") {
return expandIncludes(dir, out, depth+1)
}
return out, nil
}
// renderBlocks 反复解析最内层块标签直至全部展开
func renderBlocks(content string, ctx *RenderCtx) (string, error) {
out := content
for i := 0; i < 10; i++ {
if !reBlock.MatchString(out) {
break
}
var ferr error
out = reBlock.ReplaceAllStringFunc(out, func(m string) string {
sub := reBlock.FindStringSubmatch(m)
name := strings.ToLower(sub[1])
attrs := parseAttrs(sub[2])
body := sub[3]
p, ok := blockProviders[name]
if !ok {
return "" // 未注册的块标签直接置空,避免标签泄漏到前台
}
rows, err := p(ctx.Tid, attrs, ctx)
if err != nil {
ferr = err
return ""
}
var sb strings.Builder
for _, row := range rows {
sb.WriteString(reField.ReplaceAllStringFunc(body, func(fm string) string {
field := reField.FindStringSubmatch(fm)[1]
return row[field]
}))
}
return sb.String()
})
if ferr != nil {
return "", ferr
}
}
return out, nil
}
// renderSelfClose 处理剩余自闭合标签(onepage/pagelist 等,include 已先行展开)
func renderSelfClose(content string, ctx *RenderCtx) (string, error) {
var ferr error
out := reSelfClose.ReplaceAllStringFunc(content, func(m string) string {
sub := reSelfClose.FindStringSubmatch(m)
name := strings.ToLower(sub[1])
h, ok := selfCloseHandlers[name]
if !ok {
return ""
}
s, err := h(ctx.Tid, parseAttrs(sub[2]), ctx)
if err != nil {
ferr = err
return ""
}
return s
})
if ferr != nil {
return "", ferr
}
return out, nil
}
// renderGlobals 替换全局标签;未提供的标签置空
func renderGlobals(content string, ctx *RenderCtx) string {
var vals map[string]string
if globalProvider != nil {
vals, _ = globalProvider(ctx.Tid, ctx)
}
return reGlobal.ReplaceAllStringFunc(content, func(m string) string {
key := reGlobal.FindStringSubmatch(m)[1]
if vals != nil {
if v, ok := vals[key]; ok {
return v
}
}
return ""
})
}
+100
View File
@@ -0,0 +1,100 @@
package tagengine
// TagMeta 标签元信息:由注册表统一维护,供 platform 端"标签调用说明"页输出。
// 新增标签时在此登记一条,文档页自动同步,避免文档与实现脱节。
type TagMeta struct {
Name string `json:"name"` // 标签名
Category string `json:"category"` // 分类:global 全局 / loop 循环 / single 单页 / other 其他
Syntax string `json:"syntax"` // 语法示例
Desc string `json:"desc"` // 用途说明
Params []string `json:"params"` // 可用参数(key=说明)
Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段
Example string `json:"example"` // 模板内示例代码
}
// TagDocs 返回全部已登记标签的说明数据
func TagDocs() []TagMeta {
return []TagMeta{
{
Name: "全局站点信息",
Category: "global",
Syntax: "{yz:sitename} {yz:logo} {yz:logow} {yz:ico} {yz:icp} {yz:copyright} {yz:companyname} {yz:description}",
Desc: "取自当前租户的站点设置(backend 端 - 站点设置),直接替换为对应文本/地址,无需闭合。",
Params: nil,
Fields: nil,
Example: "<title>{yz:sitename}</title>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\" />\n<p>{yz:icp} {yz:copyright}</p>",
},
{
Name: "nav 导航菜单",
Category: "loop",
Syntax: "{yz:nav}...{/yz:nav}",
Desc: "循环输出当前租户在 backend 端配置的前台导航菜单(yz_backend_menu_front)。",
Params: nil,
Fields: []string{"id 菜单ID", "name 菜单名称", "url 跳转路径", "children 子菜单HTML(<ul class=\"yz-sub-menu\">结构,无子菜单时为空)"},
Example: "{yz:nav}<li><a href=\"[field:url/]\">[field:name/]</a>[field:children/]</li>{/yz:nav}",
},
{
Name: "banner 轮播图",
Category: "loop",
Syntax: "{yz:banner row=\"5\"}...{/yz:banner}",
Desc: "循环输出当前租户启用中的 Banner(backend 端 - Banner管理),按 sort 升序。",
Params: []string{"row 输出条数,默认 10"},
Fields: []string{"id BannerID", "title 标题", "desc 简介", "image 图片地址", "url 跳转地址"},
Example: "{yz:banner}<a href=\"[field:url/]\"><img src=\"[field:image/]\" alt=\"[field:title/]\" /></a>{/yz:banner}",
},
{
Name: "arclist 文章列表",
Category: "loop",
Syntax: "{yz:arclist row=\"6\" cateid=\"2\" titlelen=\"30\" flag=\"recommend\"}...{/yz:arclist}",
Desc: "循环输出当前租户已发布的文章,默认按 置顶>最新 排序。",
Params: []string{"row 输出条数,默认 10,最大 50", "cateid 文章分类ID,不填为全部", "titlelen 标题截断字数,不填为完整标题", "flag recommend=推荐 / top=置顶 / hot=按阅读量排序"},
Fields: []string{"id 文章ID", "title 标题(受 titlelen 影响)", "titlefull 完整标题", "desc 摘要", "image 缩略图", "arcurl 详情页链接(/news/ID)", "pubdate 发布日期", "views 阅读量", "author 作者"},
Example: "{yz:arclist row=\"6\" titlelen=\"20\"}\n<li><a href=\"[field:arcurl/]\">[field:title/]</a><span>[field:pubdate/]</span></li>\n{/yz:arclist}",
},
{
Name: "arcview 文章详情",
Category: "single",
Syntax: "{yz:arcview}...{/yz:arcview}",
Desc: "仅在文章详情页(/news/:id)模板内有效,输出当前文章信息。",
Params: nil,
Fields: []string{"id 文章ID", "title 标题", "content 富文本正文", "desc 摘要", "image 缩略图", "author 作者", "pubdate 发布时间", "views 阅读量"},
Example: "{yz:arcview}\n<h1>[field:title/]</h1>\n<p>[field:pubdate/] 阅读 [field:views/]</p>\n<div class=\"content\">[field:content/]</div>\n{/yz:arcview}",
},
{
Name: "friendlink 友情链接",
Category: "loop",
Syntax: "{yz:friendlink}...{/yz:friendlink}",
Desc: "循环输出当前租户启用中的友情链接(backend 端 - 友情链接),按 sort 升序。",
Params: []string{"row 输出条数,默认 20"},
Fields: []string{"id 链接ID", "name 链接名称", "url 链接地址", "logo Logo地址", "description 描述"},
Example: "{yz:friendlink}<a href=\"[field:url/]\" target=\"_blank\">[field:name/]</a>{/yz:friendlink}",
},
{
Name: "onepage 单页内容",
Category: "single",
Syntax: "{yz:onepage path=\"about\" field=\"content\"/}",
Desc: "输出指定单页(backend 端 - 单页管理)的标题或正文;在单页路由(/page/:path)模板内可省略 path,自动取当前单页。",
Params: []string{"path 单页路径标识,单页路由模板内可省略", "field 输出字段,content(默认) / title"},
Fields: nil,
Example: "{yz:onepage field=\"title\"/>\n<div class=\"content\">{yz:onepage/}</div>",
},
{
Name: "pagelist 分页",
Category: "other",
Syntax: "{yz:pagelist/}",
Desc: "在新闻列表页(/news)模板内输出分页链接(上一页/页码/下一页),链接指向 /news?page=N,样式类名 yz-pagination/yz-page。",
Params: nil,
Fields: nil,
Example: "<div class=\"pager\">{yz:pagelist/}</div>",
},
{
Name: "include 包含子模板",
Category: "other",
Syntax: "{yz:include file=\"header.html\"/}",
Desc: "在当前模板位置引入同目录下的子模板文件(如公共头部/底部),最大嵌套 5 层。",
Params: []string{"file 子模板文件名,如 header.html"},
Fields: nil,
Example: "{yz:include file=\"header.html\"/}",
},
}
}
+370
View File
@@ -0,0 +1,370 @@
package tagengine
import (
"fmt"
"strconv"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
const (
dateLayout = "2006-01-02"
datetimeLayout = "2006-01-02 15:04"
maxIncludeRows = 200
)
func init() {
RegisterGlobal(globalSiteInfo)
RegisterBlock("nav", navProvider)
RegisterBlock("banner", bannerProvider)
RegisterBlock("arclist", arclistProvider)
RegisterBlock("arcview", arcviewProvider)
RegisterBlock("friendlink", friendlinkProvider)
RegisterSelfClose("onepage", onepageHandler)
RegisterSelfClose("pagelist", pagelistHandler)
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return def
}
// truncateTitle 按字符数(而非字节)安全截断标题
func truncateTitle(s string, n int) string {
if n <= 0 {
return s
}
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n]) + "..."
}
// publishedCond 已发布状态条件:兼容历史 status=1 与现行 status=2(已发布)
func publishedCond(qs orm.QuerySeter) orm.QuerySeter {
return qs.Filter("status__in", []int8{1, 2})
}
// globalSiteInfo 全局标签:站点设置各字段
func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) {
out := map[string]string{
"sitename": "", "logo": "", "logow": "", "ico": "", "icp": "",
"copyright": "", "companyname": "", "description": "",
}
var row models.TenantSiteSetting
err := models.Orm.QueryTable(new(models.TenantSiteSetting)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
One(&row)
if err != nil {
return out, nil
}
out["sitename"] = row.Sitename
out["logo"] = row.Logo
out["logow"] = row.Logow
out["ico"] = row.Ico
out["icp"] = row.Icp
out["copyright"] = row.Copyright
out["companyname"] = row.Companyname
out["description"] = row.Description
return out, nil
}
// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML
func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
var rows []models.BackendMenuFront
_, err := models.Orm.QueryTable(new(models.BackendMenuFront)).
Filter("tenant_id", tid).
Filter("pid", 0).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
Limit(maxIncludeRows).
All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
var children []models.BackendMenuFront
_, _ = models.Orm.QueryTable(new(models.BackendMenuFront)).
Filter("tenant_id", tid).
Filter("delete_time__isnull", true).
Filter("pid__gt", 0).
OrderBy("sort", "id").
Limit(maxIncludeRows).
All(&children)
childMap := map[int64][]models.BackendMenuFront{}
for _, c := range children {
childMap[c.Pid] = append(childMap[c.Pid], c)
}
out := make([]map[string]string, 0, len(rows))
for _, m := range rows {
childHTML := ""
if subs, ok := childMap[int64(m.ID)]; ok && len(subs) > 0 {
var sb strings.Builder
sb.WriteString(`<ul class="yz-sub-menu">`)
for _, s := range subs {
sb.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, s.Path, s.Title))
}
sb.WriteString(`</ul>`)
childHTML = sb.String()
}
out = append(out, map[string]string{
"id": strconv.FormatUint(m.ID, 10),
"name": m.Title,
"url": m.Path,
"children": childHTML,
})
}
return out, nil
}
// bannerProvider 首页 Banner 循环
func bannerProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
row := atoiDefault(params["row"], 10)
var rows []models.CmsFrontendBanner
_, err := models.Orm.QueryTable(new(models.CmsFrontendBanner)).
Filter("tid", tid).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
Limit(row).
All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
out := make([]map[string]string, 0, len(rows))
for _, b := range rows {
out = append(out, map[string]string{
"id": strconv.FormatUint(b.ID, 10),
"title": b.Title,
"desc": b.Desc,
"image": b.Image,
"url": b.Url,
})
}
return out, nil
}
// friendlinkProvider 友情链接循环
func friendlinkProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
row := atoiDefault(params["row"], 20)
var rows []models.CmsFrontendFriendlink
_, err := models.Orm.QueryTable(new(models.CmsFrontendFriendlink)).
Filter("tid", tid).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
Limit(row).
All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
out := make([]map[string]string, 0, len(rows))
for _, f := range rows {
out = append(out, map[string]string{
"id": strconv.FormatUint(f.ID, 10),
"name": f.LinkName,
"url": f.LinkUrl,
"logo": f.LinkLogo,
"description": f.Description,
})
}
return out, nil
}
// arclistProvider 文章列表循环,支持 row/cateid/titlelen/flag 参数
func arclistProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
row := atoiDefault(params["row"], 10)
if row <= 0 || row > 50 {
row = 10
}
titleLen := atoiDefault(params["titlelen"], 0)
qs := publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
Filter("tid", tid).
Filter("delete_time__isnull", true))
if cid, err := strconv.ParseUint(strings.TrimSpace(params["cateid"]), 10, 64); err == nil && cid > 0 {
qs = qs.Filter("cate_id", cid)
}
switch strings.ToLower(strings.TrimSpace(params["flag"])) {
case "recommend":
qs = qs.Filter("recommend", 1)
case "top":
qs = qs.Filter("top", 1)
case "hot":
qs = qs.OrderBy("-views", "-id")
}
var rows []models.CmsArticle
order := []string{"-top", "-id"}
if strings.ToLower(strings.TrimSpace(params["flag"])) == "hot" {
order = []string{"-views", "-id"}
}
// 列表页携带页码时按页偏移,配合 {yz:pagelist/} 分页
offset := 0
if ctx.Page > 1 {
offset = (ctx.Page - 1) * row
}
_, err := qs.OrderBy(order...).Limit(row, offset).All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
return articleRows(rows, titleLen), nil
}
// articleRows 文章行转标签字段
func articleRows(rows []models.CmsArticle, titleLen int) []map[string]string {
out := make([]map[string]string, 0, len(rows))
for _, a := range rows {
pub := ""
if a.PublishTime != nil {
pub = a.PublishTime.Format(dateLayout)
} else {
pub = a.CreateTime.Format(dateLayout)
}
title := a.Title
if titleLen > 0 {
title = truncateTitle(title, titleLen)
}
out = append(out, map[string]string{
"id": strconv.FormatUint(a.ID, 10),
"title": title,
"titlefull": a.Title,
"desc": a.Desc,
"image": a.Image,
"arcurl": "/news/" + strconv.FormatUint(a.ID, 10),
"pubdate": pub,
"views": strconv.Itoa(a.Views),
"author": a.Author,
})
}
return out
}
// arcviewProvider 文章详情(详情页模板内),取当前路由文章
func arcviewProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
if ctx.ArticleID == 0 {
return nil, nil
}
var a models.CmsArticle
err := models.Orm.QueryTable(new(models.CmsArticle)).
Filter("id", ctx.ArticleID).
Filter("tid", tid).
Filter("delete_time__isnull", true).
One(&a)
if err != nil {
return nil, nil
}
pub := ""
if a.PublishTime != nil {
pub = a.PublishTime.Format(datetimeLayout)
} else {
pub = a.CreateTime.Format(datetimeLayout)
}
return []map[string]string{{
"id": strconv.FormatUint(a.ID, 10),
"title": a.Title,
"content": a.Content,
"desc": a.Desc,
"image": a.Image,
"author": a.Author,
"pubdate": pub,
"views": strconv.Itoa(a.Views),
}}, nil
}
// onepageHandler 单页内容 {yz:onepage path="about" field="content"/}
func onepageHandler(tid uint64, params map[string]string, ctx *RenderCtx) (string, error) {
path := strings.TrimSpace(params["path"])
if path == "" {
path = ctx.PagePath
}
if path == "" {
return "", nil
}
field := strings.ToLower(strings.TrimSpace(params["field"]))
if field == "" {
field = "content"
}
var row models.CmsFrontendOnepage
err := models.Orm.QueryTable(new(models.CmsFrontendOnepage)).
Filter("tid", tid).
Filter("path", path).
Filter("status", 1).
Filter("delete_time__isnull", true).
One(&row)
if err != nil {
return "", nil
}
switch field {
case "title":
return row.Title, nil
default:
return row.Content, nil
}
}
// newsQueryBase 新闻列表统一查询条件(pagelist 与 news.html 保持一致)
func newsQueryBase(tid uint64) orm.QuerySeter {
return publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
Filter("tid", tid).
Filter("delete_time__isnull", true))
}
// pagelistHandler 分页链接 {yz:pagelist/},配合 /news?page=N
func pagelistHandler(tid uint64, params map[string]string, ctx *RenderCtx) (string, error) {
pageSize := ctx.PageSize
if pageSize <= 0 {
pageSize = 10
}
total, err := newsQueryBase(tid).Count()
if err != nil || total == 0 {
return "", nil
}
pages := int((total + int64(pageSize) - 1) / int64(pageSize))
cur := ctx.Page
if cur < 1 {
cur = 1
}
if cur > pages {
cur = pages
}
var sb strings.Builder
sb.WriteString(`<nav class="yz-pagination">`)
if cur > 1 {
sb.WriteString(fmt.Sprintf(`<a class="yz-page prev" href="/news?page=%d">上一页</a>`, cur-1))
}
start := cur - 3
if start < 1 {
start = 1
}
end := cur + 3
if end > pages {
end = pages
}
for i := start; i <= end; i++ {
if i == cur {
sb.WriteString(fmt.Sprintf(`<span class="yz-page current">%d</span>`, i))
} else {
sb.WriteString(fmt.Sprintf(`<a class="yz-page" href="/news?page=%d">%d</a>`, i, i))
}
}
if cur < pages {
sb.WriteString(fmt.Sprintf(`<a class="yz-page next" href="/news?page=%d">下一页</a>`, cur+1))
}
sb.WriteString(`</nav>`)
return sb.String(), nil
}