优化企业网站功能

This commit is contained in:
2026-08-20 16:21:07 +08:00
parent 112d60b993
commit 3f0fcb88e7
45 changed files with 8758 additions and 5058 deletions
+24 -7
View File
@@ -26,8 +26,12 @@ type SelfCloseHandler func(tid uint64, params map[string]string, ctx *RenderCtx)
// GlobalProvider 全局标签提供器:返回 标签名->值 映射
type GlobalProvider func(tid uint64, ctx *RenderCtx) (map[string]string, error)
// BodyProvider 带循环体的块标签:自行组装 HTML(如带 Tab 的新闻中心)
type BodyProvider func(tid uint64, params map[string]string, ctx *RenderCtx, body string) (string, error)
var (
blockProviders = map[string]Provider{}
blockProviders = map[string]Provider{}
bodyProviders = map[string]BodyProvider{}
selfCloseHandlers = map[string]SelfCloseHandler{}
globalProvider GlobalProvider
)
@@ -35,6 +39,9 @@ var (
// RegisterBlock 注册循环标签({yz:name}...{/yz:name})
func RegisterBlock(name string, p Provider) { blockProviders[name] = p }
// RegisterBodyBlock 注册自行组装循环体的块标签
func RegisterBodyBlock(name string, p BodyProvider) { bodyProviders[name] = p }
// RegisterSelfClose 注册自闭合标签({yz:name ... /})
func RegisterSelfClose(name string, h SelfCloseHandler) { selfCloseHandlers[name] = h }
@@ -67,6 +74,13 @@ func parseAttrs(attrStr string) map[string]string {
return out
}
func applyFields(body string, row map[string]string) string {
return reField.ReplaceAllStringFunc(body, func(fm string) string {
field := reField.FindStringSubmatch(fm)[1]
return row[field]
})
}
// Render 渲染模板目录下的指定文件
// 流水线:include 展开 → 块标签 → 自闭合标签 → 全局标签
func Render(templateDir, file string, ctx *RenderCtx) (string, error) {
@@ -152,18 +166,21 @@ func renderBlocks(content string, ctx *RenderCtx) (string, error) {
}
rendered := ""
if p, ok := blockProviders[closeName]; ok {
body := out[openEnd:c[0]]
if bp, ok := bodyProviders[closeName]; ok {
s, err := bp(ctx.Tid, parseAttrs(attrs), ctx, body)
if err != nil {
return "", err
}
rendered = s
} else if p, ok := blockProviders[closeName]; ok {
rows, err := p(ctx.Tid, parseAttrs(attrs), ctx)
if err != nil {
return "", err
}
body := out[openEnd:c[0]]
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]
}))
sb.WriteString(applyFields(body, row))
}
rendered = sb.String()
}
+63 -17
View File
@@ -4,11 +4,11 @@ package tagengine
// 新增标签时在此登记一条,文档页自动同步,避免文档与实现脱节。
type TagMeta struct {
Name string `json:"name"` // 标签名
Category string `json:"category"` // 分类:global 全局 / loop 循环 / single 单页 / other 其他
Category string `json:"category"` // 分类:guide 入门 / global 全局 / loop 循环 / single 单页 / other 其他
Syntax string `json:"syntax"` // 语法示例
Desc string `json:"desc"` // 用途说明
Params []string `json:"params"` // 可用参数(key=说明)
Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段
Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段;global 分类下为标签清单(名称 说明 数据来源)
Example string `json:"example"` // 模板内示例代码
}
@@ -16,31 +16,68 @@ type TagMeta struct {
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} {yz:companyintroduction}",
Desc: "取自当前租户的站点设置(backend 端 - 站点设置 - 基本信息),直接替换为对应文本/地址,无需闭合。logo 彩色Logo / logow 白色Logo / ico 站点图标 / companyintroduction 企业介绍(富文本)。",
Name: "基本用法",
Category: "guide",
Syntax: "{yz:标签} / {yz:标签 参数=\"值\"/} / {yz:标签}...[field:字段/]...{/yz:标签}",
Desc: "模板中用 {yz:xxx} 调用数据。三种形态:① 全局标签直接替换为文本,无需闭合;② 自闭合标签带参数、以 /} 结尾;③ 循环标签成对出现,循环体内用 [field:字段/] 输出每一行数据的字段。未配置的项输出空字符串,不会报错。",
Params: nil,
Fields: nil,
Example: "<title>{yz:sitename}</title>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\" />\n<p>{yz:icp} {yz:copyright}</p>",
Example: "<!-- 全局标签:直接替换 -->\n<h1>{yz:sitename}</h1>\n\n<!-- 自闭合标签:带参数 -->\n{yz:onepage path=\"about\" field=\"content\"/}\n\n<!-- 循环标签:成对 + 字段占位 -->\n{yz:arclist row=\"6\"}\n<li><a href=\"[field:arcurl/]\">[field:title/]</a></li>\n{/yz:arclist}",
},
{
Name: "全局联系方式",
Category: "global",
Syntax: "{yz:phone} {yz:email} {yz:address} {yz:worktime}",
Desc: "取自租户的公司信息(backend 端 - 站点设置 - 公司信息),直接替换为文本,无需闭合。phone 联系电话(别名 {yz:tel} / {yz:mobile}) / email 电子邮箱 / address 公司地址 / worktime 工作时间。",
Name: "模板文件结构",
Category: "guide",
Syntax: "themes/{模板编码}/",
Desc: "模板存放在服务器 themes/{编码}/ 目录,按请求域名识别租户后用对应模板渲染。路由与文件的对应关系:首页 / → index.html;新闻列表 /news → news.html;文章详情 /news/:id → news_detail.html;单页 /page/:path → page.html。公共头部/底部建议拆成 header.html / footer.html,用 {yz:include/} 引入。模板内的相对资源路径(assets/css/xxx.css、style.css 等)渲染时会自动改写为 /themes/{编码}/ 绝对路径,无需手工改。",
Params: nil,
Fields: nil,
Example: "<a href=\"tel:{yz:phone}\">{yz:phone}</a>\n<a href=\"mailto:{yz:email}\">{yz:email}</a>\n<p>{yz:address}</p>\n<p>工作时间:{yz:worktime}</p>",
Example: "themes/business/\n├── index.html ← 首页 /\n├── news.html ← 新闻列表 /news?page=N\n├── news_detail.html ← 文章详情 /news/:id\n├── page.html ← 单页 /page/about\n├── header.html ← 公共头部(include 引入)\n├── footer.html ← 公共底部(include 引入)\n└── assets/ ← 模板自带的 css/js/图片",
},
{
Name: "全局 SEO 信息",
Name: "站点基本信息",
Category: "global",
Syntax: "{yz:seo_title} {yz:keywords} {yz:seo_description}",
Desc: "取自租户的 SEO 设置(backend 端 - 站点设置 - SEO 设置),用于 head 区域的 meta 标签。seo_title 未填时建议回退用 {yz:sitename}。",
Syntax: "{yz:sitename}、{yz:logo}、{yz:ico} …直接写在模板任意位置",
Desc: "取自当前租户的站点设置(backend 端 - 站点设置 - 基本信息)。直接替换为文本或地址,无需闭合;未配置的项输出空字符串。点击行末复制按钮可复制标签。",
Params: nil,
Fields: nil,
Example: "<title>{yz:seo_title}</title>\n<meta name=\"keywords\" content=\"{yz:keywords}\">\n<meta name=\"description\" content=\"{yz:seo_description}\">",
Fields: []string{
"sitename 站点名称,常用于 <title> 和页头 来源:基本信息-站点名称",
"logo 彩色 Logo 图片地址,适合浅色背景 来源:基本信息-站点LOGO",
"logow 白色 Logo 图片地址,适合深色背景/页脚 来源:基本信息-白色LOGO",
"ico 站点图标地址,用于 <link rel=\"icon\"> 来源:基本信息-站点图标",
"companyname 公司全称 来源:基本信息-公司名称",
"companyintroduction 企业介绍(富文本HTML,直接用 div 承接) 来源:基本信息-企业介绍",
"description 站点简介,可用于 meta description 来源:基本信息-站点描述",
"copyright 版权信息,常用于页脚 来源:基本信息-版权信息",
"icp ICP 备案号,常用于页脚 来源:基本信息-ICP备案",
},
Example: "<head>\n <title>{yz:sitename}</title>\n <link rel=\"icon\" href=\"{yz:ico}\">\n <meta name=\"description\" content=\"{yz:description}\">\n</head>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\">\n<div class=\"about\">{yz:companyintroduction}</div>\n<footer>\n <p>{yz:copyright}</p>\n <p>{yz:icp}</p>\n</footer>",
},
{
Name: "联系方式",
Category: "global",
Syntax: "{yz:phone}、{yz:email}、{yz:address}、{yz:worktime}",
Desc: "取自租户的公司信息(backend 端 - 公司信息),常用于页头联系栏和页脚。直接替换为文本,无需闭合。",
Params: nil,
Fields: []string{
"phone 联系电话,别名 {yz:tel}、{yz:mobile} 三者等价 来源:公司信息-联系电话",
"email 电子邮箱,可配合 mailto: 使用 来源:公司信息-邮箱",
"address 公司地址 来源:公司信息-地址",
"worktime 工作时间 来源:公司信息-工作时间",
},
Example: "<!-- 页头联系栏 -->\n<a href=\"tel:{yz:phone}\">服务热线:{yz:phone}</a>\n<a href=\"mailto:{yz:email}\">{yz:email}</a>\n\n<!-- 页脚 -->\n<p>地址:{yz:address}</p>\n<p>工作时间:{yz:worktime}</p>",
},
{
Name: "SEO 信息",
Category: "global",
Syntax: "{yz:seotitle}、{yz:keywords}、{yz:seodescription}",
Desc: "取自租户的 SEO 设置(backend 端 - SEO 设置),用于 <head> 区域。seotitle 未填时输出为空,建议模板里用 {yz:sitename} 兜底。",
Params: nil,
Fields: []string{
"seotitle SEO 标题,未填时建议回退 {yz:sitename} 来源:SEO设置-SEO标题",
"keywords SEO 关键词,用于 meta keywords 来源:SEO设置-关键词",
"seodescription SEO 描述,用于 meta description 来源:SEO设置-描述",
},
Example: "<title>{yz:seotitle}</title>\n<meta name=\"keywords\" content=\"{yz:keywords}\">\n<meta name=\"description\" content=\"{yz:seodescription}\">",
},
{
Name: "nav 导航菜单",
@@ -69,6 +106,15 @@ func TagDocs() []TagMeta {
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: "newscenter 新闻中心(带Tab)",
Category: "loop",
Syntax: "{yz:newscenter row=\"8\" titlelen=\"30\"}...{/yz:newscenter}",
Desc: "首页新闻中心区块。循环体是单条新闻卡片。若「新闻中心」分类下有子分类,则按子分类输出 Tab 并可切换,每个 Tab 展示 row 条;没有子分类则不输出 Tab,直接列出 row 条(含新闻中心及其子类文章)。",
Params: []string{"row 每个分类输出条数,默认 8,最大 50", "titlelen 标题截断字数,不填为完整标题"},
Fields: []string{"id 文章ID", "title 标题(受 titlelen 影响)", "titlefull 完整标题", "desc 摘要", "image 缩略图", "arcurl 详情页链接(/news/ID)", "pubdate 发布日期", "views 阅读量", "author 作者"},
Example: "{yz:newscenter row=\"8\" titlelen=\"28\"}\n<div class=\"col-lg-3 col-md-6\">\n <a href=\"[field:arcurl/]\">\n <img src=\"[field:image/]\" alt=\"[field:titlefull/]\">\n <h4>[field:title/]</h4>\n <p>[field:desc/]</p>\n <span>[field:pubdate/]</span>\n </a>\n</div>\n{/yz:newscenter}",
},
{
Name: "arcview 文章详情",
Category: "single",
+108 -6
View File
@@ -2,6 +2,7 @@ package tagengine
import (
"fmt"
"html"
"strconv"
"strings"
@@ -23,6 +24,7 @@ func init() {
RegisterBlock("arclist", arclistProvider)
RegisterBlock("arcview", arcviewProvider)
RegisterBlock("friendlink", friendlinkProvider)
RegisterBodyBlock("newscenter", newscenterHandler)
RegisterSelfClose("onepage", onepageHandler)
RegisterSelfClose("pagelist", pagelistHandler)
}
@@ -61,7 +63,8 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) {
"copyright": "", "companyname": "", "description": "",
"companyintroduction": "",
"phone": "", "tel": "", "mobile": "", "email": "", "address": "", "worktime": "",
"keywords": "", "seo_title": "", "seo_description": "",
"keywords": "", "seotitle": "", "seodescription": "",
"aboutus": "/page/about", "contact": "/page/contact", "news": "/news", "team": "/page/team",
}
var row models.TenantSiteSetting
err := models.Orm.QueryTable(new(models.TenantSiteSetting)).
@@ -105,8 +108,8 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) {
// SEO 三项取自租户扩展设置表(backend 端 - 站点设置 - SEO 设置)
if vals, err := queryTenantSettingItems(tid, "seoKeywords", "seoTitle", "seoDescription"); err == nil {
out["keywords"] = vals["seoKeywords"]
out["seo_title"] = vals["seoTitle"]
out["seo_description"] = vals["seoDescription"]
out["seotitle"] = vals["seoTitle"]
out["seodescription"] = vals["seoDescription"]
}
return out, nil
}
@@ -136,11 +139,14 @@ func queryTenantSettingItems(tid uint64, keys ...string) (map[string]string, err
return out, nil
}
// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML
// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML。
// 一级菜单含全局默认(tenant_id=0,不可删)与租户自定义;子菜单仅租户自有。
func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
models.EnsureGlobalDefaultFrontMenus()
var rows []models.BackendMenuFront
_, err := models.Orm.QueryTable(new(models.BackendMenuFront)).
Filter("tenant_id", tid).
Filter("tenant_id__in", []uint64{0, tid}).
Filter("pid", 0).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
@@ -169,7 +175,7 @@ func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[st
childHTML := ""
if subs, ok := childMap[int64(m.ID)]; ok && len(subs) > 0 {
var sb strings.Builder
sb.WriteString(`<ul class="yz-sub-menu">`)
sb.WriteString(`<ul class="sub-menu">`)
for _, s := range subs {
sb.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, s.Path, s.Title))
}
@@ -373,6 +379,102 @@ func onepageHandler(tid uint64, params map[string]string, ctx *RenderCtx) (strin
}
}
// newscenterHandler 首页新闻中心:循环体为单条新闻卡片。
// 有「新闻中心」子分类时输出 Bootstrap Tab,每个 Tab 8 条;无子分类则不输出 Tab,直接列出 8 条。
func newscenterHandler(tid uint64, params map[string]string, ctx *RenderCtx, body string) (string, error) {
row := atoiDefault(params["row"], 8)
if row <= 0 || row > 50 {
row = 8
}
titleLen := atoiDefault(params["titlelen"], 0)
parent, err := findNewsCenterCategory(tid)
if err != nil {
return "", err
}
children := []models.CmsArticleCategory{}
if parent != nil {
_, _ = models.Orm.QueryTable(new(models.CmsArticleCategory)).
Filter("cid", parent.ID).
Filter("tid__in", []uint64{0, tid}).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
All(&children)
}
renderCards := func(cateID uint64) string {
qs := publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
Filter("tid", tid).
Filter("delete_time__isnull", true))
if cateID > 0 {
qs = qs.Filter("cate_id", cateID)
} else if parent != nil {
ids := []uint64{parent.ID}
for _, c := range children {
ids = append(ids, c.ID)
}
qs = qs.Filter("cate_id__in", ids)
}
var arts []models.CmsArticle
_, _ = qs.OrderBy("-top", "-id").Limit(row).All(&arts)
var sb strings.Builder
for _, a := range articleRows(arts, titleLen) {
sb.WriteString(applyFields(body, a))
}
return sb.String()
}
if len(children) == 0 {
return `<div class="row">` + renderCards(0) + `</div>`, nil
}
var tabs, panes strings.Builder
tabs.WriteString(`<nav class="news-center-tabs"><div class="nav nav-tabs" role="tablist">`)
panes.WriteString(`<div class="tab-content">`)
for i, c := range children {
paneID := fmt.Sprintf("news-cate-%d", c.ID)
tabID := paneID + "-tab"
active := ""
show := ""
selected := "false"
if i == 0 {
active = " active"
show = " show active"
selected = "true"
}
tabs.WriteString(fmt.Sprintf(
`<button class="nav-link%s" id="%s" data-bs-toggle="tab" data-bs-target="#%s" type="button" role="tab" aria-controls="%s" aria-selected="%s">%s</button>`,
active, tabID, paneID, paneID, selected, html.EscapeString(c.Name),
))
panes.WriteString(fmt.Sprintf(
`<div class="tab-pane fade%s" id="%s" role="tabpanel" aria-labelledby="%s"><div class="row">%s</div></div>`,
show, paneID, tabID, renderCards(c.ID),
))
}
tabs.WriteString(`</div></nav>`)
panes.WriteString(`</div>`)
return tabs.String() + panes.String(), nil
}
func findNewsCenterCategory(tid uint64) (*models.CmsArticleCategory, error) {
var row models.CmsArticleCategory
err := models.Orm.QueryTable(new(models.CmsArticleCategory)).
Filter("name", "新闻中心").
Filter("tid__in", []uint64{0, tid}).
Filter("delete_time__isnull", true).
OrderBy("-tid", "id").
One(&row)
if err == orm.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// newsQueryBase 新闻列表统一查询条件(pagelist 与 news.html 保持一致)
func newsQueryBase(tid uint64) orm.QuerySeter {
return publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).