From 14f2e8b5c176ce04be0da5c56e3b82613e3699d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 19 Aug 2026 23:34:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A7=9F=E6=88=B7=E5=AE=98?= =?UTF-8?q?=E7=BD=91=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/README.md | 6 +- go/controllers/backend_frontend_content.go | 857 +++++++++++++++++++++ go/controllers/backend_theme.go | 339 ++++++++ go/controllers/platform_template.go | 576 ++++++++++++++ go/controllers/tenant_site.go | 190 +++++ go/main.go | 2 + go/models/cms_frontend.go | 78 ++ go/models/init.go | 5 + go/models/system_tenant_site_setting.go | 3 + go/pkg/tagengine/engine.go | 201 +++++ go/pkg/tagengine/meta.go | 100 +++ go/pkg/tagengine/provider.go | 370 +++++++++ go/routers/backend/backend.go | 21 + go/routers/index/index.go | 6 + go/routers/platform/platform.go | 11 + go/themes/default/footer.html | 14 + go/themes/default/header.html | 23 + go/themes/default/index.html | 39 + go/themes/default/news.html | 24 + go/themes/default/news_detail.html | 18 + go/themes/default/page.html | 12 + go/themes/default/static/main.js | 13 + go/themes/default/static/style.css | 83 ++ platform/src/api/templateManage.js | 82 ++ platform/src/router/index.js | 12 + platform/src/views/template/index.vue | 415 +++++++++- platform/src/views/template/tags.vue | 235 ++++++ sql/yz_cms_frontend_tables.sql | 84 ++ 28 files changed, 3812 insertions(+), 7 deletions(-) create mode 100644 go/controllers/backend_frontend_content.go create mode 100644 go/controllers/backend_theme.go create mode 100644 go/controllers/platform_template.go create mode 100644 go/controllers/tenant_site.go create mode 100644 go/models/cms_frontend.go create mode 100644 go/pkg/tagengine/engine.go create mode 100644 go/pkg/tagengine/meta.go create mode 100644 go/pkg/tagengine/provider.go create mode 100644 go/themes/default/footer.html create mode 100644 go/themes/default/header.html create mode 100644 go/themes/default/index.html create mode 100644 go/themes/default/news.html create mode 100644 go/themes/default/news_detail.html create mode 100644 go/themes/default/page.html create mode 100644 go/themes/default/static/main.js create mode 100644 go/themes/default/static/style.css create mode 100644 platform/src/api/templateManage.js create mode 100644 platform/src/views/template/tags.vue create mode 100644 sql/yz_cms_frontend_tables.sql diff --git a/frontend/README.md b/frontend/README.md index 615dc26..a328bc6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,2 +1,6 @@ # METETEME WEBSITE -# 美天智能科技官网前端 \ No newline at end of file +# 美天智能科技官网前端 + +> **⚠️ 废弃预告**:官网前台已迁移至 Go 标签模板引擎直出渲染(`go/themes/` + `go/pkg/tagengine`, +> 由 `tenant_site.go` 按租户域名直出 HTML)。本 frontend 程序在引擎上线且平台主域名官网验证通过后废弃删除, +> 不再新增功能,请勿继续在此基础上开发。 \ No newline at end of file diff --git a/go/controllers/backend_frontend_content.go b/go/controllers/backend_frontend_content.go new file mode 100644 index 0000000..31629a0 --- /dev/null +++ b/go/controllers/backend_frontend_content.go @@ -0,0 +1,857 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendBannerController 官网 Banner 管理(租户端,tid 严格隔离) +type BackendBannerController struct { + beego.Controller +} + +// BackendFriendlinkController 官网友情链接管理(租户端) +type BackendFriendlinkController struct { + beego.Controller +} + +// BackendOnepageController 官网单页管理(租户端) +type BackendOnepageController struct { + beego.Controller +} + +func (c *BackendBannerController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendFriendlinkController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendOnepageController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendBannerController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendFriendlinkController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendOnepageController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func cmsBannerToMap(row models.CmsFrontendBanner) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "desc": row.Desc, + "image": row.Image, + "url": row.Url, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Unix(), + } +} + +func cmsFriendlinkToMap(row models.CmsFrontendFriendlink) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "link_name": row.LinkName, + "link_url": row.LinkUrl, + "link_logo": row.LinkLogo, + "description": row.Description, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + } +} + +func cmsOnepageToMap(row models.CmsFrontendOnepage) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "title": row.Title, + "path": row.Path, + "content": row.Content, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Unix(), + } +} + +// ---------------- Banner ---------------- + +type cmsBannerPayload struct { + Title string `json:"title"` + Desc string `json:"desc"` + Image string `json:"image"` + Url string `json:"url"` + Sort int `json:"sort"` + Status int8 `json:"status"` +} + +// readBannerPayload 兼容 JSON 与 multipart/form-data 两种请求体 +// (前端 createbanner 以 multipart 头发送,实际可能是 JSON 体) +func (c *BackendBannerController) readBannerPayload() (*cmsBannerPayload, error) { + ct := c.Ctx.Request.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + // cmsEffectiveTid 已触发 ParseForm,表单字段可直接读取 + sort, _ := c.GetInt("sort") + status, _ := c.GetInt8("status", 1) + return &cmsBannerPayload{ + Title: strings.TrimSpace(c.GetString("title")), + Desc: strings.TrimSpace(c.GetString("desc")), + Image: strings.TrimSpace(c.GetString("image")), + Url: strings.TrimSpace(c.GetString("url")), + Sort: sort, + Status: status, + }, nil + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + return nil, err + } + var p cmsBannerPayload + p.Status = 1 + if len(raw) > 0 { + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("参数错误") + } + } + return &p, nil +} + +// ListAll GET /backend/allbanners +func (c *BackendBannerController) ListAll() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + var rows []models.CmsFrontendBanner + _, err = models.Orm.QueryTable(new(models.CmsFrontendBanner)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取Banner列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsBannerToMap(r)) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +// Create POST /backend/createbanner +func (c *BackendBannerController) Create() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + p, err := c.readBannerPayload() + if err != nil { + c.cmsJSONErr(400, 400, err.Error()) + return + } + if strings.TrimSpace(p.Title) == "" && strings.TrimSpace(p.Image) == "" { + c.cmsJSONErr(400, 400, "标题与图片不能都为空") + return + } + + now := time.Now() + row := models.CmsFrontendBanner{ + Tid: tid, + Title: strings.TrimSpace(p.Title), + Desc: strings.TrimSpace(p.Desc), + Image: strings.TrimSpace(p.Image), + Url: strings.TrimSpace(p.Url), + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + if row.Status == 0 { + row.Status = 1 + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "创建失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update POST /backend/editbanner/:id +func (c *BackendBannerController) Update() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsBannerPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + now := time.Now() + fields := map[string]interface{}{ + "title": strings.TrimSpace(p.Title), + "desc": strings.TrimSpace(p.Desc), + "image": strings.TrimSpace(p.Image), + "url": strings.TrimSpace(p.Url), + "sort": p.Sort, + "update_time": now, + } + if p.Status > 0 { + fields["status"] = p.Status + } + n, err := models.Orm.QueryTable(new(models.CmsFrontendBanner)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "Banner不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deletebanner/:id +func (c *BackendBannerController) Delete() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsFrontendBanner)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "Banner不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// ---------------- 友情链接 ---------------- + +// List GET /backend/friendlinks(分页:page/limit/keyword/status) +func (c *BackendFriendlinkController) List() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + limit, _ := c.GetInt("limit", 10) + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 200 { + limit = 200 + } + + qs := models.Orm.QueryTable(new(models.CmsFrontendFriendlink)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("link_name__icontains", kw) + } + if st := strings.TrimSpace(c.GetString("status")); st != "" { + if v, e := strconv.Atoi(st); e == nil { + qs = qs.Filter("status", v) + } + } + + total, _ := qs.Count() + var rows []models.CmsFrontendFriendlink + offset := (page - 1) * limit + _, err = qs.OrderBy("sort", "id").Limit(limit, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取友情链接失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsFriendlinkToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +// ListAll GET /backend/friendlinks/all +func (c *BackendFriendlinkController) ListAll() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + 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"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取友情链接失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsFriendlinkToMap(r)) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +type cmsFriendlinkPayload struct { + LinkName string `json:"link_name"` + LinkUrl string `json:"link_url"` + LinkLogo string `json:"link_logo"` + Description string `json:"description"` + Sort int `json:"sort"` + Status int8 `json:"status"` +} + +// Create POST /backend/friendlinks +func (c *BackendFriendlinkController) Create() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsFriendlinkPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + if strings.TrimSpace(p.LinkName) == "" { + c.cmsJSONErr(400, 400, "链接名称不能为空") + return + } + if strings.TrimSpace(p.LinkUrl) == "" { + c.cmsJSONErr(400, 400, "链接地址不能为空") + return + } + + now := time.Now() + row := models.CmsFrontendFriendlink{ + Tid: tid, + LinkName: strings.TrimSpace(p.LinkName), + LinkUrl: strings.TrimSpace(p.LinkUrl), + LinkLogo: strings.TrimSpace(p.LinkLogo), + Description: strings.TrimSpace(p.Description), + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + if row.Status == 0 { + row.Status = 1 + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "创建失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update PUT /backend/friendlinks/:id(支持只传部分字段,如 {status:1}) +func (c *BackendFriendlinkController) Update() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + fields := map[string]interface{}{} + if v, ok := body["link_name"]; ok { + fields["link_name"] = strings.TrimSpace(fmt.Sprintf("%v", v)) + } + if v, ok := body["link_url"]; ok { + fields["link_url"] = strings.TrimSpace(fmt.Sprintf("%v", v)) + } + if v, ok := body["link_logo"]; ok { + fields["link_logo"] = strings.TrimSpace(fmt.Sprintf("%v", v)) + } + if v, ok := body["description"]; ok { + fields["description"] = strings.TrimSpace(fmt.Sprintf("%v", v)) + } + if v, ok := body["sort"]; ok { + if n, e := strconv.Atoi(fmt.Sprintf("%.0f", toFloat(v))); e == nil { + fields["sort"] = n + } + } + if v, ok := body["status"]; ok { + fields["status"] = int8(toFloat(v)) + } + if len(fields) == 0 { + c.cmsJSONErr(400, 400, "没有可更新的字段") + return + } + fields["update_time"] = time.Now() + + n, err := models.Orm.QueryTable(new(models.CmsFrontendFriendlink)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "友情链接不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// toFloat 宽松地把 JSON 值转成数字(兼容字符串数字) +func toFloat(v interface{}) float64 { + switch x := v.(type) { + case float64: + return x + case string: + if n, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil { + return n + } + } + return 0 +} + +// Delete DELETE /backend/friendlinks/:id +func (c *BackendFriendlinkController) Delete() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsFrontendFriendlink)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "友情链接不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// BatchDelete POST /backend/friendlinks/batchdelete +func (c *BackendFriendlinkController) BatchDelete() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var body struct { + IDs []interface{} `json:"ids"` + } + if err := json.Unmarshal(raw, &body); err != nil || len(body.IDs) == 0 { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + ids := make([]uint64, 0, len(body.IDs)) + for _, v := range body.IDs { + if n := cmsParseUintArg(v); n > 0 { + ids = append(ids, n) + } + } + if len(ids) == 0 { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.CmsFrontendFriendlink)). + Filter("tid", tid). + Filter("id__in", ids). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// ---------------- 单页 ---------------- + +type cmsOnepagePayload struct { + Title string `json:"title"` + Path string `json:"path"` + Content string `json:"content"` + Sort int `json:"sort"` + Status int8 `json:"status"` +} + +// ListAll GET /backend/allonepages +func (c *BackendOnepageController) ListAll() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + var rows []models.CmsFrontendOnepage + _, err = models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取单页列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsOnepageToMap(r)) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +// Create POST /backend/createonepage +func (c *BackendOnepageController) Create() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsOnepagePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + path := strings.TrimSpace(p.Path) + if title == "" { + c.cmsJSONErr(400, 400, "标题不能为空") + return + } + if path == "" { + c.cmsJSONErr(400, 400, "路径标识不能为空") + return + } + if strings.ContainsAny(path, "/\\ ") { + c.cmsJSONErr(400, 400, "路径标识只能包含字母数字与下划线") + return + } + + // path 租户内唯一 + cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("tid", tid). + Filter("path", path). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + c.cmsJSONErr(400, 400, "该路径标识已存在") + return + } + + now := time.Now() + row := models.CmsFrontendOnepage{ + Tid: tid, + Title: title, + Path: path, + Content: p.Content, + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + if row.Status == 0 { + row.Status = 1 + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "创建失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update POST /backend/editonepage/:id +func (c *BackendOnepageController) Update() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsOnepagePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + path := strings.TrimSpace(p.Path) + if path != "" && strings.ContainsAny(path, "/\\ ") { + c.cmsJSONErr(400, 400, "路径标识只能包含字母数字与下划线") + return + } + if path != "" { + dup, _ := models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("tid", tid). + Filter("path", path). + Filter("delete_time__isnull", true). + Exclude("id", id). + Count() + if dup > 0 { + c.cmsJSONErr(400, 400, "该路径标识已存在") + return + } + } + + now := time.Now() + fields := map[string]interface{}{ + "title": strings.TrimSpace(p.Title), + "content": p.Content, + "sort": p.Sort, + "update_time": now, + } + if path != "" { + fields["path"] = path + } + if p.Status > 0 { + fields["status"] = p.Status + } + n, err := models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "单页不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deleteonepage/:id +func (c *BackendOnepageController) Delete() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "单页不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/backend_theme.go b/go/controllers/backend_theme.go new file mode 100644 index 0000000..12b810e --- /dev/null +++ b/go/controllers/backend_theme.go @@ -0,0 +1,339 @@ +package controllers + +import ( + "encoding/json" + "io" + "regexp" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendThemeController 官网模板选用(租户端只读选用,模板管理在 platform 端) +type BackendThemeController struct { + beego.Controller +} + +func (c *BackendThemeController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendThemeController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +var reThemeCode = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`) + +// List GET /backend/theme 仅返回启用中的模板(含内置 default) +func (c *BackendThemeController) List() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + var rows []models.CmsFrontendTemplate + _, err = models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取模板列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)+1) + hasDefault := false + for _, r := range rows { + if r.Code == "default" { + hasDefault = true + } + list = append(list, map[string]interface{}{ + "key": r.Code, + "name": r.Name, + "description": r.Description, + "preview": r.Preview, + "version": "1.0", + "author": "", + }) + } + // 内置 default 模板始终可选,即使未登记入库 + if !hasDefault { + list = append([]map[string]interface{}{{ + "key": "default", + "name": "默认模板", + "description": "系统内置默认模板", + "preview": "/themes/default/static/preview.png", + "version": "1.0", + "author": "", + }}, list...) + } + + current := cmsTenantTemplateCode(tid) + if current == "" { + current = "default" + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "currentTheme": current}, + } + _ = c.ServeJSON() +} + +// cmsTenantTemplateCode 读取租户当前选用的模板编码 +func cmsTenantTemplateCode(tid uint64) string { + 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 "" + } + return row.TemplateCode +} + +// Switch POST /backend/theme/switch 切换租户使用的模板(写入 template_code) +func (c *BackendThemeController) Switch() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var body struct { + Tid uint64 `json:"tid"` + ThemeKey string `json:"theme_key"` + } + if err := json.Unmarshal(raw, &body); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + if tid == 0 { + tid = body.Tid + } + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + code := strings.TrimSpace(body.ThemeKey) + if code == "" || !reThemeCode.MatchString(code) { + c.cmsJSONErr(400, 400, "模板编码不合法") + return + } + // 非内置 default 模板必须已登记且启用中 + if code != "default" { + cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("code", code). + Filter("status", 1). + Filter("delete_time__isnull", true). + Count() + if cnt == 0 { + c.cmsJSONErr(400, 400, "模板不存在或已停用") + return + } + } + + if err := cmsUpsertTenantTemplate(tid, code); err != nil { + c.cmsJSONErr(500, 500, "切换失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "切换成功", "data": map[string]interface{}{"currentTheme": code}} + _ = c.ServeJSON() +} + +// cmsUpsertTenantTemplate 写入租户模板编码(站点设置不存在则创建) +func cmsUpsertTenantTemplate(tid uint64, code string) error { + now := time.Now() + cnt, err := models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + return err + } + if cnt == 0 { + _, err = models.Orm.Insert(&models.TenantSiteSetting{ + Tid: tid, + TemplateCode: code, + CreateTime: now, + UpdateTime: &now, + }) + return err + } + _, err = models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"template_code": code, "update_time": now}) + return err +} + +// GetData GET /backend/theme/data 返回站点设置(供模板配置展示) +func (c *BackendThemeController) GetData() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + var row models.TenantSiteSetting + err = models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + One(&row) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取失败") + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "tid": tid, + "sitename": row.Sitename, + "logo": row.Logo, + "logow": row.Logow, + "ico": row.Ico, + "description": row.Description, + "copyright": row.Copyright, + "companyname": row.Companyname, + "icp": row.Icp, + "companyintroduction": row.Companyintroduction, + "template_code": row.TemplateCode, + }, + } + _ = c.ServeJSON() +} + +// SaveData POST /backend/theme/data 保存站点设置(模板配置用,白名单字段) +func (c *BackendThemeController) SaveData() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + + fields := map[string]interface{}{} + allowed := []string{"sitename", "logo", "logow", "ico", "description", "copyright", "companyname", "icp", "companyintroduction"} + for _, k := range allowed { + if v, ok := body[k]; ok { + fields[k] = strings.TrimSpace(toString(v)) + } + } + if len(fields) == 0 { + c.cmsJSONErr(400, 400, "没有可保存的字段") + return + } + fields["update_time"] = time.Now() + + cnt, err := models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Count() + if err != nil { + c.cmsJSONErr(500, 500, "保存失败") + return + } + if cnt == 0 { + now := time.Now() + row := &models.TenantSiteSetting{Tid: tid, CreateTime: now, UpdateTime: &now} + if v, ok := fields["sitename"]; ok { + row.Sitename = v.(string) + } + if v, ok := fields["logo"]; ok { + row.Logo = v.(string) + } + if v, ok := fields["logow"]; ok { + row.Logow = v.(string) + } + if v, ok := fields["ico"]; ok { + row.Ico = v.(string) + } + if v, ok := fields["description"]; ok { + row.Description = v.(string) + } + if v, ok := fields["copyright"]; ok { + row.Copyright = v.(string) + } + if v, ok := fields["companyname"]; ok { + row.Companyname = v.(string) + } + if v, ok := fields["icp"]; ok { + row.Icp = v.(string) + } + if v, ok := fields["companyintroduction"]; ok { + row.Companyintroduction = v.(string) + } + if _, err := models.Orm.Insert(row); err != nil { + c.cmsJSONErr(500, 500, "保存失败") + return + } + } else { + if _, err := models.Orm.QueryTable(new(models.TenantSiteSetting)). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(fields); err != nil { + c.cmsJSONErr(500, 500, "保存失败") + return + } + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +// toString 宽松地把 JSON 值转成字符串 +func toString(v interface{}) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/go/controllers/platform_template.go b/go/controllers/platform_template.go new file mode 100644 index 0000000..49319b8 --- /dev/null +++ b/go/controllers/platform_template.go @@ -0,0 +1,576 @@ +package controllers + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "server/models" + "server/pkg/tagengine" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformTemplateController 官网模板管理(平台端专用) +// 模板文件存磁盘 themes/{code}/,DB 仅登记元信息;租户端只读选用。 +type PlatformTemplateController struct { + beego.Controller +} + +func (c *PlatformTemplateController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// cmsThemesRoot 模板根目录(相对运行目录),渲染与静态路径保持一致 +func cmsThemesRoot() string { + return "themes" +} + +// cmsTemplateSafePath 校验 code 与相对路径,返回模板文件的绝对安全路径(防目录穿越) +func cmsTemplateSafePath(code, rel string) (string, error) { + if !reThemeCode.MatchString(code) { + return "", fmt.Errorf("模板编码不合法") + } + rel = strings.TrimSpace(rel) + if rel == "" { + return "", fmt.Errorf("文件路径不能为空") + } + rel = strings.ReplaceAll(rel, "\\", "/") + if strings.Contains(rel, "..") { + return "", fmt.Errorf("文件路径不合法") + } + cleaned := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+rel)), "/") + if cleaned == "" || cleaned == "." { + return "", fmt.Errorf("文件路径不合法") + } + root, err := filepath.Abs(cmsThemesRoot()) + if err != nil { + return "", fmt.Errorf("模板目录解析失败") + } + full := filepath.Join(root, code, filepath.FromSlash(cleaned)) + abs, err := filepath.Abs(full) + if err != nil || !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return "", fmt.Errorf("文件路径不合法") + } + return abs, nil +} + +// cmsTemplateDirExists 判断模板目录是否存在 +func cmsTemplateDirExists(code string) bool { + if !reThemeCode.MatchString(code) { + return false + } + info, err := os.Stat(filepath.Join(cmsThemesRoot(), code)) + return err == nil && info.IsDir() +} + +func cmsTemplateToMap(row models.CmsFrontendTemplate) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "code": row.Code, + "name": row.Name, + "description": row.Description, + "preview": row.Preview, + "status": row.Status, + "sort": row.Sort, + "dir_exists": cmsTemplateDirExists(row.Code), + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + } +} + +// Index GET /platform/template/index 模板列表(DB 登记) +func (c *PlatformTemplateController) Index() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + var rows []models.CmsFrontendTemplate + _, err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "获取模板列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsTemplateToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": len(list)}, + } + _ = c.ServeJSON() +} + +// cmsScanThemeDirs 扫描 themes/ 目录,返回磁盘上的模板编码列表 +func cmsScanThemeDirs() []string { + entries, err := os.ReadDir(cmsThemesRoot()) + if err != nil { + return nil + } + out := []string{} + for _, e := range entries { + if e.IsDir() && reThemeCode.MatchString(e.Name()) { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +// Scan POST /platform/template/scan 扫描 themes/ 目录,新目录自动登记入库 +func (c *PlatformTemplateController) Scan() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + codes := cmsScanThemeDirs() + added := 0 + now := time.Now() + for _, code := range codes { + cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("code", code). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + continue + } + name := code + if code == "default" { + name = "默认模板" + } + row := models.CmsFrontendTemplate{ + Code: code, + Name: name, + Description: "目录扫描自动登记", + Preview: "/themes/" + code + "/static/preview.png", + Status: 1, + CreateTime: now, + UpdateTime: &now, + } + if _, err := models.Orm.Insert(&row); err == nil { + added++ + } + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "扫描完成", + "data": map[string]interface{}{"scanned": len(codes), "added": added}, + } + _ = c.ServeJSON() +} + +// Upload POST /platform/template/upload 上传模板 zip(解压到 themes/{code}/) +func (c *PlatformTemplateController) Upload() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + f, header, err := c.GetFile("file") + if err != nil || header == nil { + c.jsonErr(400, 400, "请上传模板 zip 文件") + return + } + defer f.Close() + + code := strings.TrimSpace(c.GetString("code")) + if code == "" { + // 未显式指定编码时取上传文件名(去扩展名) + code = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) + } + code = strings.ToLower(strings.TrimSpace(code)) + if !reThemeCode.MatchString(code) { + c.jsonErr(400, 400, "模板编码不合法(仅允许字母数字下划线中划线)") + return + } + + if header.Size > 50<<20 { + c.jsonErr(400, 400, "模板包过大(限制 50MB)") + return + } + + tmp, err := os.CreateTemp("", "yz_template_*.zip") + if err != nil { + c.jsonErr(500, 500, "临时文件创建失败") + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := io.Copy(tmp, f); err != nil { + tmp.Close() + c.jsonErr(500, 500, "上传失败") + return + } + tmp.Close() + + if err := cmsUnzipTemplate(tmpPath, filepath.Join(cmsThemesRoot(), code)); err != nil { + c.jsonErr(400, 400, "解压失败: "+err.Error()) + return + } + + // 解压成功后自动登记/更新 DB + now := time.Now() + cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("code", code). + Filter("delete_time__isnull", true). + Count() + if cnt == 0 { + row := models.CmsFrontendTemplate{ + Code: code, + Name: code, + Description: "zip 上传自动登记", + Preview: "/themes/" + code + "/static/preview.png", + Status: 1, + CreateTime: now, + UpdateTime: &now, + } + _, _ = models.Orm.Insert(&row) + } else { + _, _ = models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("code", code). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"update_time": now}) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "上传成功", + "data": map[string]interface{}{"code": code}, + } + _ = c.ServeJSON() +} + +// cmsUnzipTemplate 解压模板 zip 到目标目录; +// 支持 zip 内含单一顶层目录的情况(自动剥离),并拦截 zip-slip。 +func cmsUnzipTemplate(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("无效的 zip 文件") + } + defer r.Close() + + // 探测是否所有文件都在同一顶层目录下 + topDir := "" + singleTop := true + for _, f := range r.File { + name := strings.ReplaceAll(f.Name, "\\", "/") + if name == "" || strings.Contains(name, "..") { + continue + } + parts := strings.Split(strings.Trim(name, "/"), "/") + if topDir == "" { + topDir = parts[0] + } else if parts[0] != topDir { + singleTop = false + break + } + } + if len(r.File) == 0 { + return fmt.Errorf("zip 内容为空") + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("模板目录创建失败") + } + destAbs, _ := filepath.Abs(destDir) + + totalSize := int64(0) + for _, f := range r.File { + name := strings.ReplaceAll(f.Name, "\\", "/") + if name == "" || strings.Contains(name, "..") { + continue + } + rel := name + if singleTop && topDir != "" { + parts := strings.Split(strings.Trim(name, "/"), "/") + if len(parts) == 1 && f.FileInfo().IsDir() { + continue // 顶层目录本身 + } + rel = strings.Join(parts[1:], "/") + if rel == "" { + continue + } + } + + target := filepath.Join(destAbs, filepath.FromSlash(rel)) + tabs, _ := filepath.Abs(target) + if !strings.HasPrefix(tabs, destAbs+string(filepath.Separator)) { + continue // 越界条目直接跳过 + } + + if f.FileInfo().IsDir() { + _ = os.MkdirAll(tabs, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(tabs), 0o755) + + rc, err := f.Open() + if err != nil { + return fmt.Errorf("读取 zip 条目失败") + } + out, err := os.Create(tabs) + if err != nil { + rc.Close() + return fmt.Errorf("写入模板文件失败") + } + n, err := io.Copy(out, rc) + out.Close() + rc.Close() + if err != nil { + return fmt.Errorf("写入模板文件失败") + } + totalSize += n + if totalSize > 200<<20 { + return fmt.Errorf("解压内容过大(限制 200MB)") + } + } + return nil +} + +// Files GET /platform/template/files/:code 列出模板目录下可编辑文件 +func (c *PlatformTemplateController) Files() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + code := c.GetString(":code") + if !cmsTemplateDirExists(code) { + c.jsonErr(404, 404, "模板目录不存在") + return + } + + base := filepath.Join(cmsThemesRoot(), code) + files := []map[string]interface{}{} + _ = filepath.Walk(base, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".html" && ext != ".css" && ext != ".js" { + return nil + } + rel, _ := filepath.Rel(base, path) + files = append(files, map[string]interface{}{ + "path": filepath.ToSlash(rel), + "size": info.Size(), + }) + return nil + }) + sort.Slice(files, func(i, j int) bool { + return files[i]["path"].(string) < files[j]["path"].(string) + }) + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"code": code, "files": files}, + } + _ = c.ServeJSON() +} + +// ReadFile GET /platform/template/file?code=&path= 读取模板文件内容 +func (c *PlatformTemplateController) ReadFile() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + code := strings.TrimSpace(c.GetString("code")) + path := strings.TrimSpace(c.GetString("path")) + full, err := cmsTemplateSafePath(code, path) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + ext := strings.ToLower(filepath.Ext(full)) + if ext != ".html" && ext != ".css" && ext != ".js" { + c.jsonErr(400, 400, "仅支持读取 html/css/js 文件") + return + } + data, err := os.ReadFile(full) + if err != nil { + c.jsonErr(404, 404, "文件不存在") + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"code": code, "path": path, "content": string(data)}, + } + _ = c.ServeJSON() +} + +// SaveFile POST /platform/template/file/save 保存模板文件(路径校验防穿越) +func (c *PlatformTemplateController) SaveFile() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body struct { + Code string `json:"code"` + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal(raw, &body); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + full, err := cmsTemplateSafePath(strings.TrimSpace(body.Code), body.Path) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + ext := strings.ToLower(filepath.Ext(full)) + if ext != ".html" && ext != ".css" && ext != ".js" { + c.jsonErr(400, 400, "仅支持保存 html/css/js 文件") + return + } + if len(body.Content) > 2<<20 { + c.jsonErr(400, 400, "文件内容过大(限制 2MB)") + return + } + + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + c.jsonErr(500, 500, "保存失败") + return + } + if err := os.WriteFile(full, []byte(body.Content), 0o644); err != nil { + c.jsonErr(500, 500, "保存失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +// Status POST /platform/template/status 启用/停用模板 +func (c *PlatformTemplateController) Status() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var body struct { + ID uint64 `json:"id"` + Status int8 `json:"status"` + } + if err := json.Unmarshal(raw, &body); err != nil || body.ID == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + if body.Status != 0 && body.Status != 1 { + c.jsonErr(400, 400, "状态值不合法") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("id", body.ID). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"status": body.Status, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "操作失败") + return + } + if n == 0 { + c.jsonErr(404, 404, "模板不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "操作成功"} + _ = c.ServeJSON() +} + +// Delete POST /platform/template/delete/:id 删除登记(可选同时删除磁盘目录) +func (c *PlatformTemplateController) Delete() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, _ := c.GetUint64(":id") + if id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + + raw, _ := io.ReadAll(c.Ctx.Request.Body) + var body struct { + RemoveFiles bool `json:"remove_files"` + } + if len(raw) > 0 { + _ = json.Unmarshal(raw, &body) + } + + var row models.CmsFrontendTemplate + err := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row) + if err != nil { + c.jsonErr(404, 404, "模板不存在") + return + } + if row.Code == "default" { + c.jsonErr(400, 400, "内置默认模板不可删除") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("id", id). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败") + return + } + if body.RemoveFiles && cmsTemplateDirExists(row.Code) { + _ = os.RemoveAll(filepath.Join(cmsThemesRoot(), row.Code)) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// Tags GET /platform/template/tags 标签调用说明数据(由引擎注册表元信息生成) +func (c *PlatformTemplateController) Tags() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": tagengine.TagDocs(), + } + _ = c.ServeJSON() +} diff --git a/go/controllers/tenant_site.go b/go/controllers/tenant_site.go new file mode 100644 index 0000000..9a44a62 --- /dev/null +++ b/go/controllers/tenant_site.go @@ -0,0 +1,190 @@ +package controllers + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "server/models" + "server/pkg/tagengine" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// TenantSiteController 租户官网前台渲染(Go 直出 HTML,无需登录) +// 路由:GET / | /news | /news/:id | /page/:path +type TenantSiteController struct { + beego.Controller +} + +// siteNotFoundPage 未匹配域名的提示页 +const siteNotFoundPage = ` + +站点不存在 + +

