1. 增加了几种模板
This commit is contained in:
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -24,4 +24,5 @@
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*.local
|
||||
/uploads/
|
||||
|
||||
@@ -23,7 +23,7 @@ func GetSettings(c *gin.Context) {
|
||||
publicKeys := []string{
|
||||
"site_title", "site_description", "site_author", "site_keywords",
|
||||
"visible_menus", "posts_per_page", "works_per_page", "snippets_per_page",
|
||||
"footer_icp", "contact_email", "homepage_config",
|
||||
"footer_icp", "contact_email", "homepage_config", "site_template",
|
||||
}
|
||||
|
||||
for _, key := range publicKeys {
|
||||
|
||||
556
server/handlers/template.go
Normal file
556
server/handlers/template.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
"github.com/niangaodev/art-code/repositories"
|
||||
"github.com/niangaodev/art-code/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
maxZipSize = 10 << 20 // 上传 zip 上限 10MB
|
||||
maxZipEntries = 200 // 条目数上限
|
||||
maxExtractSize = 50 << 20 // 解压后总量上限 50MB
|
||||
templatesURLBase = "/uploads/templates"
|
||||
)
|
||||
|
||||
var templateSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
// 扩展名白名单(禁 JS/HTML/SVG,防 XSS 与任意执行)
|
||||
var templateExtWhitelist = map[string]bool{
|
||||
".json": true, ".css": true,
|
||||
".woff2": true, ".woff": true, ".ttf": true,
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true, ".ico": true,
|
||||
}
|
||||
|
||||
func templatesUploadDir() string {
|
||||
base := os.Getenv("UPLOADS_BASE_PATH")
|
||||
if base == "" {
|
||||
base = "./uploads"
|
||||
}
|
||||
return filepath.Join(base, "templates")
|
||||
}
|
||||
|
||||
// ---------- 公开接口 ----------
|
||||
|
||||
// GetTemplates 公开:返回所有启用模板的元数据列表
|
||||
func GetTemplates(c *gin.Context) {
|
||||
list, err := repositories.GetActiveTemplates()
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(list))
|
||||
for i := range list {
|
||||
out = append(out, templateMeta(&list[i]))
|
||||
}
|
||||
utils.Success(c, out)
|
||||
}
|
||||
|
||||
// GetTemplateDescriptor 公开:返回模板完整布局描述(自定义渲染引擎拉取)
|
||||
func GetTemplateDescriptor(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
t, err := repositories.GetActiveTemplateBySlug(slug)
|
||||
if err != nil {
|
||||
utils.Error(c, 404, "Template not found or inactive")
|
||||
return
|
||||
}
|
||||
|
||||
var manifest, layout interface{}
|
||||
if t.Manifest != "" {
|
||||
_ = json.Unmarshal([]byte(t.Manifest), &manifest)
|
||||
}
|
||||
if t.LayoutJSON != "" {
|
||||
_ = json.Unmarshal([]byte(t.LayoutJSON), &layout)
|
||||
}
|
||||
|
||||
utils.Success(c, models.TemplateDescriptor{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Type: t.Type,
|
||||
Description: t.Description,
|
||||
Manifest: manifest,
|
||||
Layout: layout,
|
||||
SkinCssPath: t.SkinCSSPath,
|
||||
AssetsBase: fmt.Sprintf("%s/%s", templatesURLBase, t.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 后台接口 ----------
|
||||
|
||||
// AdminGetTemplates 后台:全部模板列表
|
||||
func AdminGetTemplates(c *gin.Context) {
|
||||
list, err := repositories.GetAllTemplates()
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
current := repositories.GetCurrentSiteTemplateSlug()
|
||||
out := make([]map[string]interface{}, 0, len(list))
|
||||
for i := range list {
|
||||
m := templateMeta(&list[i])
|
||||
m["isCurrent"] = list[i].Slug == current
|
||||
out = append(out, m)
|
||||
}
|
||||
utils.Success(c, out)
|
||||
}
|
||||
|
||||
// AdminUploadTemplate 后台:上传自定义布局包(zip)
|
||||
func AdminUploadTemplate(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxZipSize)
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.Error(c, 400, "请上传 zip 文件(字段名 file)")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验扩展名
|
||||
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
|
||||
utils.Error(c, 400, "仅支持 .zip 格式")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(src, maxZipSize+1))
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
if len(buf) > maxZipSize {
|
||||
utils.Error(c, 400, "zip 超过 10MB 限制")
|
||||
return
|
||||
}
|
||||
|
||||
// zip 魔数校验
|
||||
if len(buf) < 4 || !bytes.Equal(buf[:4], []byte("PK\x03\x04")) {
|
||||
utils.Error(c, 400, "文件不是有效的 zip 包")
|
||||
return
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
||||
if err != nil {
|
||||
utils.Error(c, 400, "zip 解析失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(zr.File) > maxZipEntries {
|
||||
utils.Error(c, 400, fmt.Sprintf("zip 条目数超过 %d 限制", maxZipEntries))
|
||||
return
|
||||
}
|
||||
|
||||
// 遍历校验 + 提取 manifest/layout/skin
|
||||
manifestRaw := ""
|
||||
layoutRaw := ""
|
||||
skinName := ""
|
||||
totalSize := int64(0)
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := filepath.ToSlash(f.Name)
|
||||
if name == "" || strings.HasSuffix(name, "/") {
|
||||
continue // 目录条目
|
||||
}
|
||||
if !safeZipPath(name) {
|
||||
utils.Error(c, 400, "zip 包含非法路径: "+name)
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !templateExtWhitelist[ext] {
|
||||
utils.Error(c, 400, "不允许的文件类型 "+ext+"(仅支持 json/css/字体/图片): "+name)
|
||||
return
|
||||
}
|
||||
if f.UncompressedSize64 > maxExtractSize {
|
||||
utils.Error(c, 400, "单个文件过大: "+name)
|
||||
return
|
||||
}
|
||||
totalSize += int64(f.UncompressedSize64)
|
||||
if totalSize > maxExtractSize {
|
||||
utils.Error(c, 400, "解压后总量超过 50MB 限制")
|
||||
return
|
||||
}
|
||||
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case base == "manifest.json" && !strings.Contains(name, "/"):
|
||||
manifestRaw, err = readZipEntry(f)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
case base == "layout.json" && !strings.Contains(name, "/"):
|
||||
layoutRaw, err = readZipEntry(f)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
case strings.HasSuffix(name, ".css") && !strings.Contains(name, "/"):
|
||||
if skinName == "" {
|
||||
skinName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// manifest 校验
|
||||
if manifestRaw == "" {
|
||||
utils.Error(c, 400, "zip 根目录缺少 manifest.json")
|
||||
return
|
||||
}
|
||||
var manifest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Skin string `json:"skin"`
|
||||
Layout string `json:"layout"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(manifestRaw), &manifest); err != nil {
|
||||
utils.Error(c, 400, "manifest.json 不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
manifest.Name = strings.TrimSpace(manifest.Name)
|
||||
manifest.Slug = strings.TrimSpace(manifest.Slug)
|
||||
manifest.Version = strings.TrimSpace(manifest.Version)
|
||||
if manifest.Name == "" || manifest.Slug == "" || manifest.Version == "" {
|
||||
utils.Error(c, 400, "manifest.json 缺少必填字段 name/slug/version")
|
||||
return
|
||||
}
|
||||
if !templateSlugRe.MatchString(manifest.Slug) {
|
||||
utils.Error(c, 400, "slug 需匹配 ^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
return
|
||||
}
|
||||
// 内置模板 slug 为保留字
|
||||
builtinSlugs := map[string]bool{
|
||||
repositories.BuiltinTemplateClassic: true,
|
||||
repositories.BuiltinTemplateTerminal: true,
|
||||
repositories.BuiltinTemplatePixel: true,
|
||||
repositories.BuiltinTemplateMagazine: true,
|
||||
repositories.BuiltinTemplateNewspaper: true,
|
||||
repositories.BuiltinTemplateBento: true,
|
||||
repositories.BuiltinTemplateGlass: true,
|
||||
repositories.BuiltinTemplateRetro: true,
|
||||
repositories.BuiltinTemplateGuofeng: true,
|
||||
}
|
||||
if builtinSlugs[manifest.Slug] {
|
||||
utils.Error(c, 400, "slug 与内置模板冲突(内置模板 slug 为保留字)")
|
||||
return
|
||||
}
|
||||
if manifest.Type != "" && manifest.Type != repositories.TemplateTypeCustom {
|
||||
utils.Error(c, 400, "manifest.type 必须为 custom")
|
||||
return
|
||||
}
|
||||
// 至少提供 skin 或 layout 之一,且必须在 zip 根目录
|
||||
if manifest.Skin == "" && manifest.Layout == "" {
|
||||
utils.Error(c, 400, "manifest 至少需要 skin 或 layout 字段之一")
|
||||
return
|
||||
}
|
||||
if manifest.Skin != "" && path.Base(filepath.ToSlash(manifest.Skin)) != manifest.Skin {
|
||||
utils.Error(c, 400, "skin 必须是 zip 根目录下的 .css 文件")
|
||||
return
|
||||
}
|
||||
if manifest.Skin != "" && !strings.HasSuffix(manifest.Skin, ".css") {
|
||||
utils.Error(c, 400, "skin 必须是 .css 文件")
|
||||
return
|
||||
}
|
||||
if manifest.Layout != "" && !strings.HasSuffix(manifest.Layout, ".json") {
|
||||
utils.Error(c, 400, "layout 必须是 .json 文件")
|
||||
return
|
||||
}
|
||||
// 校验 zip 中确实包含所声明的文件
|
||||
if manifest.Skin != "" && skinName == "" {
|
||||
utils.Error(c, 400, "zip 中缺少 manifest 声明的 skin 文件: "+manifest.Skin)
|
||||
return
|
||||
}
|
||||
if manifest.Layout != "" && layoutRaw == "" {
|
||||
utils.Error(c, 400, "zip 中缺少 manifest 声明的 layout 文件: "+manifest.Layout)
|
||||
return
|
||||
}
|
||||
// layout.json 若存在必须为合法 JSON
|
||||
if layoutRaw != "" {
|
||||
var probe interface{}
|
||||
if err := json.Unmarshal([]byte(layoutRaw), &probe); err != nil {
|
||||
utils.Error(c, 400, "layout.json 不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// slug 冲突检查
|
||||
if existing, _ := repositories.GetTemplateBySlug(manifest.Slug); existing != nil {
|
||||
utils.Error(c, 409, "模板 slug 已存在: "+manifest.Slug)
|
||||
return
|
||||
}
|
||||
|
||||
// 落盘
|
||||
dir := filepath.Join(templatesUploadDir(), manifest.Slug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
rollback := true
|
||||
defer func() {
|
||||
if rollback {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := filepath.ToSlash(f.Name)
|
||||
if name == "" || strings.HasSuffix(name, "/") {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(dir, filepath.FromSlash(name))
|
||||
// 双重路径校验(Join 后必须仍在 dir 内)
|
||||
rel, err := filepath.Rel(dir, target)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
utils.Error(c, 400, "zip 包含非法路径: "+name)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
if err := writeZipEntry(f, target); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 落库
|
||||
skinCssPath := ""
|
||||
if manifest.Skin != "" {
|
||||
skinCssPath = fmt.Sprintf("%s/%s/%s", templatesURLBase, manifest.Slug, manifest.Skin)
|
||||
}
|
||||
t := models.Template{
|
||||
Slug: manifest.Slug,
|
||||
Name: manifest.Name,
|
||||
Type: repositories.TemplateTypeCustom,
|
||||
Description: manifest.Description,
|
||||
Manifest: manifestRaw,
|
||||
LayoutJSON: layoutRaw,
|
||||
SkinCSSPath: skinCssPath,
|
||||
IsSystem: false,
|
||||
IsActive: false, // 上传后默认停用,需手动启用
|
||||
SortOrder: 100,
|
||||
}
|
||||
if err := repositories.CreateTemplate(&t); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rollback = false
|
||||
utils.SuccessWithMsg(c, "模板上传成功", templateMeta(&t))
|
||||
}
|
||||
|
||||
// AdminUpdateTemplate 后台:更新模板 name/description/isActive
|
||||
func AdminUpdateTemplate(c *gin.Context) {
|
||||
t, ok := getTemplateByIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IsActive *bool `json:"isActive"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Error(c, 400, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
if t.IsSystem {
|
||||
utils.Error(c, 400, "系统内置模板不可修改名称")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
utils.Error(c, 400, "Name is required")
|
||||
return
|
||||
}
|
||||
t.Name = name
|
||||
}
|
||||
if req.Description != nil {
|
||||
t.Description = *req.Description
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
if t.IsSystem {
|
||||
utils.Error(c, 400, "系统内置模板不可停用")
|
||||
return
|
||||
}
|
||||
// 不允许停用当前正在使用的模板
|
||||
if !*req.IsActive && t.Slug == repositories.GetCurrentSiteTemplateSlug() {
|
||||
utils.Error(c, 400, "当前正在使用的模板不能停用")
|
||||
return
|
||||
}
|
||||
t.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := repositories.UpdateTemplate(t); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
utils.SuccessWithMsg(c, "模板已更新", templateMeta(t))
|
||||
}
|
||||
|
||||
// AdminGetTemplateDetail 后台:模板详情(含 manifest/layout 全文,供详情面板展示)
|
||||
func AdminGetTemplateDetail(c *gin.Context) {
|
||||
t, ok := getTemplateByIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var manifest, layout interface{}
|
||||
if t.Manifest != "" {
|
||||
_ = json.Unmarshal([]byte(t.Manifest), &manifest)
|
||||
}
|
||||
if t.LayoutJSON != "" {
|
||||
_ = json.Unmarshal([]byte(t.LayoutJSON), &layout)
|
||||
}
|
||||
m := templateMeta(t)
|
||||
m["isCurrent"] = t.Slug == repositories.GetCurrentSiteTemplateSlug()
|
||||
m["manifest"] = manifest
|
||||
m["layout"] = layout
|
||||
m["assetsBase"] = fmt.Sprintf("%s/%s", templatesURLBase, t.Slug)
|
||||
utils.Success(c, m)
|
||||
}
|
||||
|
||||
// AdminActivateTemplate 后台:将模板设为站点当前模板
|
||||
func AdminActivateTemplate(c *gin.Context) {
|
||||
t, ok := getTemplateByIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !t.IsActive {
|
||||
utils.Error(c, 400, "模板未启用,无法设为当前模板")
|
||||
return
|
||||
}
|
||||
if err := repositories.ActivateTemplate(t.Slug); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
utils.SuccessWithMsg(c, "已切换模板", gin.H{"slug": t.Slug})
|
||||
}
|
||||
|
||||
// AdminDeleteTemplate 后台:删除模板(仅自定义且非当前使用)
|
||||
func AdminDeleteTemplate(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
|
||||
utils.Error(c, 400, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
t, err := repositories.GetTemplateByID(id)
|
||||
if err != nil {
|
||||
utils.Error(c, 404, "Template not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repositories.DeleteTemplate(id); err != nil {
|
||||
if errors.Is(err, repositories.ErrTemplateSystemDelete) {
|
||||
utils.Error(c, 400, "系统内置模板不可删除")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repositories.ErrTemplateInUse) {
|
||||
utils.Error(c, 400, "当前正在使用的模板不可删除,请先切换到其他模板")
|
||||
return
|
||||
}
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 物理删除文件目录
|
||||
_ = os.RemoveAll(filepath.Join(templatesUploadDir(), t.Slug))
|
||||
utils.SuccessWithMsg(c, "模板已删除", nil)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func templateMeta(t *models.Template) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"slug": t.Slug,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"description": t.Description,
|
||||
"skinCssPath": t.SkinCSSPath,
|
||||
"isSystem": t.IsSystem,
|
||||
"isActive": t.IsActive,
|
||||
"sortOrder": t.SortOrder,
|
||||
"createdAt": t.CreatedAt,
|
||||
"updatedAt": t.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func getTemplateByIDParam(c *gin.Context) (*models.Template, bool) {
|
||||
idStr := c.Param("id")
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
|
||||
utils.Error(c, 400, "Invalid ID")
|
||||
return nil, false
|
||||
}
|
||||
t, err := repositories.GetTemplateByID(id)
|
||||
if err != nil {
|
||||
utils.Error(c, 404, "Template not found")
|
||||
return nil, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// safeZipPath 路径穿越防护:拒绝绝对路径、反斜杠、.. 段
|
||||
func safeZipPath(name string) bool {
|
||||
if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") {
|
||||
return false
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) (string, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(f *zip.File, target string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func main() {
|
||||
repositories.MigrateVideoModule()
|
||||
repositories.MigratePptTemplates()
|
||||
repositories.MigrateOSSConfigFields()
|
||||
repositories.MigrateTemplates()
|
||||
|
||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
||||
@@ -115,6 +116,10 @@ func main() {
|
||||
api.GET("/ppt-templates", handlers.GetPptTemplates)
|
||||
api.GET("/ppt-templates/default", handlers.GetDefaultPptTemplate)
|
||||
|
||||
// 前端页面模板
|
||||
api.GET("/templates", handlers.GetTemplates)
|
||||
api.GET("/templates/:slug", handlers.GetTemplateDescriptor)
|
||||
|
||||
// 咨询相关路由
|
||||
api.POST("/inquiries", handlers.SubmitInquiry)
|
||||
api.GET("/email-suffixes", handlers.GetEmailSuffixes)
|
||||
@@ -282,6 +287,14 @@ func main() {
|
||||
authAdmin.PUT("/ppt-templates/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdatePptTemplate)
|
||||
authAdmin.DELETE("/ppt-templates/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeletePptTemplate)
|
||||
|
||||
// 前端页面模板管理 (复用 settings 权限)
|
||||
authAdmin.GET("/templates", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetTemplates)
|
||||
authAdmin.GET("/templates/:id", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetTemplateDetail)
|
||||
authAdmin.POST("/templates/upload", middleware.PermissionMiddleware("settings", "create"), handlers.AdminUploadTemplate)
|
||||
authAdmin.PUT("/templates/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateTemplate)
|
||||
authAdmin.POST("/templates/:id/activate", middleware.PermissionMiddleware("settings", "update"), handlers.AdminActivateTemplate)
|
||||
authAdmin.DELETE("/templates/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteTemplate)
|
||||
|
||||
// 咨询管理
|
||||
authAdmin.GET("/inquiries", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetInquiries)
|
||||
authAdmin.PUT("/inquiries/:id/status", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateInquiryStatus)
|
||||
|
||||
57
server/models/template.go
Normal file
57
server/models/template.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Template 前端页面模板(内置 / 自定义布局包)
|
||||
type Template struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;column:id"`
|
||||
Slug string `json:"slug" gorm:"column:slug;uniqueIndex"`
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
Type string `json:"type" gorm:"column:type"` // builtin | custom
|
||||
Description string `json:"description" gorm:"column:description;type:text"`
|
||||
Manifest string `json:"-" gorm:"column:manifest;type:longtext"` // 解析后的 manifest.json(DB 冗余,避免读盘)
|
||||
LayoutJSON string `json:"-" gorm:"column:layout_json;type:longtext"` // 解析后的 layout.json(仅 custom)
|
||||
SkinCSSPath string `json:"skinCssPath" gorm:"column:skin_css_path;type:varchar(500)"` // 皮肤 CSS 相对路径 /uploads/templates/{slug}/skin.css
|
||||
IsSystem bool `json:"isSystem" gorm:"column:is_system;default:0"`
|
||||
IsActive bool `json:"isActive" gorm:"column:is_active;default:1"`
|
||||
SortOrder int `json:"sortOrder" gorm:"column:sort_order;default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
|
||||
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
|
||||
}
|
||||
|
||||
func (Template) TableName() string {
|
||||
return "templates"
|
||||
}
|
||||
|
||||
func (t *Template) BeforeCreate(tx *gorm.DB) error {
|
||||
now := time.Now().Unix()
|
||||
if t.CreatedAt == 0 {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt == 0 {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Template) BeforeUpdate(tx *gorm.DB) error {
|
||||
t.UpdatedAt = time.Now().Unix()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TemplateDescriptor 公开接口返回的模板描述(供自定义布局渲染引擎拉取)
|
||||
type TemplateDescriptor struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Manifest interface{} `json:"manifest,omitempty"`
|
||||
Layout interface{} `json:"layout,omitempty"`
|
||||
SkinCssPath string `json:"skinCssPath,omitempty"`
|
||||
AssetsBase string `json:"assetsBase"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
)
|
||||
|
||||
// MigrateToBigInt performs schema migration to BigInt timestamps
|
||||
@@ -332,6 +333,77 @@ func MigratePptTemplates() {
|
||||
EnsureSystemPptTemplate()
|
||||
}
|
||||
|
||||
// MigrateTemplates 创建 templates 表并种入内置模板(classic / ubuntu-terminal)
|
||||
func MigrateTemplates() {
|
||||
log.Printf("Migrating templates...")
|
||||
|
||||
if !tableExists("templates") {
|
||||
execSQL(`CREATE TABLE templates (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID',
|
||||
slug VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识(classic/ubuntu-terminal/自定义slug)',
|
||||
name VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板显示名称',
|
||||
type VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'custom' COMMENT '类型(builtin内置/custom自定义)',
|
||||
description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '模板描述',
|
||||
manifest LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'manifest.json 内容(custom)',
|
||||
layout_json LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'layout.json 内容(custom)',
|
||||
skin_css_path VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '皮肤CSS相对路径(/uploads/templates/{slug}/skin.css)',
|
||||
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置模板(0否 1是,不可删除)',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用(0否 1是)',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
|
||||
deleted_at BIGINT NOT NULL DEFAULT 0 COMMENT '软删除时间戳',
|
||||
created_at BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间(Unix秒)',
|
||||
updated_at BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间(Unix秒)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX idx_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前端页面模板表'`)
|
||||
}
|
||||
|
||||
ensureBuiltinTemplate(BuiltinTemplateClassic, "经典", "系统内置经典布局", 0)
|
||||
ensureBuiltinTemplate(BuiltinTemplateTerminal, "Ubuntu 终端", "系统内置终端风格(ls/search/cat 命令交互)", 1)
|
||||
ensureBuiltinTemplate(BuiltinTemplatePixel, "像素", "系统内置像素风(8-bit 游戏机界面)", 2)
|
||||
ensureBuiltinTemplate(BuiltinTemplateMagazine, "杂志", "系统内置杂志风(衬线多栏编辑排版)", 3)
|
||||
ensureBuiltinTemplate(BuiltinTemplateNewspaper, "报纸", "系统内置报纸风(报头多栏纸感排版)", 4)
|
||||
ensureBuiltinTemplate(BuiltinTemplateBento, "Bento 宫格", "系统内置 Bento 宫格风(圆角几何色块)", 5)
|
||||
ensureBuiltinTemplate(BuiltinTemplateGlass, "玻璃拟态", "系统内置玻璃拟态(毛玻璃光感)", 6)
|
||||
ensureBuiltinTemplate(BuiltinTemplateRetro, "复古印刷", "系统内置复古印刷(旧印刷烫金质感)", 7)
|
||||
ensureBuiltinTemplate(BuiltinTemplateGuofeng, "国风水墨", "系统内置国风水墨(宣纸印章山水)", 8)
|
||||
|
||||
// 种入 site_template 默认配置
|
||||
if s, err := GetSettingByKey(SettingKeySiteTemplate); err == nil && s == nil {
|
||||
_ = CreateSetting(&models.Setting{
|
||||
KeyName: SettingKeySiteTemplate,
|
||||
Value: DefaultSiteTemplateSlug,
|
||||
Description: "当前启用的前端页面模板 slug",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ensureBuiltinTemplate 幂等种入内置模板
|
||||
func ensureBuiltinTemplate(slug, name, desc string, sortOrder int) {
|
||||
var count int64
|
||||
config.DB.Model(&models.Template{}).
|
||||
Where("slug = ? AND deleted_at = ?", slug, 0).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
t := models.Template{
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
Type: TemplateTypeBuiltin,
|
||||
Description: desc,
|
||||
IsSystem: true,
|
||||
IsActive: true,
|
||||
SortOrder: sortOrder,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := config.DB.Create(&t).Error; err != nil {
|
||||
log.Printf("Failed to seed builtin template %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateOSSConfigFields 为 oss_configs 补齐各云厂商专用列(幂等)
|
||||
func MigrateOSSConfigFields() {
|
||||
log.Printf("Migrating oss_configs provider fields...")
|
||||
|
||||
145
server/repositories/template_repository.go
Normal file
145
server/repositories/template_repository.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/niangaodev/art-code/config"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
)
|
||||
|
||||
// 内置模板 slug(保留字,自定义上传不可占用)
|
||||
const (
|
||||
BuiltinTemplateClassic = "classic"
|
||||
BuiltinTemplateTerminal = "ubuntu-terminal"
|
||||
BuiltinTemplatePixel = "pixel"
|
||||
BuiltinTemplateMagazine = "magazine"
|
||||
BuiltinTemplateNewspaper = "newspaper"
|
||||
BuiltinTemplateBento = "bento"
|
||||
BuiltinTemplateGlass = "glass"
|
||||
BuiltinTemplateRetro = "retro"
|
||||
BuiltinTemplateGuofeng = "guofeng"
|
||||
DefaultSiteTemplateSlug = "classic"
|
||||
TemplateTypeBuiltin = "builtin"
|
||||
TemplateTypeCustom = "custom"
|
||||
SettingKeySiteTemplate = "site_template"
|
||||
)
|
||||
|
||||
var ErrTemplateSystemDelete = errors.New("system template cannot be deleted")
|
||||
var ErrTemplateInUse = errors.New("template is currently active and cannot be deleted")
|
||||
|
||||
// GetActiveTemplates 获取所有启用模板(公开)
|
||||
func GetActiveTemplates() ([]models.Template, error) {
|
||||
var list []models.Template
|
||||
err := config.DB.Model(&models.Template{}).
|
||||
Where("deleted_at = ? AND is_active = ?", 0, true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetAllTemplates 获取全部模板(后台)
|
||||
func GetAllTemplates() ([]models.Template, error) {
|
||||
var list []models.Template
|
||||
err := config.DB.Model(&models.Template{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetTemplateByID 按 ID 获取模板
|
||||
func GetTemplateByID(id uint) (*models.Template, error) {
|
||||
var t models.Template
|
||||
err := config.DB.Model(&models.Template{}).
|
||||
Where("id = ? AND deleted_at = ?", id, 0).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// GetTemplateBySlug 按 slug 获取模板(含未启用,内部使用)
|
||||
func GetTemplateBySlug(slug string) (*models.Template, error) {
|
||||
var t models.Template
|
||||
err := config.DB.Model(&models.Template{}).
|
||||
Where("slug = ? AND deleted_at = ?", slug, 0).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// GetActiveTemplateBySlug 按 slug 获取启用模板(公开接口,未启用返回 404)
|
||||
func GetActiveTemplateBySlug(slug string) (*models.Template, error) {
|
||||
var t models.Template
|
||||
err := config.DB.Model(&models.Template{}).
|
||||
Where("slug = ? AND deleted_at = ? AND is_active = ?", slug, 0, true).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CreateTemplate 创建模板
|
||||
func CreateTemplate(t *models.Template) error {
|
||||
return config.DB.Create(t).Error
|
||||
}
|
||||
|
||||
// UpdateTemplate 更新模板基础信息
|
||||
func UpdateTemplate(t *models.Template) error {
|
||||
return config.DB.Model(&models.Template{}).
|
||||
Where("id = ? AND deleted_at = ?", t.ID, 0).
|
||||
Updates(map[string]interface{}{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"is_active": t.IsActive,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteTemplate 物理删除自定义模板(内置模板禁止删除)
|
||||
func DeleteTemplate(id uint) error {
|
||||
t, err := GetTemplateByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.IsSystem {
|
||||
return ErrTemplateSystemDelete
|
||||
}
|
||||
if t.Slug == GetCurrentSiteTemplateSlug() {
|
||||
return ErrTemplateInUse
|
||||
}
|
||||
return config.DB.Model(&models.Template{}).
|
||||
Where("id = ?", id).
|
||||
Delete(&models.Template{}).Error
|
||||
}
|
||||
|
||||
// GetCurrentSiteTemplateSlug 读取当前启用的站点模板 slug(缺省 classic)
|
||||
func GetCurrentSiteTemplateSlug() string {
|
||||
s, err := GetSettingByKey(SettingKeySiteTemplate)
|
||||
if err != nil || s == nil || s.Value == "" {
|
||||
return DefaultSiteTemplateSlug
|
||||
}
|
||||
return s.Value
|
||||
}
|
||||
|
||||
// ActivateTemplate 将模板设为站点当前模板
|
||||
func ActivateTemplate(slug string) error {
|
||||
s, err := GetSettingByKey(SettingKeySiteTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return CreateSetting(&models.Setting{
|
||||
KeyName: SettingKeySiteTemplate,
|
||||
Value: slug,
|
||||
Description: "当前启用的前端页面模板 slug",
|
||||
})
|
||||
}
|
||||
s.Value = slug
|
||||
return UpdateSetting(s)
|
||||
}
|
||||
BIN
server/uploads.zip
Normal file
BIN
server/uploads.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user