增加租户官网模块
This commit is contained in:
+5
-1
@@ -1,2 +1,6 @@
|
||||
# METETEME WEBSITE
|
||||
# 美天智能科技官网前端
|
||||
# 美天智能科技官网前端
|
||||
|
||||
> **⚠️ 废弃预告**:官网前台已迁移至 Go 标签模板引擎直出渲染(`go/themes/` + `go/pkg/tagengine`,
|
||||
> 由 `tenant_site.go` 按租户域名直出 HTML)。本 frontend 程序在引擎上线且平台主域名官网验证通过后废弃删除,
|
||||
> 不再新增功能,请勿继续在此基础上开发。
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>站点不存在</title>
|
||||
<style>body{font-family:"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#333;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{background:#fff;padding:48px 64px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}
|
||||
h1{font-size:22px;margin:0 0 12px}p{color:#888;font-size:14px;margin:0}</style></head>
|
||||
<body><div class="box"><h1>站点不存在</h1><p>当前域名未绑定任何租户官网,请检查域名配置。</p></div></body>
|
||||
</html>`
|
||||
|
||||
// renderErrorPage 渲染失败的兜底页
|
||||
const renderErrorPage = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>页面异常</title>
|
||||
<style>body{font-family:"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#333;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{background:#fff;padding:48px 64px;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}
|
||||
h1{font-size:22px;margin:0 0 12px}p{color:#888;font-size:14px;margin:0}</style></head>
|
||||
<body><div class="box"><h1>页面暂时无法访问</h1><p>%s</p></div></body>
|
||||
</html>`
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -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{}))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -70,6 +70,11 @@ func Init(_ string) {
|
||||
new(CmsArticleCategory),
|
||||
new(CmsArticle),
|
||||
|
||||
new(CmsFrontendTemplate),
|
||||
new(CmsFrontendBanner),
|
||||
new(CmsFrontendFriendlink),
|
||||
new(CmsFrontendOnepage),
|
||||
|
||||
new(SystemReminderList),
|
||||
|
||||
new(SystemNormalSetting),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ""
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tagengine
|
||||
|
||||
// TagMeta 标签元信息:由注册表统一维护,供 platform 端"标签调用说明"页输出。
|
||||
// 新增标签时在此登记一条,文档页自动同步,避免文档与实现脱节。
|
||||
type TagMeta struct {
|
||||
Name string `json:"name"` // 标签名
|
||||
Category string `json:"category"` // 分类:global 全局 / loop 循环 / single 单页 / other 其他
|
||||
Syntax string `json:"syntax"` // 语法示例
|
||||
Desc string `json:"desc"` // 用途说明
|
||||
Params []string `json:"params"` // 可用参数(key=说明)
|
||||
Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段
|
||||
Example string `json:"example"` // 模板内示例代码
|
||||
}
|
||||
|
||||
// TagDocs 返回全部已登记标签的说明数据
|
||||
func TagDocs() []TagMeta {
|
||||
return []TagMeta{
|
||||
{
|
||||
Name: "全局站点信息",
|
||||
Category: "global",
|
||||
Syntax: "{yz:sitename} {yz:logo} {yz:logow} {yz:ico} {yz:icp} {yz:copyright} {yz:companyname} {yz:description}",
|
||||
Desc: "取自当前租户的站点设置(backend 端 - 站点设置),直接替换为对应文本/地址,无需闭合。",
|
||||
Params: nil,
|
||||
Fields: nil,
|
||||
Example: "<title>{yz:sitename}</title>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\" />\n<p>{yz:icp} {yz:copyright}</p>",
|
||||
},
|
||||
{
|
||||
Name: "nav 导航菜单",
|
||||
Category: "loop",
|
||||
Syntax: "{yz:nav}...{/yz:nav}",
|
||||
Desc: "循环输出当前租户在 backend 端配置的前台导航菜单(yz_backend_menu_front)。",
|
||||
Params: nil,
|
||||
Fields: []string{"id 菜单ID", "name 菜单名称", "url 跳转路径", "children 子菜单HTML(<ul class=\"yz-sub-menu\">结构,无子菜单时为空)"},
|
||||
Example: "{yz:nav}<li><a href=\"[field:url/]\">[field:name/]</a>[field:children/]</li>{/yz:nav}",
|
||||
},
|
||||
{
|
||||
Name: "banner 轮播图",
|
||||
Category: "loop",
|
||||
Syntax: "{yz:banner row=\"5\"}...{/yz:banner}",
|
||||
Desc: "循环输出当前租户启用中的 Banner(backend 端 - Banner管理),按 sort 升序。",
|
||||
Params: []string{"row 输出条数,默认 10"},
|
||||
Fields: []string{"id BannerID", "title 标题", "desc 简介", "image 图片地址", "url 跳转地址"},
|
||||
Example: "{yz:banner}<a href=\"[field:url/]\"><img src=\"[field:image/]\" alt=\"[field:title/]\" /></a>{/yz:banner}",
|
||||
},
|
||||
{
|
||||
Name: "arclist 文章列表",
|
||||
Category: "loop",
|
||||
Syntax: "{yz:arclist row=\"6\" cateid=\"2\" titlelen=\"30\" flag=\"recommend\"}...{/yz:arclist}",
|
||||
Desc: "循环输出当前租户已发布的文章,默认按 置顶>最新 排序。",
|
||||
Params: []string{"row 输出条数,默认 10,最大 50", "cateid 文章分类ID,不填为全部", "titlelen 标题截断字数,不填为完整标题", "flag recommend=推荐 / top=置顶 / hot=按阅读量排序"},
|
||||
Fields: []string{"id 文章ID", "title 标题(受 titlelen 影响)", "titlefull 完整标题", "desc 摘要", "image 缩略图", "arcurl 详情页链接(/news/ID)", "pubdate 发布日期", "views 阅读量", "author 作者"},
|
||||
Example: "{yz:arclist row=\"6\" titlelen=\"20\"}\n<li><a href=\"[field:arcurl/]\">[field:title/]</a><span>[field:pubdate/]</span></li>\n{/yz:arclist}",
|
||||
},
|
||||
{
|
||||
Name: "arcview 文章详情",
|
||||
Category: "single",
|
||||
Syntax: "{yz:arcview}...{/yz:arcview}",
|
||||
Desc: "仅在文章详情页(/news/:id)模板内有效,输出当前文章信息。",
|
||||
Params: nil,
|
||||
Fields: []string{"id 文章ID", "title 标题", "content 富文本正文", "desc 摘要", "image 缩略图", "author 作者", "pubdate 发布时间", "views 阅读量"},
|
||||
Example: "{yz:arcview}\n<h1>[field:title/]</h1>\n<p>[field:pubdate/] 阅读 [field:views/]</p>\n<div class=\"content\">[field:content/]</div>\n{/yz:arcview}",
|
||||
},
|
||||
{
|
||||
Name: "friendlink 友情链接",
|
||||
Category: "loop",
|
||||
Syntax: "{yz:friendlink}...{/yz:friendlink}",
|
||||
Desc: "循环输出当前租户启用中的友情链接(backend 端 - 友情链接),按 sort 升序。",
|
||||
Params: []string{"row 输出条数,默认 20"},
|
||||
Fields: []string{"id 链接ID", "name 链接名称", "url 链接地址", "logo Logo地址", "description 描述"},
|
||||
Example: "{yz:friendlink}<a href=\"[field:url/]\" target=\"_blank\">[field:name/]</a>{/yz:friendlink}",
|
||||
},
|
||||
{
|
||||
Name: "onepage 单页内容",
|
||||
Category: "single",
|
||||
Syntax: "{yz:onepage path=\"about\" field=\"content\"/}",
|
||||
Desc: "输出指定单页(backend 端 - 单页管理)的标题或正文;在单页路由(/page/:path)模板内可省略 path,自动取当前单页。",
|
||||
Params: []string{"path 单页路径标识,单页路由模板内可省略", "field 输出字段,content(默认) / title"},
|
||||
Fields: nil,
|
||||
Example: "{yz:onepage field=\"title\"/>\n<div class=\"content\">{yz:onepage/}</div>",
|
||||
},
|
||||
{
|
||||
Name: "pagelist 分页",
|
||||
Category: "other",
|
||||
Syntax: "{yz:pagelist/}",
|
||||
Desc: "在新闻列表页(/news)模板内输出分页链接(上一页/页码/下一页),链接指向 /news?page=N,样式类名 yz-pagination/yz-page。",
|
||||
Params: nil,
|
||||
Fields: nil,
|
||||
Example: "<div class=\"pager\">{yz:pagelist/}</div>",
|
||||
},
|
||||
{
|
||||
Name: "include 包含子模板",
|
||||
Category: "other",
|
||||
Syntax: "{yz:include file=\"header.html\"/}",
|
||||
Desc: "在当前模板位置引入同目录下的子模板文件(如公共头部/底部),最大嵌套 5 层。",
|
||||
Params: []string{"file 子模板文件名,如 header.html"},
|
||||
Fields: nil,
|
||||
Example: "{yz:include file=\"header.html\"/}",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package tagengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
const (
|
||||
dateLayout = "2006-01-02"
|
||||
datetimeLayout = "2006-01-02 15:04"
|
||||
maxIncludeRows = 200
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterGlobal(globalSiteInfo)
|
||||
RegisterBlock("nav", navProvider)
|
||||
RegisterBlock("banner", bannerProvider)
|
||||
RegisterBlock("arclist", arclistProvider)
|
||||
RegisterBlock("arcview", arcviewProvider)
|
||||
RegisterBlock("friendlink", friendlinkProvider)
|
||||
RegisterSelfClose("onepage", onepageHandler)
|
||||
RegisterSelfClose("pagelist", pagelistHandler)
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// truncateTitle 按字符数(而非字节)安全截断标题
|
||||
func truncateTitle(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return s
|
||||
}
|
||||
rs := []rune(s)
|
||||
if len(rs) <= n {
|
||||
return s
|
||||
}
|
||||
return string(rs[:n]) + "..."
|
||||
}
|
||||
|
||||
// publishedCond 已发布状态条件:兼容历史 status=1 与现行 status=2(已发布)
|
||||
func publishedCond(qs orm.QuerySeter) orm.QuerySeter {
|
||||
return qs.Filter("status__in", []int8{1, 2})
|
||||
}
|
||||
|
||||
// globalSiteInfo 全局标签:站点设置各字段
|
||||
func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) {
|
||||
out := map[string]string{
|
||||
"sitename": "", "logo": "", "logow": "", "ico": "", "icp": "",
|
||||
"copyright": "", "companyname": "", "description": "",
|
||||
}
|
||||
var row models.TenantSiteSetting
|
||||
err := models.Orm.QueryTable(new(models.TenantSiteSetting)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return out, nil
|
||||
}
|
||||
out["sitename"] = row.Sitename
|
||||
out["logo"] = row.Logo
|
||||
out["logow"] = row.Logow
|
||||
out["ico"] = row.Ico
|
||||
out["icp"] = row.Icp
|
||||
out["copyright"] = row.Copyright
|
||||
out["companyname"] = row.Companyname
|
||||
out["description"] = row.Description
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML
|
||||
func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
|
||||
var rows []models.BackendMenuFront
|
||||
_, err := models.Orm.QueryTable(new(models.BackendMenuFront)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("pid", 0).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("sort", "id").
|
||||
Limit(maxIncludeRows).
|
||||
All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var children []models.BackendMenuFront
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendMenuFront)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("pid__gt", 0).
|
||||
OrderBy("sort", "id").
|
||||
Limit(maxIncludeRows).
|
||||
All(&children)
|
||||
|
||||
childMap := map[int64][]models.BackendMenuFront{}
|
||||
for _, c := range children {
|
||||
childMap[c.Pid] = append(childMap[c.Pid], c)
|
||||
}
|
||||
|
||||
out := make([]map[string]string, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
childHTML := ""
|
||||
if subs, ok := childMap[int64(m.ID)]; ok && len(subs) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<ul class="yz-sub-menu">`)
|
||||
for _, s := range subs {
|
||||
sb.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, s.Path, s.Title))
|
||||
}
|
||||
sb.WriteString(`</ul>`)
|
||||
childHTML = sb.String()
|
||||
}
|
||||
out = append(out, map[string]string{
|
||||
"id": strconv.FormatUint(m.ID, 10),
|
||||
"name": m.Title,
|
||||
"url": m.Path,
|
||||
"children": childHTML,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bannerProvider 首页 Banner 循环
|
||||
func bannerProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
|
||||
row := atoiDefault(params["row"], 10)
|
||||
var rows []models.CmsFrontendBanner
|
||||
_, err := models.Orm.QueryTable(new(models.CmsFrontendBanner)).
|
||||
Filter("tid", tid).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("sort", "id").
|
||||
Limit(row).
|
||||
All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]string, 0, len(rows))
|
||||
for _, b := range rows {
|
||||
out = append(out, map[string]string{
|
||||
"id": strconv.FormatUint(b.ID, 10),
|
||||
"title": b.Title,
|
||||
"desc": b.Desc,
|
||||
"image": b.Image,
|
||||
"url": b.Url,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// friendlinkProvider 友情链接循环
|
||||
func friendlinkProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
|
||||
row := atoiDefault(params["row"], 20)
|
||||
var rows []models.CmsFrontendFriendlink
|
||||
_, err := models.Orm.QueryTable(new(models.CmsFrontendFriendlink)).
|
||||
Filter("tid", tid).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("sort", "id").
|
||||
Limit(row).
|
||||
All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]string, 0, len(rows))
|
||||
for _, f := range rows {
|
||||
out = append(out, map[string]string{
|
||||
"id": strconv.FormatUint(f.ID, 10),
|
||||
"name": f.LinkName,
|
||||
"url": f.LinkUrl,
|
||||
"logo": f.LinkLogo,
|
||||
"description": f.Description,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// arclistProvider 文章列表循环,支持 row/cateid/titlelen/flag 参数
|
||||
func arclistProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
|
||||
row := atoiDefault(params["row"], 10)
|
||||
if row <= 0 || row > 50 {
|
||||
row = 10
|
||||
}
|
||||
titleLen := atoiDefault(params["titlelen"], 0)
|
||||
|
||||
qs := publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true))
|
||||
if cid, err := strconv.ParseUint(strings.TrimSpace(params["cateid"]), 10, 64); err == nil && cid > 0 {
|
||||
qs = qs.Filter("cate_id", cid)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params["flag"])) {
|
||||
case "recommend":
|
||||
qs = qs.Filter("recommend", 1)
|
||||
case "top":
|
||||
qs = qs.Filter("top", 1)
|
||||
case "hot":
|
||||
qs = qs.OrderBy("-views", "-id")
|
||||
}
|
||||
|
||||
var rows []models.CmsArticle
|
||||
order := []string{"-top", "-id"}
|
||||
if strings.ToLower(strings.TrimSpace(params["flag"])) == "hot" {
|
||||
order = []string{"-views", "-id"}
|
||||
}
|
||||
// 列表页携带页码时按页偏移,配合 {yz:pagelist/} 分页
|
||||
offset := 0
|
||||
if ctx.Page > 1 {
|
||||
offset = (ctx.Page - 1) * row
|
||||
}
|
||||
_, err := qs.OrderBy(order...).Limit(row, offset).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return articleRows(rows, titleLen), nil
|
||||
}
|
||||
|
||||
// articleRows 文章行转标签字段
|
||||
func articleRows(rows []models.CmsArticle, titleLen int) []map[string]string {
|
||||
out := make([]map[string]string, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
pub := ""
|
||||
if a.PublishTime != nil {
|
||||
pub = a.PublishTime.Format(dateLayout)
|
||||
} else {
|
||||
pub = a.CreateTime.Format(dateLayout)
|
||||
}
|
||||
title := a.Title
|
||||
if titleLen > 0 {
|
||||
title = truncateTitle(title, titleLen)
|
||||
}
|
||||
out = append(out, map[string]string{
|
||||
"id": strconv.FormatUint(a.ID, 10),
|
||||
"title": title,
|
||||
"titlefull": a.Title,
|
||||
"desc": a.Desc,
|
||||
"image": a.Image,
|
||||
"arcurl": "/news/" + strconv.FormatUint(a.ID, 10),
|
||||
"pubdate": pub,
|
||||
"views": strconv.Itoa(a.Views),
|
||||
"author": a.Author,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// arcviewProvider 文章详情(详情页模板内),取当前路由文章
|
||||
func arcviewProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
|
||||
if ctx.ArticleID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var a models.CmsArticle
|
||||
err := models.Orm.QueryTable(new(models.CmsArticle)).
|
||||
Filter("id", ctx.ArticleID).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&a)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
pub := ""
|
||||
if a.PublishTime != nil {
|
||||
pub = a.PublishTime.Format(datetimeLayout)
|
||||
} else {
|
||||
pub = a.CreateTime.Format(datetimeLayout)
|
||||
}
|
||||
return []map[string]string{{
|
||||
"id": strconv.FormatUint(a.ID, 10),
|
||||
"title": a.Title,
|
||||
"content": a.Content,
|
||||
"desc": a.Desc,
|
||||
"image": a.Image,
|
||||
"author": a.Author,
|
||||
"pubdate": pub,
|
||||
"views": strconv.Itoa(a.Views),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// onepageHandler 单页内容 {yz:onepage path="about" field="content"/}
|
||||
func onepageHandler(tid uint64, params map[string]string, ctx *RenderCtx) (string, error) {
|
||||
path := strings.TrimSpace(params["path"])
|
||||
if path == "" {
|
||||
path = ctx.PagePath
|
||||
}
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
field := strings.ToLower(strings.TrimSpace(params["field"]))
|
||||
if field == "" {
|
||||
field = "content"
|
||||
}
|
||||
|
||||
var row models.CmsFrontendOnepage
|
||||
err := models.Orm.QueryTable(new(models.CmsFrontendOnepage)).
|
||||
Filter("tid", tid).
|
||||
Filter("path", path).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
switch field {
|
||||
case "title":
|
||||
return row.Title, nil
|
||||
default:
|
||||
return row.Content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newsQueryBase 新闻列表统一查询条件(pagelist 与 news.html 保持一致)
|
||||
func newsQueryBase(tid uint64) orm.QuerySeter {
|
||||
return publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true))
|
||||
}
|
||||
|
||||
// pagelistHandler 分页链接 {yz:pagelist/},配合 /news?page=N
|
||||
func pagelistHandler(tid uint64, params map[string]string, ctx *RenderCtx) (string, error) {
|
||||
pageSize := ctx.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
total, err := newsQueryBase(tid).Count()
|
||||
if err != nil || total == 0 {
|
||||
return "", nil
|
||||
}
|
||||
pages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
cur := ctx.Page
|
||||
if cur < 1 {
|
||||
cur = 1
|
||||
}
|
||||
if cur > pages {
|
||||
cur = pages
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<nav class="yz-pagination">`)
|
||||
if cur > 1 {
|
||||
sb.WriteString(fmt.Sprintf(`<a class="yz-page prev" href="/news?page=%d">上一页</a>`, cur-1))
|
||||
}
|
||||
start := cur - 3
|
||||
if start < 1 {
|
||||
start = 1
|
||||
}
|
||||
end := cur + 3
|
||||
if end > pages {
|
||||
end = pages
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
if i == cur {
|
||||
sb.WriteString(fmt.Sprintf(`<span class="yz-page current">%d</span>`, i))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(`<a class="yz-page" href="/news?page=%d">%d</a>`, i, i))
|
||||
}
|
||||
}
|
||||
if cur < pages {
|
||||
sb.WriteString(fmt.Sprintf(`<a class="yz-page next" href="/news?page=%d">下一页</a>`, cur+1))
|
||||
}
|
||||
sb.WriteString(`</nav>`)
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -132,6 +132,27 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/editCategory/:id", &controllers.BackendArticleCategoryController{}, "post:Update")
|
||||
beego.Router("/backend/categories/:id/status", &controllers.BackendArticleCategoryController{}, "patch:UpdateStatus")
|
||||
|
||||
// 官网前台内容(Banner / 友链 / 单页,yz_cms_frontend_*,按租户隔离)
|
||||
beego.Router("/backend/allbanners", &controllers.BackendBannerController{}, "get:ListAll")
|
||||
beego.Router("/backend/createbanner", &controllers.BackendBannerController{}, "post:Create")
|
||||
beego.Router("/backend/editbanner/:id", &controllers.BackendBannerController{}, "post:Update")
|
||||
beego.Router("/backend/deletebanner/:id", &controllers.BackendBannerController{}, "delete:Delete")
|
||||
|
||||
beego.Router("/backend/friendlinks", &controllers.BackendFriendlinkController{}, "get:List;post:Create")
|
||||
beego.Router("/backend/friendlinks/all", &controllers.BackendFriendlinkController{}, "get:ListAll")
|
||||
beego.Router("/backend/friendlinks/batchdelete", &controllers.BackendFriendlinkController{}, "post:BatchDelete")
|
||||
beego.Router("/backend/friendlinks/:id", &controllers.BackendFriendlinkController{}, "put:Update;delete:Delete")
|
||||
|
||||
beego.Router("/backend/allonepages", &controllers.BackendOnepageController{}, "get:ListAll")
|
||||
beego.Router("/backend/createonepage", &controllers.BackendOnepageController{}, "post:Create")
|
||||
beego.Router("/backend/editonepage/:id", &controllers.BackendOnepageController{}, "post:Update")
|
||||
beego.Router("/backend/deleteonepage/:id", &controllers.BackendOnepageController{}, "delete:Delete")
|
||||
|
||||
// 官网模板选用(只读列表 + 切换,模板管理在 platform 端)
|
||||
beego.Router("/backend/theme", &controllers.BackendThemeController{}, "get:List")
|
||||
beego.Router("/backend/theme/switch", &controllers.BackendThemeController{}, "post:Switch")
|
||||
beego.Router("/backend/theme/data", &controllers.BackendThemeController{}, "get:GetData;post:SaveData")
|
||||
|
||||
// 域名管理(主域名池 / 租户域名)
|
||||
beego.Router("/backend/domain/pool/index", &controllers.BackendDomainPoolController{}, "get:Index")
|
||||
beego.Router("/backend/domain/pool/getEnabledDomains", &controllers.BackendDomainPoolController{}, "get:GetEnabledDomains")
|
||||
|
||||
@@ -8,6 +8,12 @@ import (
|
||||
|
||||
// Register 注册前端站点(index)路由。
|
||||
func Register() {
|
||||
// 租户官网前台渲染(Go 直出 HTML,标签引擎解析 themes/{code}/)
|
||||
beego.Router("/", &controllers.TenantSiteController{}, "get:Index")
|
||||
beego.Router("/news", &controllers.TenantSiteController{}, "get:News")
|
||||
beego.Router("/news/:id", &controllers.TenantSiteController{}, "get:NewsDetail")
|
||||
beego.Router("/page/:path", &controllers.TenantSiteController{}, "get:Page")
|
||||
|
||||
beego.Router("/index/headmenu", &controllers.IndexPortalController{}, "get:GetHeadMenu")
|
||||
beego.Router("/index/footerdata", &controllers.IndexPortalController{}, "get:GetFooterData")
|
||||
beego.Router("/index/newscentertop4", &controllers.IndexPortalController{}, "get:GetNewsCenterTop4")
|
||||
|
||||
@@ -269,4 +269,15 @@ func Register() {
|
||||
beego.Router("/platform/reminder/:id", &controllers.PlatformReminderController{}, "get:GetReminderDetail;put:UpdateReminder;delete:DeleteReminder")
|
||||
beego.Router("/platform/reminder", &controllers.PlatformReminderController{}, "post:CreateReminder")
|
||||
beego.Router("/platform/reminder/batchDelete", &controllers.PlatformReminderController{}, "post:BatchDeleteReminder")
|
||||
|
||||
// 官网模板管理(上传/扫描登记/在线编辑/启停删除 + 标签调用说明)
|
||||
beego.Router("/platform/template/index", &controllers.PlatformTemplateController{}, "get:Index")
|
||||
beego.Router("/platform/template/scan", &controllers.PlatformTemplateController{}, "post:Scan")
|
||||
beego.Router("/platform/template/upload", &controllers.PlatformTemplateController{}, "post:Upload")
|
||||
beego.Router("/platform/template/files/:code", &controllers.PlatformTemplateController{}, "get:Files")
|
||||
beego.Router("/platform/template/file", &controllers.PlatformTemplateController{}, "get:ReadFile")
|
||||
beego.Router("/platform/template/file/save", &controllers.PlatformTemplateController{}, "post:SaveFile")
|
||||
beego.Router("/platform/template/status", &controllers.PlatformTemplateController{}, "post:Status")
|
||||
beego.Router("/platform/template/delete/:id", &controllers.PlatformTemplateController{}, "post:Delete")
|
||||
beego.Router("/platform/template/tags", &controllers.PlatformTemplateController{}, "get:Tags")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<footer class="yz-footer">
|
||||
<div class="yz-container">
|
||||
<div class="yz-friendlinks">
|
||||
<span>友情链接:</span>
|
||||
{yz:friendlink}<a href="[field:url/]" target="_blank">[field:name/]</a>{/yz:friendlink}
|
||||
</div>
|
||||
<div class="yz-copyright">
|
||||
<p>{yz:companyname} {yz:copyright}</p>
|
||||
<p>{yz:icp}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="{yz:description}">
|
||||
<title>{yz:sitename}</title>
|
||||
<link rel="icon" href="{yz:ico}">
|
||||
<link rel="stylesheet" href="/themes/default/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="yz-header">
|
||||
<div class="yz-container yz-header-inner">
|
||||
<a class="yz-logo" href="/">
|
||||
<img src="{yz:logo}" alt="{yz:sitename}">
|
||||
</a>
|
||||
<nav class="yz-nav">
|
||||
<ul>
|
||||
{yz:nav}<li><a href="[field:url/]">[field:name/]</a>[field:children/]</li>{/yz:nav}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,39 @@
|
||||
{yz:include file="header.html"/}
|
||||
|
||||
<!-- Banner 轮播 -->
|
||||
<section class="yz-banner">
|
||||
{yz:banner}
|
||||
<a class="yz-banner-item" href="[field:url/]">
|
||||
<img src="[field:image/]" alt="[field:title/]">
|
||||
<div class="yz-banner-caption">
|
||||
<h2>[field:title/]</h2>
|
||||
<p>[field:desc/]</p>
|
||||
</div>
|
||||
</a>
|
||||
{/yz:banner}
|
||||
</section>
|
||||
|
||||
<!-- 新闻动态 -->
|
||||
<section class="yz-section">
|
||||
<div class="yz-container">
|
||||
<div class="yz-section-head">
|
||||
<h2>新闻动态</h2>
|
||||
<a class="yz-more" href="/news">更多 »</a>
|
||||
</div>
|
||||
<ul class="yz-news-grid">
|
||||
{yz:arclist row="4" titlelen="24"}
|
||||
<li class="yz-news-card">
|
||||
<a href="[field:arcurl/]">
|
||||
<img class="yz-news-thumb" src="[field:image/]" alt="[field:titlefull/]" onerror="this.style.display='none'">
|
||||
<h3 title="[field:titlefull/]">[field:title/]</h3>
|
||||
<p class="yz-news-desc">[field:desc/]</p>
|
||||
<span class="yz-news-date">[field:pubdate/]</span>
|
||||
</a>
|
||||
</li>
|
||||
{/yz:arclist}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/themes/default/static/main.js"></script>
|
||||
{yz:include file="footer.html"/}
|
||||
@@ -0,0 +1,24 @@
|
||||
{yz:include file="header.html"/}
|
||||
|
||||
<section class="yz-section">
|
||||
<div class="yz-container">
|
||||
<div class="yz-section-head">
|
||||
<h2>新闻动态</h2>
|
||||
</div>
|
||||
<ul class="yz-news-list">
|
||||
{yz:arclist row="10" titlelen="40"}
|
||||
<li class="yz-news-row">
|
||||
<a class="yz-news-title" href="[field:arcurl/]" title="[field:titlefull/]">[field:title/]</a>
|
||||
<p class="yz-news-desc">[field:desc/]</p>
|
||||
<div class="yz-news-meta">
|
||||
<span>[field:pubdate/]</span>
|
||||
<span>阅读 [field:views/]</span>
|
||||
</div>
|
||||
</li>
|
||||
{/yz:arclist}
|
||||
</ul>
|
||||
<div class="yz-pager">{yz:pagelist/}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{yz:include file="footer.html"/}
|
||||
@@ -0,0 +1,18 @@
|
||||
{yz:include file="header.html"/}
|
||||
|
||||
<section class="yz-section">
|
||||
<div class="yz-container yz-article">
|
||||
{yz:arcview}
|
||||
<h1 class="yz-article-title">[field:title/]</h1>
|
||||
<div class="yz-article-meta">
|
||||
<span>发布时间:[field:pubdate/]</span>
|
||||
<span>作者:[field:author/]</span>
|
||||
<span>阅读:[field:views/]</span>
|
||||
</div>
|
||||
<div class="yz-article-content">[field:content/]</div>
|
||||
{/yz:arcview}
|
||||
<div class="yz-article-back"><a href="/news">« 返回新闻列表</a></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{yz:include file="footer.html"/}
|
||||
@@ -0,0 +1,12 @@
|
||||
{yz:include file="header.html"/}
|
||||
|
||||
<section class="yz-section">
|
||||
<div class="yz-container yz-article">
|
||||
<h1 class="yz-article-title">{yz:onepage field="title"/}</h1>
|
||||
<div class="yz-article-content">
|
||||
{yz:onepage/}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{yz:include file="footer.html"/}
|
||||
@@ -0,0 +1,13 @@
|
||||
// default 模板脚本:Banner 简单轮播
|
||||
(function () {
|
||||
var items = document.querySelectorAll('.yz-banner-item');
|
||||
if (items.length === 0) return;
|
||||
var idx = 0;
|
||||
items[0].classList.add('active');
|
||||
if (items.length === 1) return;
|
||||
setInterval(function () {
|
||||
items[idx].classList.remove('active');
|
||||
idx = (idx + 1) % items.length;
|
||||
items[idx].classList.add('active');
|
||||
}, 4000);
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
/* default 模板样式(自包含,类名统一 yz- 前缀) */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; color: #333; background: #fff; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
ul { list-style: none; }
|
||||
img { max-width: 100%; display: block; }
|
||||
|
||||
.yz-container { width: 1200px; max-width: 92%; margin: 0 auto; }
|
||||
|
||||
/* 头部 */
|
||||
.yz-header { background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.08); position: sticky; top: 0; z-index: 99; }
|
||||
.yz-header-inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||
.yz-logo img { height: 40px; }
|
||||
.yz-nav ul { display: flex; gap: 4px; }
|
||||
.yz-nav li { position: relative; }
|
||||
.yz-nav li > a { display: block; padding: 0 18px; line-height: 64px; font-size: 15px; }
|
||||
.yz-nav li > a:hover { color: #1890ff; }
|
||||
.yz-sub-menu { display: none; position: absolute; left: 0; top: 64px; background: #fff; box-shadow: 0 4px 12px rgba(0,0,0,.1); min-width: 140px; }
|
||||
.yz-nav li:hover .yz-sub-menu { display: block; }
|
||||
.yz-sub-menu li a { line-height: 40px; padding: 0 16px; font-size: 14px; white-space: nowrap; }
|
||||
.yz-sub-menu li a:hover { background: #f5f7fa; color: #1890ff; }
|
||||
|
||||
/* Banner */
|
||||
.yz-banner { position: relative; height: 420px; overflow: hidden; background: #10253f; }
|
||||
.yz-banner-item { display: none; position: relative; height: 420px; }
|
||||
.yz-banner-item.active { display: block; }
|
||||
.yz-banner-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.yz-banner-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 40px 8%; color: #fff; background: linear-gradient(transparent, rgba(0,0,0,.55)); }
|
||||
.yz-banner-caption h2 { font-size: 30px; margin-bottom: 8px; }
|
||||
.yz-banner-caption p { font-size: 15px; opacity: .9; }
|
||||
|
||||
/* 通用区块 */
|
||||
.yz-section { padding: 48px 0; }
|
||||
.yz-section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24px; border-bottom: 2px solid #1890ff; padding-bottom: 12px; }
|
||||
.yz-section-head h2 { font-size: 24px; }
|
||||
.yz-more { font-size: 14px; color: #888; }
|
||||
.yz-more:hover { color: #1890ff; }
|
||||
|
||||
/* 首页新闻卡片 */
|
||||
.yz-news-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||
.yz-news-card a { display: block; border: 1px solid #eee; border-radius: 6px; overflow: hidden; transition: box-shadow .25s; background: #fff; height: 100%; }
|
||||
.yz-news-card a:hover { box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||
.yz-news-thumb { width: 100%; height: 140px; object-fit: cover; }
|
||||
.yz-news-card h3 { font-size: 15px; padding: 12px 14px 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.yz-news-desc { font-size: 13px; color: #888; padding: 0 14px; height: 38px; overflow: hidden; }
|
||||
.yz-news-date { display: block; font-size: 12px; color: #aaa; padding: 8px 14px 12px; }
|
||||
|
||||
/* 新闻列表 */
|
||||
.yz-news-list .yz-news-row { padding: 18px 0; border-bottom: 1px dashed #e5e5e5; }
|
||||
.yz-news-title { font-size: 17px; font-weight: 600; }
|
||||
.yz-news-title:hover { color: #1890ff; }
|
||||
.yz-news-row .yz-news-desc { height: auto; padding: 6px 0; color: #777; font-size: 13px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.yz-news-meta { font-size: 12px; color: #aaa; display: flex; gap: 16px; }
|
||||
|
||||
/* 分页(引擎输出类名 yz-pagination / yz-page) */
|
||||
.yz-pager { margin-top: 32px; text-align: center; }
|
||||
.yz-pagination { display: inline-flex; gap: 6px; }
|
||||
.yz-page { display: inline-block; min-width: 34px; height: 34px; line-height: 32px; padding: 0 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; color: #555; }
|
||||
.yz-page:hover { color: #1890ff; border-color: #1890ff; }
|
||||
.yz-page.current { background: #1890ff; border-color: #1890ff; color: #fff; }
|
||||
|
||||
/* 文章/单页正文 */
|
||||
.yz-article { max-width: 860px; }
|
||||
.yz-article-title { font-size: 26px; text-align: center; margin-bottom: 16px; }
|
||||
.yz-article-meta { text-align: center; color: #999; font-size: 13px; display: flex; gap: 20px; justify-content: center; padding-bottom: 20px; border-bottom: 1px solid #eee; margin-bottom: 24px; }
|
||||
.yz-article-content { font-size: 15px; line-height: 1.9; min-height: 200px; }
|
||||
.yz-article-content img { margin: 12px auto; }
|
||||
.yz-article-back { margin-top: 40px; }
|
||||
.yz-article-back a { color: #1890ff; font-size: 14px; }
|
||||
|
||||
/* 底部 */
|
||||
.yz-footer { background: #10253f; color: #cfd8e3; padding: 28px 0; margin-top: 40px; }
|
||||
.yz-friendlinks { font-size: 13px; padding-bottom: 14px; border-bottom: 1px solid rgba(255,255,255,.12); margin-bottom: 14px; }
|
||||
.yz-friendlinks a { margin-right: 14px; opacity: .85; }
|
||||
.yz-friendlinks a:hover { color: #fff; }
|
||||
.yz-copyright { font-size: 13px; text-align: center; opacity: .8; }
|
||||
.yz-copyright p { margin: 2px 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.yz-news-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.yz-banner, .yz-banner-item { height: 260px; }
|
||||
.yz-nav li > a { padding: 0 10px; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== 官网模板管理(平台端) ====================
|
||||
|
||||
// 模板列表
|
||||
export function getTemplateList(params) {
|
||||
return request({
|
||||
url: '/platform/template/index',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 扫描 themes/ 目录,自动登记新模板
|
||||
export function scanTemplates() {
|
||||
return request({
|
||||
url: '/platform/template/scan',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 上传模板 zip
|
||||
export function uploadTemplate(formData) {
|
||||
return request({
|
||||
url: '/platform/template/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 模板目录下可编辑文件列表
|
||||
export function getTemplateFiles(code) {
|
||||
return request({
|
||||
url: `/platform/template/files/${code}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 读取模板文件内容
|
||||
export function getTemplateFile(code, path) {
|
||||
return request({
|
||||
url: '/platform/template/file',
|
||||
method: 'get',
|
||||
params: { code, path }
|
||||
})
|
||||
}
|
||||
|
||||
// 保存模板文件
|
||||
export function saveTemplateFile(data) {
|
||||
return request({
|
||||
url: '/platform/template/file/save',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 启用/停用模板
|
||||
export function setTemplateStatus(id, status) {
|
||||
return request({
|
||||
url: '/platform/template/status',
|
||||
method: 'post',
|
||||
data: { id, status }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除模板登记(可选删除磁盘目录)
|
||||
export function deleteTemplate(id, removeFiles = false) {
|
||||
return request({
|
||||
url: `/platform/template/delete/${id}`,
|
||||
method: 'post',
|
||||
data: { remove_files: removeFiles }
|
||||
})
|
||||
}
|
||||
|
||||
// 标签调用说明数据
|
||||
export function getTagDocs() {
|
||||
return request({
|
||||
url: '/platform/template/tags',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,18 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/system/email/index.vue"),
|
||||
meta: { requiresAuth: true, title: "邮箱管理" }
|
||||
},
|
||||
{
|
||||
path: "/template/index",
|
||||
name: "PlatformTemplateManage",
|
||||
component: () => import("@/views/template/index.vue"),
|
||||
meta: { requiresAuth: true, title: "官网模板管理" }
|
||||
},
|
||||
{
|
||||
path: "/template/tags",
|
||||
name: "PlatformTemplateTags",
|
||||
component: () => import("@/views/template/tags.vue"),
|
||||
meta: { requiresAuth: true, title: "标签调用说明" }
|
||||
},
|
||||
// 兼容拼写错误的路径重定向
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
|
||||
@@ -1,11 +1,414 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="template-manage">
|
||||
<div class="header-bar">
|
||||
<h2>官网模板管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="uploadVisible = true">
|
||||
<el-icon><Upload /></el-icon>上传模板
|
||||
</el-button>
|
||||
<el-button @click="handleScan" :loading="scanning">
|
||||
<el-icon><Refresh /></el-icon>扫描目录
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||
模板文件存放于服务器 themes/{编码}/ 目录;上传 zip 或扫描目录后自动登记。
|
||||
租户端(backend)只能选用已启用的模板,模板编辑仅平台端可操作。
|
||||
</el-alert>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border style="width: 100%">
|
||||
<el-table-column label="预览" width="140" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.preview"
|
||||
:src="getPreviewUrl(scope.row.preview)"
|
||||
fit="cover"
|
||||
style="width: 110px; height: 66px; border-radius: 4px"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<span v-else style="color: #ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="code" label="编码" width="130" />
|
||||
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="目录" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.dir_exists" type="success" size="small">存在</el-tag>
|
||||
<el-tag v-else type="danger" size="small">缺失</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="scope.row.status === 1"
|
||||
@change="handleStatusChange(scope.row, $event)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="登记时间" width="170" />
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="openEditor(scope.row)">
|
||||
编辑文件
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 上传模板 -->
|
||||
<el-dialog v-model="uploadVisible" title="上传模板 zip" width="460px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="模板编码">
|
||||
<el-input v-model="uploadCode" placeholder="留空则取 zip 文件名(仅字母数字下划线中划线)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="zip 文件">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".zip"
|
||||
:on-change="handleFileChange"
|
||||
:on-exceed="() => ElMessage.warning('只能上传一个 zip,请先移除')"
|
||||
>
|
||||
<el-button type="primary">选择 zip</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUpload">上传并登记</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 在线编辑 -->
|
||||
<el-dialog v-model="editorVisible" :title="`模板文件编辑 - ${editorCode}`" width="80%" top="5vh">
|
||||
<div class="editor-layout">
|
||||
<div class="editor-files">
|
||||
<div
|
||||
v-for="f in editorFiles"
|
||||
:key="f.path"
|
||||
class="file-item"
|
||||
:class="{ active: f.path === currentFile }"
|
||||
@click="openFile(f.path)"
|
||||
>
|
||||
{{ f.path }}
|
||||
</div>
|
||||
<el-empty v-if="editorFiles.length === 0" description="无可编辑文件" :image-size="60" />
|
||||
</div>
|
||||
<div class="editor-main">
|
||||
<div class="editor-toolbar">
|
||||
<span class="editor-path">{{ currentFile || '请选择左侧文件' }}</span>
|
||||
<el-button type="primary" size="small" :loading="saving" :disabled="!currentFile" @click="handleSave">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="fileContent"
|
||||
class="editor-textarea"
|
||||
spellcheck="false"
|
||||
placeholder="选择左侧 html/css/js 文件进行编辑"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getTemplateList,
|
||||
scanTemplates,
|
||||
uploadTemplate,
|
||||
getTemplateFiles,
|
||||
getTemplateFile,
|
||||
saveTemplateFile,
|
||||
setTemplateStatus,
|
||||
deleteTemplate
|
||||
} from '@/api/templateManage'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const scanning = ref(false)
|
||||
|
||||
// 上传相关
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadCode = ref('')
|
||||
const uploadRef = ref(null)
|
||||
const uploadFile = ref(null)
|
||||
|
||||
// 编辑器相关
|
||||
const editorVisible = ref(false)
|
||||
const editorCode = ref('')
|
||||
const editorFiles = ref([])
|
||||
const currentFile = ref('')
|
||||
const fileContent = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const getPreviewUrl = (path) => {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return API_BASE_URL + path
|
||||
}
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none'
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTemplateList()
|
||||
if (res.code === 200) {
|
||||
list.value = res.data.list || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取模板列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取模板列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
scanning.value = true
|
||||
try {
|
||||
const res = await scanTemplates()
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(`扫描完成,新登记 ${res.data.added} 个模板`)
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '扫描失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('扫描失败')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
uploadFile.value = file.raw
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile.value) {
|
||||
ElMessage.warning('请先选择 zip 文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', uploadFile.value)
|
||||
if (uploadCode.value.trim()) {
|
||||
fd.append('code', uploadCode.value.trim())
|
||||
}
|
||||
const res = await uploadTemplate(fd)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('上传成功,模板编码:' + res.data.code)
|
||||
uploadVisible.value = false
|
||||
uploadCode.value = ''
|
||||
uploadFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row, val) => {
|
||||
try {
|
||||
const res = await setTemplateStatus(row.id, val ? 1 : 0)
|
||||
if (res.code === 200) {
|
||||
row.status = val ? 1 : 0
|
||||
ElMessage.success(val ? '已启用' : '已停用(已选用租户将回退默认模板)')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除模板「${row.name}」的登记吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '删除登记',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
distinguishCancelAndClose: true,
|
||||
showClose: true
|
||||
}
|
||||
)
|
||||
const res = await deleteTemplate(row.id, false)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
// 用户取消或接口异常均不提示
|
||||
}
|
||||
}
|
||||
|
||||
const openEditor = async (row) => {
|
||||
if (!row.dir_exists) {
|
||||
ElMessage.warning('模板目录不存在,无法编辑')
|
||||
return
|
||||
}
|
||||
editorCode.value = row.code
|
||||
currentFile.value = ''
|
||||
fileContent.value = ''
|
||||
editorVisible.value = true
|
||||
try {
|
||||
const res = await getTemplateFiles(row.code)
|
||||
if (res.code === 200) {
|
||||
editorFiles.value = res.data.files || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取文件列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取文件列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = async (path) => {
|
||||
currentFile.value = path
|
||||
fileContent.value = ''
|
||||
try {
|
||||
const res = await getTemplateFile(editorCode.value, path)
|
||||
if (res.code === 200) {
|
||||
fileContent.value = res.data.content || ''
|
||||
} else {
|
||||
ElMessage.error(res.msg || '读取失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('读取失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveTemplateFile({
|
||||
code: editorCode.value,
|
||||
path: currentFile.value,
|
||||
content: fileContent.value
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-manage {
|
||||
padding: 16px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 62vh;
|
||||
}
|
||||
.editor-files {
|
||||
width: 220px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.file-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.file-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.file-item.active {
|
||||
background: #ecf5ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.editor-path {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.editor-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
.editor-textarea:focus {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="tag-docs">
|
||||
<div class="header-bar">
|
||||
<h2>标签调用说明</h2>
|
||||
<el-button @click="fetchDocs" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
模板中使用 <b>{yz:标签}</b> 调用数据,循环标签内用 <b>[field:字段/]</b> 输出字段。
|
||||
本页由引擎注册表自动生成,新增标签后无需改文档。
|
||||
</el-alert>
|
||||
|
||||
<div v-loading="loading">
|
||||
<template v-for="cat in categories" :key="cat.key">
|
||||
<div v-if="grouped[cat.key]?.length" class="cat-block">
|
||||
<h3 class="cat-title">{{ cat.label }}</h3>
|
||||
<el-card v-for="tag in grouped[cat.key]" :key="tag.name" class="tag-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="tag-head">
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
<code class="tag-syntax">{{ tag.syntax }}</code>
|
||||
<el-button size="small" text type="primary" @click="copy(tag.syntax)">复制语法</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="tag-desc">{{ tag.desc }}</p>
|
||||
|
||||
<div v-if="tag.params?.length" class="tag-section">
|
||||
<div class="sec-label">参数</div>
|
||||
<ul>
|
||||
<li v-for="p in tag.params" :key="p"><code>{{ paramName(p) }}</code>{{ paramDesc(p) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="tag.fields?.length" class="tag-section">
|
||||
<div class="sec-label">可用字段</div>
|
||||
<div class="field-tags">
|
||||
<el-tag
|
||||
v-for="f in tag.fields"
|
||||
:key="f"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="field-tag"
|
||||
@click="copy('[field:' + fieldName(f) + '/]')"
|
||||
>
|
||||
[field:{{ fieldName(f) }}/]
|
||||
</el-tag>
|
||||
</div>
|
||||
<ul class="field-desc">
|
||||
<li v-for="f in tag.fields" :key="f"><code>[field:{{ fieldName(f) }}/]</code>{{ fieldDesc(f) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="tag.example" class="tag-section">
|
||||
<div class="sec-label">
|
||||
示例
|
||||
<el-button size="small" text type="primary" @click="copy(tag.example)">复制</el-button>
|
||||
</div>
|
||||
<pre class="tag-example">{{ tag.example }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getTagDocs } from '@/api/templateManage'
|
||||
|
||||
const docs = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const categories = [
|
||||
{ key: 'global', label: '全局标签' },
|
||||
{ key: 'loop', label: '循环标签' },
|
||||
{ key: 'single', label: '单条内容标签' },
|
||||
{ key: 'other', label: '其他标签' }
|
||||
]
|
||||
|
||||
const grouped = computed(() => {
|
||||
const out = {}
|
||||
for (const d of docs.value) {
|
||||
if (!out[d.category]) out[d.category] = []
|
||||
out[d.category].push(d)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// "row 输出条数,默认 10" -> 名称与说明拆分
|
||||
const paramName = (p) => (p || '').split(' ')[0]
|
||||
const paramDesc = (p) => {
|
||||
const i = (p || '').indexOf(' ')
|
||||
return i > 0 ? ' — ' + p.slice(i + 1) : ''
|
||||
}
|
||||
const fieldName = (f) => (f || '').split(' ')[0]
|
||||
const fieldDesc = (f) => {
|
||||
const i = (f || '').indexOf(' ')
|
||||
return i > 0 ? ' — ' + f.slice(i + 1) : ''
|
||||
}
|
||||
|
||||
const copy = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDocs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTagDocs()
|
||||
if (res.code === 200) {
|
||||
docs.value = res.data || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取标签说明失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取标签说明失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-docs {
|
||||
padding: 16px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cat-block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cat-title {
|
||||
font-size: 16px;
|
||||
border-left: 3px solid #1890ff;
|
||||
padding-left: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tag-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tag-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.tag-syntax {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #d63384;
|
||||
}
|
||||
.tag-desc {
|
||||
margin: 0 0 12px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tag-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.sec-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tag-section ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.tag-section li {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.field-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-tag {
|
||||
cursor: pointer;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
.field-desc code,
|
||||
.tag-section code {
|
||||
background: #f5f7fa;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
}
|
||||
.tag-example {
|
||||
background: #282c34;
|
||||
color: #abb2bf;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
-- =====================================================================
|
||||
-- 多租户官网标签模板引擎 - 数据库脚本
|
||||
-- 说明:本脚本只交付,不代执行,请在数据库后台手动运行
|
||||
-- 新建表统一使用 yz_cms_frontend_ 前缀(官网前台专用)
|
||||
-- =====================================================================
|
||||
|
||||
-- 1. 模板登记表(platform 端登记,供租户选用)
|
||||
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_template` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(50) NOT NULL DEFAULT '' COMMENT '模板编码,对应 themes/{code}/ 目录',
|
||||
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '模板名称',
|
||||
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '模板描述',
|
||||
`preview` varchar(500) NOT NULL DEFAULT '' COMMENT '预览图地址',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
|
||||
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`delete_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_code` (`code`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网模板登记表';
|
||||
|
||||
-- 2. 官网 Banner 表(按租户隔离)
|
||||
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_banner` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
|
||||
`desc` varchar(500) NOT NULL DEFAULT '' COMMENT '简介',
|
||||
`image` varchar(500) NOT NULL DEFAULT '' COMMENT '图片地址',
|
||||
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '跳转地址',
|
||||
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`delete_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_status` (`tid`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网Banner表';
|
||||
|
||||
-- 3. 友情链接表(按租户隔离)
|
||||
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_friendlink` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`link_name` varchar(100) NOT NULL DEFAULT '' COMMENT '链接名称',
|
||||
`link_url` varchar(500) NOT NULL DEFAULT '' COMMENT '链接地址',
|
||||
`link_logo` varchar(500) NOT NULL DEFAULT '' COMMENT 'Logo地址',
|
||||
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '描述',
|
||||
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`delete_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_status` (`tid`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网友情链接表';
|
||||
|
||||
-- 4. 单页表(按租户隔离,path 租户内唯一)
|
||||
CREATE TABLE IF NOT EXISTS `yz_cms_frontend_onepage` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`tid` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '单页标题',
|
||||
`path` varchar(100) NOT NULL DEFAULT '' COMMENT '访问路径标识,租户内唯一,如 about',
|
||||
`content` longtext COMMENT '富文本内容',
|
||||
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`delete_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_path` (`tid`, `path`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网单页表';
|
||||
|
||||
-- 5. 租户站点设置表新增模板编码字段(租户选用的官网模板)
|
||||
ALTER TABLE `yz_system_tenant_site_setting`
|
||||
ADD COLUMN `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '选用的官网模板编码';
|
||||
|
||||
-- 6. 初始登记内置默认模板(可选,platform 端扫描目录也会自动登记)
|
||||
INSERT INTO `yz_cms_frontend_template` (`code`, `name`, `description`, `preview`, `status`, `sort`, `create_time`)
|
||||
SELECT 'default', '默认模板', '系统内置默认官网模板', '/themes/default/static/preview.png', 1, 0, NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `yz_cms_frontend_template` WHERE `code` = 'default' AND `delete_time` IS NULL
|
||||
);
|
||||
Reference in New Issue
Block a user