优化企业网站功能
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)).
|
||||
|
||||
Reference in New Issue
Block a user