站点不存在

当前域名未绑定任何租户官网,请检查域名配置。

+` + +// renderErrorPage 渲染失败的兜底页 +const renderErrorPage = ` + +页面异常 + +

页面暂时无法访问

%s

+` + +// resolveTid 按访问 Host 识别租户 ID。 +// 规则:dev 环境支持 X-Tenant-ID 头切换 → localhost/127.0.0.1 默认租户 1 +// → 平台主域名池命中返回租户 1(平台站点)→ 租户域名表命中返回对应 tid +// → 全部未命中返回 0,false(不再默认回落租户 1) +func (c *TenantSiteController) resolveTid() (uint64, bool) { + runmode, _ := beego.AppConfig.String("runmode") + if runmode == "dev" { + if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-ID")); h != "" { + if decoded, err := url.QueryUnescape(h); err == nil { + h = decoded + } + if n, err := strconv.ParseUint(h, 10, 64); err == nil && n > 0 { + return n, true + } + var tenant models.SystemTenant + cond := orm.NewCondition().Or("tenant_name", h).Or("tenant_short_name", h) + if err := models.Orm.QueryTable(new(models.SystemTenant)).SetCond(cond).One(&tenant); err == nil { + return tenant.ID, true + } + } + } + + host := c.Ctx.Request.Host + if idx := strings.Index(host, ":"); idx > 0 { + host = host[:idx] + } + if host == "localhost" || host == "127.0.0.1" { + return 1, true + } + + // 平台主域名池命中 → 平台站点(tid 1 + 默认模板) + poolCnt, _ := models.Orm.QueryTable(new(models.SystemDomainPool)). + Filter("main_domain", host). + Filter("status", 1). + Filter("delete_time__isnull", true). + Count() + if poolCnt > 0 { + return 1, true + } + + var td models.SystemTenantDomain + err := models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("full_domain", host). + Filter("status", 1). + Filter("delete_time__isnull", true). + One(&td) + if err == nil && td.Tid != nil && *td.Tid > 0 { + return *td.Tid, true + } + return 0, false +} + +// resolveTemplateCode 解析租户选用模板编码,缺失/停用/目录不存在均回退 default +func (c *TenantSiteController) resolveTemplateCode(tid uint64) string { + code := strings.TrimSpace(cmsTenantTemplateCode(tid)) + if code != "" && code != "default" { + cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). + Filter("code", code). + Filter("status", 1). + Filter("delete_time__isnull", true). + Count() + if cnt == 0 { + code = "" + } + } + if code == "" { + code = "default" + } + if !cmsTemplateDirExists(code) { + code = "default" + } + return code +} + +// writeHTML 直出 HTML +func (c *TenantSiteController) writeHTML(status int, html string) { + c.Ctx.Output.SetStatus(status) + c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8") + c.Ctx.WriteString(html) +} + +// render 解析并输出模板文件 +func (c *TenantSiteController) render(file string, mod func(ctx *tagengine.RenderCtx)) { + tid, ok := c.resolveTid() + if !ok { + c.writeHTML(404, siteNotFoundPage) + return + } + + code := c.resolveTemplateCode(tid) + dir := filepath.Join(cmsThemesRoot(), code) + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + c.writeHTML(404, fmt.Sprintf(renderErrorPage, "模板页面不存在")) + return + } + + ctx := &tagengine.RenderCtx{Tid: tid, Page: 1, PageSize: 10} + if mod != nil { + mod(ctx) + } + out, err := tagengine.Render(dir, file, ctx) + if err != nil { + c.writeHTML(500, fmt.Sprintf(renderErrorPage, "模板渲染失败")) + return + } + c.writeHTML(200, out) +} + +// Index GET / 首页 +func (c *TenantSiteController) Index() { + c.render("index.html", nil) +} + +// News GET /news 新闻列表页(?page=N 分页) +func (c *TenantSiteController) News() { + page, _ := c.GetInt("page", 1) + if page < 1 { + page = 1 + } + c.render("news.html", func(ctx *tagengine.RenderCtx) { + ctx.Page = page + }) +} + +// NewsDetail GET /news/:id 文章详情页 +func (c *TenantSiteController) NewsDetail() { + id, _ := c.GetUint64(":id") + if id == 0 { + c.writeHTML(404, fmt.Sprintf(renderErrorPage, "文章不存在")) + return + } + c.render("news_detail.html", func(ctx *tagengine.RenderCtx) { + ctx.ArticleID = id + }) +} + +// Page GET /page/:path 单页 +func (c *TenantSiteController) Page() { + path := strings.TrimSpace(c.GetString(":path")) + if path == "" || strings.ContainsAny(path, "/\\.") { + c.writeHTML(404, fmt.Sprintf(renderErrorPage, "页面不存在")) + return + } + c.render("page.html", func(ctx *tagengine.RenderCtx) { + ctx.PagePath = path + }) +} diff --git a/go/main.go b/go/main.go index e03b7f3..6a86842 100644 --- a/go/main.go +++ b/go/main.go @@ -21,6 +21,8 @@ func main() { // 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件 beego.SetStaticPath("/uploads", "uploads") + // 官网模板静态资源:themes/{code}/static/ 下的图片/JS/CSS + beego.SetStaticPath("/themes", "themes") // 启动日程提醒定时任务 services.StartReminderScheduler(make(chan struct{})) diff --git a/go/models/cms_frontend.go b/go/models/cms_frontend.go new file mode 100644 index 0000000..ab008af --- /dev/null +++ b/go/models/cms_frontend.go @@ -0,0 +1,78 @@ +package models + +import "time" + +// CmsFrontendTemplate 官网模板登记 yz_cms_frontend_template +// 由 platform 端登记管理(上传/编辑/启停),租户端只读选用。 +type CmsFrontendTemplate struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Code string `orm:"column(code);size(50)" json:"code"` + Name string `orm:"column(name);size(100)" json:"name"` + Description string `orm:"column(description);size(500);default()" json:"description"` + Preview string `orm:"column(preview);size(500);default()" json:"preview"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsFrontendTemplate) TableName() string { + return "yz_cms_frontend_template" +} + +// CmsFrontendBanner 官网 Banner yz_cms_frontend_banner(按租户隔离) +type CmsFrontendBanner struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(255)" json:"title"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Image string `orm:"column(image);size(500);default()" json:"image"` + Url string `orm:"column(url);size(500);default()" json:"url"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsFrontendBanner) TableName() string { + return "yz_cms_frontend_banner" +} + +// CmsFrontendFriendlink 官网友情链接 yz_cms_frontend_friendlink(按租户隔离) +type CmsFrontendFriendlink struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + LinkName string `orm:"column(link_name);size(100)" json:"link_name"` + LinkUrl string `orm:"column(link_url);size(500);default()" json:"link_url"` + LinkLogo string `orm:"column(link_logo);size(500);default()" json:"link_logo"` + Description string `orm:"column(description);size(500);default()" json:"description"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsFrontendFriendlink) TableName() string { + return "yz_cms_frontend_friendlink" +} + +// CmsFrontendOnepage 官网单页 yz_cms_frontend_onepage(按租户隔离,path 租户内唯一) +type CmsFrontendOnepage struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(255)" json:"title"` + Path string `orm:"column(path);size(100)" json:"path"` + Content string `orm:"column(content);type(longtext);null" json:"content"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsFrontendOnepage) TableName() string { + return "yz_cms_frontend_onepage" +} diff --git a/go/models/init.go b/go/models/init.go index 3df4a55..77ab1aa 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -70,6 +70,11 @@ func Init(_ string) { new(CmsArticleCategory), new(CmsArticle), + new(CmsFrontendTemplate), + new(CmsFrontendBanner), + new(CmsFrontendFriendlink), + new(CmsFrontendOnepage), + new(SystemReminderList), new(SystemNormalSetting), diff --git a/go/models/system_tenant_site_setting.go b/go/models/system_tenant_site_setting.go index 04bac88..0dbbff9 100644 --- a/go/models/system_tenant_site_setting.go +++ b/go/models/system_tenant_site_setting.go @@ -20,6 +20,9 @@ type TenantSiteSetting struct { Companyname string `orm:"column(companyname);size(255);null" json:"companyname"` Icp string `orm:"column(icp);size(255);null" json:"icp"` + // TemplateCode 租户选用的官网模板编码(对应 themes/{code}/ 目录) + TemplateCode string `orm:"column(template_code);size(50);default('')" json:"template_code"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add;null" json:"create_time"` UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"` DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` diff --git a/go/pkg/tagengine/engine.go b/go/pkg/tagengine/engine.go new file mode 100644 index 0000000..96edc91 --- /dev/null +++ b/go/pkg/tagengine/engine.go @@ -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 "" + }) +} diff --git a/go/pkg/tagengine/meta.go b/go/pkg/tagengine/meta.go new file mode 100644 index 0000000..054dc7e --- /dev/null +++ b/go/pkg/tagengine/meta.go @@ -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: "{yz:sitename}\n\"{yz:sitename}\"\n

{yz:icp} {yz:copyright}

", + }, + { + 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(