Add Website Template (#13400)

* feat: Add Website Template

* fix: Don't display the template list

* feat: Add Mcp TopList

* docs: Remove Mcp TopList

* Add more languages

---------

Co-authored-by: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
This commit is contained in:
BugPleaseGo
2026-07-31 10:01:20 +08:00
committed by GitHub
co-authored by CityFun
parent f35b0deb29
commit e11dc5fadd
37 changed files with 2396 additions and 6 deletions
+1
View File
@@ -61,6 +61,7 @@ var (
websiteDnsAccountService = service.NewIWebsiteDnsAccountService()
websiteSSLService = service.NewIWebsiteSSLService()
websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService()
websiteTemplateService = service.NewIWebsiteTemplateService()
nginxService = service.NewINginxService()
+259
View File
@@ -0,0 +1,259 @@
package v2
import (
"io"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/gin-gonic/gin"
)
// @Tags Website Template
// @Summary Page website templates
// @Accept json
// @Param request body request.WebsiteTemplateSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/search [post]
func (b *BaseApi) PageWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, templates, err := websiteTemplateService.PageTemplate(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: templates,
})
}
// @Tags Website Template
// @Summary Create website template
// @Accept json
// @Param request body request.WebsiteTemplateCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建网站模板 [name]","formatEN":"Create website template [name]"}
func (b *BaseApi) CreateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Update website template
// @Accept json
// @Param request body request.WebsiteTemplateUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/update [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新网站模板 [name]","formatEN":"Update website template [name]"}
func (b *BaseApi) UpdateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.UpdateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_templates","output_column":"name","output_value":"name"}],"formatZH":"删除网站模板 [name]","formatEN":"Delete website template [name]"}
func (b *BaseApi) DeleteWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteTemplate(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/get [post]
func (b *BaseApi) GetWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
template, err := websiteTemplateService.GetTemplate(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, template)
}
// @Tags Website Template
// @Summary Upload website template zip
// @Accept multipart/form-data
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/upload [post]
func (b *BaseApi) UploadTemplateZip(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
helper.BadRequest(c, err)
return
}
file, err := fileHeader.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
helper.InternalServer(c, err)
return
}
filePath, variables, err := websiteTemplateService.SaveUploadZip(fileHeader.Filename, content)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, gin.H{"filePath": filePath, "variables": variables})
}
// @Tags Website Template
// @Summary Preview website template
// @Accept json
// @Param request body request.WebsitePreviewReq true "request"
// @Success 200 {object} response.WebsitePreviewDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/preview [post]
func (b *BaseApi) PreviewWebsiteTemplate(c *gin.Context) {
var req request.WebsitePreviewReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
preview, err := websiteTemplateService.Preview(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, preview)
}
// @Tags Website Template
// @Summary Page website template outputs
// @Accept json
// @Param request body request.WebsiteTemplateOutputSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/search [post]
func (b *BaseApi) PageWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, outputs, err := websiteTemplateService.PageOutput(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: outputs,
})
}
// @Tags Website Template
// @Summary Create website template output
// @Accept json
// @Param request body request.WebsiteTemplateOutputCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成模板产物 [name]","formatEN":"Generate template output [name]"}
func (b *BaseApi) CreateWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateOutput(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_template_outputs","output_column":"name","output_value":"name"}],"formatZH":"删除模板产物 [name]","formatEN":"Delete template output [name]"}
func (b *BaseApi) DeleteWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteOutput(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateOutputDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/get [post]
func (b *BaseApi) GetWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
output, err := websiteTemplateService.GetOutput(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, output)
}
+2
View File
@@ -34,6 +34,8 @@ type WebsiteCreate struct {
SiteDir string `json:"siteDir"`
TemplateOutputID uint `json:"templateOutputID"`
RuntimeConfig
FtpConfig
DataBaseConfig
+46
View File
@@ -0,0 +1,46 @@
package request
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
type WebsiteTemplateSearch struct {
dto.PageInfo
Name string `json:"name"`
Type string `json:"type"`
}
type WebsiteTemplateCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateOutputSearch struct {
dto.PageInfo
TemplateID uint `json:"templateID"`
}
type WebsiteTemplateOutputCreate struct {
TemplateID uint `json:"templateID" validate:"required"`
Name string `json:"name" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
type WebsitePreviewReq struct {
TemplateID uint `json:"templateID" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
@@ -0,0 +1,18 @@
package response
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type WebsiteTemplateDTO struct {
model.WebsiteTemplate
}
type WebsiteTemplateOutputDTO struct {
model.WebsiteTemplateOutput
TemplateName string `json:"templateName"`
}
type WebsitePreviewDTO struct {
HTML string `json:"html"`
}
+28
View File
@@ -0,0 +1,28 @@
package model
type WebsiteTemplate struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // single | multi
Content string `gorm:"type:longtext" json:"content"`
FilePath string `json:"filePath"`
Variables string `gorm:"type:text" json:"variables"`
Remark string `json:"remark"`
}
func (w WebsiteTemplate) TableName() string {
return "website_templates"
}
type WebsiteTemplateOutput struct {
BaseModel
Name string `gorm:"not null" json:"name"`
TemplateID uint `gorm:"not null" json:"templateID"`
TemplateType string `json:"templateType"`
VariableValues string `gorm:"type:text" json:"variableValues"`
OutputPath string `json:"outputPath"`
}
func (w WebsiteTemplateOutput) TableName() string {
return "website_template_outputs"
}
+131
View File
@@ -0,0 +1,131 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"gorm.io/gorm"
)
type IWebsiteTemplateRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error)
List(opts ...DBOption) ([]model.WebsiteTemplate, error)
Create(template *model.WebsiteTemplate) error
Save(template *model.WebsiteTemplate) error
DeleteBy(opts ...DBOption) error
WithName(name string) DBOption
WithType(templateType string) DBOption
}
func NewIWebsiteTemplateRepo() IWebsiteTemplateRepo {
return &WebsiteTemplateRepo{}
}
type WebsiteTemplateRepo struct {
}
func (w *WebsiteTemplateRepo) WithName(name string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name like ?", "%"+name+"%")
}
}
func (w *WebsiteTemplateRepo) WithType(templateType string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("type = ?", templateType)
}
}
func (w *WebsiteTemplateRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&templates).Error
return count, templates, err
}
func (w *WebsiteTemplateRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error) {
var template model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
if err := db.First(&template).Error; err != nil {
return nil, err
}
return &template, nil
}
func (w *WebsiteTemplateRepo) List(opts ...DBOption) ([]model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
err := getDb(opts...).Model(&model.WebsiteTemplate{}).Find(&templates).Error
return templates, err
}
func (w *WebsiteTemplateRepo) Create(template *model.WebsiteTemplate) error {
return getDb().Create(template).Error
}
func (w *WebsiteTemplateRepo) Save(template *model.WebsiteTemplate) error {
return getDb().Save(template).Error
}
func (w *WebsiteTemplateRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplate{}).Error
}
type IWebsiteTemplateOutputRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error)
List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error)
Create(output *model.WebsiteTemplateOutput) error
Save(output *model.WebsiteTemplateOutput) error
DeleteBy(opts ...DBOption) error
WithByTemplateID(templateID uint) DBOption
}
func NewIWebsiteTemplateOutputRepo() IWebsiteTemplateOutputRepo {
return &WebsiteTemplateOutputRepo{}
}
type WebsiteTemplateOutputRepo struct {
}
func (w *WebsiteTemplateOutputRepo) WithByTemplateID(templateID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("template_id = ?", templateID)
}
}
func (w *WebsiteTemplateOutputRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&outputs).Error
return count, outputs, err
}
func (w *WebsiteTemplateOutputRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error) {
var output model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
if err := db.First(&output).Error; err != nil {
return nil, err
}
return &output, nil
}
func (w *WebsiteTemplateOutputRepo) List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
err := getDb(opts...).Model(&model.WebsiteTemplateOutput{}).Find(&outputs).Error
return outputs, err
}
func (w *WebsiteTemplateOutputRepo) Create(output *model.WebsiteTemplateOutput) error {
return getDb().Create(output).Error
}
func (w *WebsiteTemplateOutputRepo) Save(output *model.WebsiteTemplateOutput) error {
return getDb().Save(output).Error
}
func (w *WebsiteTemplateOutputRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplateOutput{}).Error
}
+8 -6
View File
@@ -40,12 +40,14 @@ var (
settingRepo = repo.NewISettingRepo()
backupRepo = repo.NewIBackupRepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteTemplateRepo = repo.NewIWebsiteTemplateRepo()
websiteTemplateOutputRepo = repo.NewIWebsiteTemplateOutputRepo()
snapshotRepo = repo.NewISnapshotRepo()
+15
View File
@@ -488,6 +488,21 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
if err = configDefaultNginx(website, domains, appInstall, runtime, create.StreamConfig); err != nil {
return err
}
if create.Type == constant.Static && create.TemplateOutputID > 0 {
templateOutput, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(create.TemplateOutputID))
if err != nil {
return err
}
if templateOutput.OutputPath == "" {
return buserr.New("ErrFileNotFound")
}
if _, err := os.Stat(templateOutput.OutputPath); err != nil {
return buserr.New("ErrFileNotFound")
}
if err := copyDir(templateOutput.OutputPath, GetSitePath(*website, SiteIndexDir)); err != nil {
return err
}
}
if website.Type != constant.Stream {
if err = createWafConfig(website, domains); err != nil {
return err
+419
View File
@@ -0,0 +1,419 @@
package service
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
)
type WebsiteTemplateService struct {
}
type IWebsiteTemplateService interface {
PageTemplate(req request.WebsiteTemplateSearch) (int64, []response.WebsiteTemplateDTO, error)
CreateTemplate(req request.WebsiteTemplateCreate) error
UpdateTemplate(req request.WebsiteTemplateUpdate) error
DeleteTemplate(id uint) error
GetTemplate(id uint) (*response.WebsiteTemplateDTO, error)
SaveUploadZip(fileName string, content []byte) (string, []string, error)
PageOutput(req request.WebsiteTemplateOutputSearch) (int64, []response.WebsiteTemplateOutputDTO, error)
CreateOutput(req request.WebsiteTemplateOutputCreate) error
DeleteOutput(id uint) error
GetOutput(id uint) (*response.WebsiteTemplateOutputDTO, error)
Preview(req request.WebsitePreviewReq) (*response.WebsitePreviewDTO, error)
}
func NewIWebsiteTemplateService() IWebsiteTemplateService {
return &WebsiteTemplateService{}
}
func templateBaseDir() string {
return path.Join(global.Dir.DataDir, "templates")
}
func templateFileDir() string {
return path.Join(templateBaseDir(), "files")
}
func templateOutputDir(id uint) string {
return path.Join(templateBaseDir(), "outputs", fmt.Sprintf("%d", id))
}
func (w WebsiteTemplateService) PageTemplate(req request.WebsiteTemplateSearch) (int64, []response.WebsiteTemplateDTO, error) {
var opts []repo.DBOption
if req.Name != "" {
opts = append(opts, websiteTemplateRepo.WithName(req.Name))
}
if req.Type != "" {
opts = append(opts, websiteTemplateRepo.WithType(req.Type))
}
opts = append(opts, repo.WithOrderDesc("created_at"))
total, templates, err := websiteTemplateRepo.Page(req.Page, req.PageSize, opts...)
if err != nil {
return 0, nil, err
}
var dtos []response.WebsiteTemplateDTO
for _, t := range templates {
dtos = append(dtos, response.WebsiteTemplateDTO{WebsiteTemplate: t})
}
return total, dtos, nil
}
func (w WebsiteTemplateService) CreateTemplate(req request.WebsiteTemplateCreate) error {
if exist, _ := websiteTemplateRepo.GetFirst(repo.WithByName(req.Name)); exist != nil {
return buserr.New("ErrNameIsExist")
}
template := &model.WebsiteTemplate{
Name: req.Name,
Type: req.Type,
Content: req.Content,
FilePath: req.FilePath,
Variables: req.Variables,
Remark: req.Remark,
}
return websiteTemplateRepo.Create(template)
}
func (w WebsiteTemplateService) UpdateTemplate(req request.WebsiteTemplateUpdate) error {
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if exist, _ := websiteTemplateRepo.GetFirst(repo.WithByName(req.Name), repo.WithByNOTID(req.ID)); exist != nil {
return buserr.New("ErrNameIsExist")
}
template.Name = req.Name
template.Type = req.Type
template.Content = req.Content
if req.FilePath != template.FilePath && template.FilePath != "" && strings.HasPrefix(template.FilePath, templateBaseDir()) {
_ = os.Remove(template.FilePath)
}
template.FilePath = req.FilePath
template.Variables = req.Variables
template.Remark = req.Remark
return websiteTemplateRepo.Save(template)
}
func (w WebsiteTemplateService) DeleteTemplate(id uint) error {
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(id))
if err != nil {
return err
}
outputs, _ := websiteTemplateOutputRepo.List(websiteTemplateOutputRepo.WithByTemplateID(id))
for _, output := range outputs {
if output.OutputPath != "" && strings.HasPrefix(output.OutputPath, templateBaseDir()) {
_ = os.RemoveAll(output.OutputPath)
}
}
if template.FilePath != "" && strings.HasPrefix(template.FilePath, templateBaseDir()) {
_ = os.Remove(template.FilePath)
}
if err := websiteTemplateOutputRepo.DeleteBy(websiteTemplateOutputRepo.WithByTemplateID(id)); err != nil {
return err
}
return websiteTemplateRepo.DeleteBy(repo.WithByID(id))
}
func (w WebsiteTemplateService) GetTemplate(id uint) (*response.WebsiteTemplateDTO, error) {
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(id))
if err != nil {
return nil, err
}
return &response.WebsiteTemplateDTO{WebsiteTemplate: *template}, nil
}
func (w WebsiteTemplateService) SaveUploadZip(fileName string, content []byte) (string, []string, error) {
if !strings.HasSuffix(strings.ToLower(fileName), ".zip") {
return "", nil, buserr.WithName("ErrNotSupportType", fileName)
}
dir := templateFileDir()
if err := os.MkdirAll(dir, constant.DirPerm); err != nil {
return "", nil, err
}
filePath := path.Join(dir, fmt.Sprintf("%d_%s", time.Now().Unix(), filepath.Base(fileName)))
if err := os.WriteFile(filePath, content, constant.FilePerm); err != nil {
return "", nil, err
}
return filePath, scanZipVariables(filePath), nil
}
func scanZipVariables(zipPath string) []string {
variables := []string{}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return variables
}
defer reader.Close()
seen := make(map[string]struct{})
for _, file := range reader.File {
if file.FileInfo().IsDir() || !isTextFile(file.Name) {
continue
}
rc, err := file.Open()
if err != nil {
continue
}
content, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
continue
}
for _, match := range templateVarRegex.FindAllStringSubmatch(string(content), -1) {
if _, ok := seen[match[1]]; !ok {
seen[match[1]] = struct{}{}
variables = append(variables, match[1])
}
}
}
return variables
}
func (w WebsiteTemplateService) PageOutput(req request.WebsiteTemplateOutputSearch) (int64, []response.WebsiteTemplateOutputDTO, error) {
var opts []repo.DBOption
if req.TemplateID > 0 {
opts = append(opts, websiteTemplateOutputRepo.WithByTemplateID(req.TemplateID))
}
opts = append(opts, repo.WithOrderDesc("created_at"))
total, outputs, err := websiteTemplateOutputRepo.Page(req.Page, req.PageSize, opts...)
if err != nil {
return 0, nil, err
}
var dtos []response.WebsiteTemplateOutputDTO
for _, output := range outputs {
item := response.WebsiteTemplateOutputDTO{WebsiteTemplateOutput: output}
if template, err := websiteTemplateRepo.GetFirst(repo.WithByID(output.TemplateID)); err == nil {
item.TemplateName = template.Name
}
dtos = append(dtos, item)
}
return total, dtos, nil
}
func (w WebsiteTemplateService) CreateOutput(req request.WebsiteTemplateOutputCreate) error {
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.TemplateID))
if err != nil {
return err
}
valuesJSON, err := json.Marshal(req.VariableValues)
if err != nil {
return err
}
output := &model.WebsiteTemplateOutput{
Name: req.Name,
TemplateID: template.ID,
TemplateType: template.Type,
VariableValues: string(valuesJSON),
}
if err := websiteTemplateOutputRepo.Create(output); err != nil {
return err
}
outputDir := templateOutputDir(output.ID)
if err := renderToDir(template, req.VariableValues, outputDir); err != nil {
_ = websiteTemplateOutputRepo.DeleteBy(repo.WithByID(output.ID))
return err
}
output.OutputPath = outputDir
return websiteTemplateOutputRepo.Save(output)
}
func (w WebsiteTemplateService) DeleteOutput(id uint) error {
output, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(id))
if err != nil {
return err
}
if output.OutputPath != "" && strings.HasPrefix(output.OutputPath, templateBaseDir()) {
_ = os.RemoveAll(output.OutputPath)
}
return websiteTemplateOutputRepo.DeleteBy(repo.WithByID(id))
}
func (w WebsiteTemplateService) GetOutput(id uint) (*response.WebsiteTemplateOutputDTO, error) {
output, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(id))
if err != nil {
return nil, err
}
item := &response.WebsiteTemplateOutputDTO{WebsiteTemplateOutput: *output}
if template, err := websiteTemplateRepo.GetFirst(repo.WithByID(output.TemplateID)); err == nil {
item.TemplateName = template.Name
}
return item, nil
}
func (w WebsiteTemplateService) Preview(req request.WebsitePreviewReq) (*response.WebsitePreviewDTO, error) {
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.TemplateID))
if err != nil {
return nil, err
}
var html string
if template.Type == "single" {
html = renderContent(template.Content, req.VariableValues)
} else {
html, err = renderMainHTMLFromZip(template.FilePath, req.VariableValues)
if err != nil {
return nil, err
}
}
return &response.WebsitePreviewDTO{HTML: html}, nil
}
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
func renderContent(content string, values map[string]string) string {
rendered := templateVarRegex.ReplaceAllStringFunc(content, func(match string) string {
key := templateVarRegex.FindStringSubmatch(match)[1]
if val, ok := values[key]; ok {
return val
}
return ""
})
return rendered
}
func renderToDir(template *model.WebsiteTemplate, values map[string]string, outputDir string) error {
if err := os.MkdirAll(outputDir, constant.DirPerm); err != nil {
return err
}
if template.Type == "single" {
html := renderContent(template.Content, values)
return os.WriteFile(path.Join(outputDir, "index.html"), []byte(html), constant.FilePerm)
}
return unzipAndRender(template.FilePath, outputDir, values)
}
func unzipAndRender(zipPath, outputDir string, values map[string]string) error {
if zipPath == "" {
return buserr.New("ErrFileNotFound")
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer reader.Close()
for _, file := range reader.File {
if err := extractAndRenderFile(file, outputDir, values); err != nil {
return err
}
}
return nil
}
func extractAndRenderFile(file *zip.File, outputDir string, values map[string]string) error {
targetPath := filepath.Join(outputDir, file.Name)
if !strings.HasPrefix(targetPath, filepath.Clean(outputDir)+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path in zip: %s", file.Name)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(targetPath, constant.DirPerm)
}
if err := os.MkdirAll(filepath.Dir(targetPath), constant.DirPerm); err != nil {
return err
}
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
content, err := io.ReadAll(rc)
if err != nil {
return err
}
if isTextFile(file.Name) {
content = []byte(renderContent(string(content), values))
}
return os.WriteFile(targetPath, content, constant.FilePerm)
}
func isTextFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".html", ".htm", ".css", ".js", ".json", ".xml", ".txt", ".svg", ".md":
return true
}
return false
}
func renderMainHTMLFromZip(zipPath string, values map[string]string) (string, error) {
if zipPath == "" {
return "", buserr.New("ErrFileNotFound")
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return "", err
}
defer reader.Close()
var mainFile *zip.File
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
name := strings.ToLower(filepath.Base(file.Name))
if name == "index.html" || name == "index.htm" {
if mainFile == nil || len(file.Name) < len(mainFile.Name) {
mainFile = file
}
}
}
if mainFile == nil {
return "", buserr.New("ErrFileNotFound")
}
rc, err := mainFile.Open()
if err != nil {
return "", err
}
defer rc.Close()
content, err := io.ReadAll(rc)
if err != nil {
return "", err
}
return renderContent(string(content), values), nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, p)
if err != nil {
return err
}
targetPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(targetPath, constant.DirPerm)
}
return copyFile(p, targetPath)
})
}
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
if err := os.MkdirAll(filepath.Dir(dst), constant.DirPerm); err != nil {
return err
}
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
+1
View File
@@ -95,6 +95,7 @@ func InitAgentDB() {
migrations.AddDatabaseUserTable,
migrations.AddBackupRecordArgs,
migrations.AddFtpIdentity,
migrations.AddWebsiteTemplateTable,
migrations.AddComposePinned,
})
if err := m.Migrate(); err != nil {
+9
View File
@@ -73,6 +73,8 @@ var AddTable = &gormigrate.Migration{
&model.Website{},
&model.WebsiteAcmeAccount{},
&model.WebsiteCA{},
&model.WebsiteTemplate{},
&model.WebsiteTemplateOutput{},
&model.WebsiteDnsAccount{},
&model.WebsiteDomain{},
&model.WebsiteSSL{},
@@ -1699,6 +1701,13 @@ var AddFtpIdentity = &gormigrate.Migration{
},
}
var AddWebsiteTemplateTable = &gormigrate.Migration{
ID: "20260728-add-website-template-table",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&model.WebsiteTemplate{},
&model.WebsiteTemplateOutput{},
)
var AddComposePinned = &gormigrate.Migration{
ID: "20260729-add-compose-pinned",
Migrate: func(tx *gorm.DB) error {
+1
View File
@@ -16,6 +16,7 @@ func commonGroups() []CommonRouter {
&WebsiteDnsAccountRouter{},
&WebsiteAcmeAccountRouter{},
&WebsiteSSLRouter{},
&WebsiteTemplateRouter{},
&DatabaseRouter{},
&NginxRouter{},
&RuntimeRouter{},
+28
View File
@@ -0,0 +1,28 @@
package router
import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/gin-gonic/gin"
)
type WebsiteTemplateRouter struct {
}
func (a *WebsiteTemplateRouter) InitRouter(Router *gin.RouterGroup) {
groupRouter := Router.Group("websites/templates")
baseApi := v2.ApiGroupApp.BaseApi
{
groupRouter.POST("/search", baseApi.PageWebsiteTemplate)
groupRouter.POST("", baseApi.CreateWebsiteTemplate)
groupRouter.POST("/update", baseApi.UpdateWebsiteTemplate)
groupRouter.POST("/del", baseApi.DeleteWebsiteTemplate)
groupRouter.POST("/get", baseApi.GetWebsiteTemplate)
groupRouter.POST("/upload", baseApi.UploadTemplateZip)
groupRouter.POST("/preview", baseApi.PreviewWebsiteTemplate)
groupRouter.POST("/outputs/search", baseApi.PageWebsiteTemplateOutput)
groupRouter.POST("/outputs", baseApi.CreateWebsiteTemplateOutput)
groupRouter.POST("/outputs/del", baseApi.DeleteWebsiteTemplateOutput)
groupRouter.POST("/outputs/get", baseApi.GetWebsiteTemplateOutput)
}
}
+2
View File
@@ -107,6 +107,7 @@ func LoadMenus() string {
Children: []dto.ShowMenu{
{ID: "31", Disabled: false, Title: "menu.website", IsShow: true, Label: "Website", Path: "/websites", Sort: 100},
{ID: "32", Disabled: false, Title: "menu.ssl", IsShow: true, Label: "SSL", Path: "/websites/ssl", Sort: 200},
{ID: "34", Disabled: false, Title: "menu.template", IsShow: true, Label: "WebsiteTemplate", Path: "/websites/templates", Sort: 250},
{ID: "33", Disabled: false, Title: "menu.runtime", IsShow: true, Label: "PHP", Path: "/websites/runtimes/php", Sort: 300},
}},
{ID: "5", Disabled: false, Title: "menu.database", IsShow: true, Label: "Database-Menu", Path: "/databases", Sort: 500},
@@ -222,6 +223,7 @@ func MenuSort() []dto.MenuLabelSort {
{Label: "Website-Menu", Sort: 400},
{Label: "Website", Sort: 100},
{Label: "SSL", Sort: 200},
{Label: "WebsiteTemplate", Sort: 250},
{Label: "PHP", Sort: 300},
{Label: "Database-Menu", Sort: 500},
{Label: "Container-Menu", Sort: 600},
+1
View File
@@ -52,6 +52,7 @@ func Init() {
migrations.AddAlertAuditUser,
migrations.AddMenuAccordionSetting,
migrations.AddAPITrustedProxiesSetting,
migrations.AddWebsiteTemplateMenu,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+15
View File
@@ -1299,3 +1299,18 @@ var AddAPITrustedProxiesSetting = &gormigrate.Migration{
return nil
},
}
var AddWebsiteTemplateMenu = &gormigrate.Migration{
ID: "20260728-add-website-template-menu",
Migrate: func(tx *gorm.DB) error {
return helper.UpsertChildMenuByLabel(tx, "Website-Menu", dto.ShowMenu{
ID: "34",
Disabled: false,
Title: "menu.template",
IsShow: true,
Label: "WebsiteTemplate",
Path: "/websites/templates",
Sort: 250,
}, "SSL")
},
}
+74
View File
@@ -96,6 +96,7 @@ export namespace Website {
ftpUser: string;
ftpPassword: string;
taskID: string;
templateOutputID?: number;
SSLID?: number;
enableSSL: boolean;
createDB?: boolean;
@@ -771,4 +772,77 @@ export namespace Website {
ids: number[];
groupID: number;
}
export interface TemplateVariable {
key: string;
label: string;
type: 'text' | 'textarea' | 'number' | 'select' | 'color';
default: string;
options: string;
required: boolean;
}
export interface Template extends CommonModel {
name: string;
type: string;
content: string;
filePath: string;
variables: string;
remark: string;
}
export interface TemplateSearch extends ReqPage {
name: string;
type: string;
}
export interface TemplateCreate {
name: string;
type: string;
content: string;
filePath: string;
variables: string;
remark: string;
}
export interface TemplateUpdate {
id: number;
name: string;
type: string;
content: string;
filePath: string;
variables: string;
remark: string;
}
export interface TemplateOutput extends CommonModel {
name: string;
templateID: number;
templateType: string;
variableValues: string;
outputPath: string;
}
export interface TemplateOutputDTO extends TemplateOutput {
templateName: string;
}
export interface TemplateOutputSearch extends ReqPage {
templateID: number;
}
export interface TemplateOutputCreate {
templateID: number;
name: string;
variableValues: Record<string, string>;
}
export interface PreviewReq {
templateID: number;
variableValues: Record<string, string>;
}
export interface PreviewDTO {
html: string;
}
}
+46
View File
@@ -401,3 +401,49 @@ export const updateWebsiteStream = (req: Website.WebsiteStreamUpdate) => {
export const batchSetHttps = (req: Website.BatchSetHttps) => {
return http.post(`/websites/batch/ssl`, req);
};
export const searchTemplates = (req: Website.TemplateSearch) => {
return http.post<ResPage<Website.Template>>(`/websites/templates/search`, req);
};
export const createTemplate = (req: Website.TemplateCreate) => {
return http.post<any>(`/websites/templates`, req);
};
export const updateTemplate = (req: Website.TemplateUpdate) => {
return http.post<any>(`/websites/templates/update`, req);
};
export const deleteTemplate = (params: { id: number }) => {
return http.post<any>(`/websites/templates/del`, params);
};
export const getTemplate = (id: number) => {
return http.post<Website.Template>(`/websites/templates/get`, { id });
};
export const uploadTemplateZip = (file: globalThis.File) => {
const formData = new FormData();
formData.append('file', file);
return http.post<{ filePath: string; variables: string[] }>(`/websites/templates/upload`, formData, TimeoutEnum.T_5M);
};
export const previewTemplate = (req: Website.PreviewReq) => {
return http.post<Website.PreviewDTO>(`/websites/templates/preview`, req);
};
export const searchTemplateOutputs = (req: Website.TemplateOutputSearch) => {
return http.post<ResPage<Website.TemplateOutputDTO>>(`/websites/templates/outputs/search`, req);
};
export const createTemplateOutput = (req: Website.TemplateOutputCreate) => {
return http.post<any>(`/websites/templates/outputs`, req);
};
export const deleteTemplateOutput = (params: { id: number }) => {
return http.post<any>(`/websites/templates/outputs/del`, params);
};
export const getTemplateOutput = (id: number) => {
return http.post<Website.TemplateOutputDTO>(`/websites/templates/outputs/get`, { id });
};
+44
View File
@@ -426,6 +426,7 @@ const message = {
app: 'Application',
msgCenter: 'Tasks',
disk: 'Disk',
template: 'Template',
},
home: {
recommend: 'Recommended',
@@ -6389,6 +6390,49 @@ const message = {
masterHostError: 'The master node IP cannot be 127.0.0.1',
},
},
template: {
create: 'Create Template',
edit: 'Edit Template',
name: 'Template Name',
type: 'Template Type',
single: 'Single File',
multi: 'Multi File (zip)',
content: 'Template Content',
variables: 'Variables',
remark: 'Remark',
filePath: 'File Path',
upload: 'Upload Zip',
preview: 'Preview',
previewEmpty: 'Select a template to preview in real time',
generate: 'Generate Output',
outputName: 'Output Name',
outputList: 'Output List',
outputCreate: 'Generate Output',
confirmDelete: 'Are you sure you want to delete this template? Associated outputs will also be deleted.',
confirmDeleteOutput: 'Are you sure you want to delete this output?',
variableKey: 'Variable Key',
variableLabel: 'Label',
variableType: 'Type',
variableDefault: 'Default',
variableOptions: 'Options (comma separated)',
variableRequired: 'Required',
varTypeText: 'Text',
varTypeTextHelper: 'Single-line text input',
varTypeTextarea: 'Textarea',
varTypeTextareaHelper: 'For paragraphs and long text',
varTypeNumber: 'Number',
varTypeNumberHelper: 'Numeric input only',
varTypeSelect: 'Select',
varTypeSelectHelper: 'Choose from preset options, options required',
varTypeColor: 'Color',
varTypeColorHelper: 'Color picker',
autoDetectHelper: 'Variables wrapped in double curly braces are auto-detected and added to the table',
selectTemplate: 'Select Template',
fillVariables: 'Fill Variables',
noVariables: 'No variables defined in this template',
outputSuccess: 'Output generated successfully',
importButton: 'Import from Template',
},
};
export default {
+44
View File
@@ -422,6 +422,7 @@ const message = {
app: 'Aplicación',
msgCenter: 'Tareas',
disk: 'Disco',
template: 'Plantilla',
filter: 'Filtro',
},
home: {
@@ -6447,6 +6448,49 @@ const message = {
masterHostError: 'La IP del nodo maestro no puede ser 127.0.0.1',
},
},
template: {
create: 'Crear plantilla',
edit: 'Editar plantilla',
name: 'Nombre de plantilla',
type: 'Tipo de plantilla',
single: 'Archivo único',
multi: 'Varios archivos (zip)',
content: 'Contenido de la plantilla',
variables: 'Definición de variables',
remark: 'Observación',
filePath: 'Ruta del archivo',
upload: 'Subir zip',
preview: 'Vista previa',
previewEmpty: 'Seleccione una plantilla para previsualizar en tiempo real',
generate: 'Generar salida',
outputName: 'Nombre de la salida',
outputList: 'Lista de salidas',
outputCreate: 'Generar salida',
confirmDelete: '¿Está seguro de que desea eliminar esta plantilla? Las salidas asociadas también se eliminarán',
confirmDeleteOutput: '¿Está seguro de que desea eliminar esta salida?',
variableKey: 'Nombre de variable',
variableLabel: 'Etiqueta',
variableType: 'Tipo',
variableDefault: 'Valor predeterminado',
variableOptions: 'Opciones (separadas por comas)',
variableRequired: 'Obligatorio',
varTypeText: 'Texto de una línea',
varTypeTextHelper: 'Campo de texto normal',
varTypeTextarea: 'Texto multilínea',
varTypeTextareaHelper: 'Adecuado para párrafos y textos largos',
varTypeNumber: 'Número',
varTypeNumberHelper: 'Solo entrada numérica',
varTypeSelect: 'Selección desplegable',
varTypeSelectHelper: 'Elegir entre opciones predefinidas, se deben indicar las opciones',
varTypeColor: 'Color',
varTypeColorHelper: 'Selector de color',
autoDetectHelper: 'Las variables entre llaves dobles se detectan automáticamente y se añaden a la tabla',
selectTemplate: 'Seleccionar plantilla',
fillVariables: 'Completar variables',
noVariables: 'Esta plantilla no tiene variables definidas',
outputSuccess: 'Salida generada correctamente',
importButton: 'Importar desde plantilla',
},
};
export default {
+44
View File
@@ -415,6 +415,7 @@ const message = {
app: 'برنامه',
msgCenter: 'وظایف',
disk: 'دیسک',
template: 'قالب',
},
home: {
recommend: 'پیشنهادی',
@@ -6339,6 +6340,49 @@ const message = {
masterHostError: 'IP گره اصلی نمی‌تواند 127.0.0.1 باشد',
},
},
template: {
create: 'ایجاد قالب',
edit: 'ویرایش قالب',
name: 'نام قالب',
type: 'نوع قالب',
single: 'تک فایل',
multi: 'چند فایل (zip)',
content: 'محتوای قالب',
variables: 'تعریف متغیرها',
remark: 'توضیحات',
filePath: 'مسیر فایل',
upload: 'بارگذاری zip',
preview: 'پیش‌نمایش',
previewEmpty: 'برای پیش‌نمایش زنده یک قالب انتخاب کنید',
generate: 'تولید خروجی',
outputName: 'نام خروجی',
outputList: 'فهرست خروجی‌ها',
outputCreate: 'تولید خروجی',
confirmDelete: 'آیا از حذف این قالب مطمئن هستید؟ خروجی‌های مرتبط نیز حذف خواهند شد',
confirmDeleteOutput: 'آیا از حذف این خروجی مطمئن هستید؟',
variableKey: 'نام متغیر',
variableLabel: 'برچسب',
variableType: 'نوع',
variableDefault: 'مقدار پیش‌فرض',
variableOptions: 'گزینه‌ها (جدا شده با کاما)',
variableRequired: 'الزامی',
varTypeText: 'متن تک‌خطی',
varTypeTextHelper: 'فیلد ورودی متن معمولی',
varTypeTextarea: 'متن چندخطی',
varTypeTextareaHelper: 'مناسب برای پاراگراف‌ها و متون طولانی',
varTypeNumber: 'عدد',
varTypeNumberHelper: 'فقط ورودی عددی',
varTypeSelect: 'انتخاب کشویی',
varTypeSelectHelper: 'انتخاب از گزینه‌های از پیش تعیین‌شده، وارد کردن گزینه‌ها الزامی است',
varTypeColor: 'رنگ',
varTypeColorHelper: 'انتخابگر رنگ',
autoDetectHelper: 'متغیرهای داخل آکولاد دوگانه به صورت خودکار شناسایی و به جدول اضافه می‌شوند',
selectTemplate: 'انتخاب قالب',
fillVariables: 'تکمیل متغیرها',
noVariables: 'در این قالب متغیری تعریف نشده است',
outputSuccess: 'خروجی با موفقیت تولید شد',
importButton: 'وارد کردن از قالب',
},
};
export default {
+44
View File
@@ -420,6 +420,7 @@ const message = {
app: 'アプリケーション',
msgCenter: 'タスク',
disk: 'ディスク',
template: 'テンプレート',
filter: 'フィルター',
},
home: {
@@ -6392,6 +6393,49 @@ const message = {
masterHostError: 'マスターノードのIPは127.0.0.1にできません',
},
},
template: {
create: 'テンプレート作成',
edit: 'テンプレート編集',
name: 'テンプレート名',
type: 'テンプレートタイプ',
single: '単一ファイル',
multi: '複数ファイル(zip)',
content: 'テンプレート内容',
variables: '変数定義',
remark: '備考',
filePath: 'ファイルパス',
upload: 'zip をアップロード',
preview: 'プレビュー',
previewEmpty: 'テンプレートを選択するとリアルタイムでプレビューできます',
generate: '成果物を生成',
outputName: '成果物名',
outputList: '成果物リスト',
outputCreate: '成果物を生成',
confirmDelete: 'このテンプレートを削除してもよろしいですか関連する成果物も削除されます',
confirmDeleteOutput: 'この成果物を削除してもよろしいですか',
variableKey: '変数名',
variableLabel: 'ラベル',
variableType: 'タイプ',
variableDefault: 'デフォルト値',
variableOptions: 'オプション(カンマ区切り)',
variableRequired: '必須',
varTypeText: '一行テキスト',
varTypeTextHelper: '通常のテキスト入力欄',
varTypeTextarea: '複数行テキスト',
varTypeTextareaHelper: '段落や長文に適しています',
varTypeNumber: '数値',
varTypeNumberHelper: '数値のみ入力可能',
varTypeSelect: 'ドロップダウン選択',
varTypeSelectHelper: 'プリセットオプションから選択オプションの入力が必要',
varTypeColor: 'カラー',
varTypeColorHelper: 'カラーピッカー',
autoDetectHelper: '内容中の二重中括弧で囲まれた変数を自動認識し変数定義テーブルに追加します',
selectTemplate: 'テンプレートを選択',
fillVariables: '変数を入力',
noVariables: 'このテンプレートには変数が定義されていません',
outputSuccess: '成果物の生成に成功しました',
importButton: 'テンプレートからインポート',
},
};
export default {
...getFuLocaleMessage('ja'),
+44
View File
@@ -416,6 +416,7 @@ const message = {
app: '애플리케이션',
msgCenter: '작업',
disk: '디스크',
template: '템플릿',
filter: '필터',
},
home: {
@@ -6267,6 +6268,49 @@ const message = {
masterHostError: '마스터 노드의 IP는 127.0.0.1 없습니다',
},
},
template: {
create: '템플릿 생성',
edit: '템플릿 편집',
name: '템플릿 이름',
type: '템플릿 유형',
single: '단일 파일',
multi: '다중 파일(zip)',
content: '템플릿 내용',
variables: '변수 정의',
remark: '비고',
filePath: '파일 경로',
upload: 'zip 업로드',
preview: '미리보기',
previewEmpty: '템플릿을 선택하면 실시간으로 미리볼 있습니다',
generate: '산출물 생성',
outputName: '산출물 이름',
outputList: '산출물 목록',
outputCreate: '산출물 생성',
confirmDelete: ' 템플릿을 삭제하시겠습니까? 연관된 산출물도 함께 삭제됩니다',
confirmDeleteOutput: ' 산출물을 삭제하시겠습니까?',
variableKey: '변수명',
variableLabel: '레이블',
variableType: '유형',
variableDefault: '기본값',
variableOptions: '옵션(쉼표로 구분)',
variableRequired: '필수',
varTypeText: ' 텍스트',
varTypeTextHelper: '일반 텍스트 입력란',
varTypeTextarea: '여러 텍스트',
varTypeTextareaHelper: '문단이나 텍스트에 적합',
varTypeNumber: '숫자',
varTypeNumberHelper: '숫자만 입력 가능',
varTypeSelect: '드롭다운 선택',
varTypeSelectHelper: '사전 설정된 옵션에서 선택, 옵션 입력 필요',
varTypeColor: '색상',
varTypeColorHelper: '색상 선택기',
autoDetectHelper: '내용에서 이중 중괄호로 감싸진 변수를 자동 인식하여 변수 정의 테이블에 추가합니다',
selectTemplate: '템플릿 선택',
fillVariables: '변수 입력',
noVariables: ' 템플릿에는 정의된 변수가 없습니다',
outputSuccess: '산출물이 성공적으로 생성되었습니다',
importButton: '템플릿에서 가져오기',
},
};
export default {
+44
View File
@@ -424,6 +424,7 @@ const message = {
app: 'ແອັບພລິເຄຊັນ',
msgCenter: 'ໜ້າວຽກ',
disk: 'ດິສກ໌',
template: 'ແມ່ແບບ',
},
home: {
recommend: 'ແນະນຳ',
@@ -6188,6 +6189,49 @@ const message = {
masterHostError: 'IP ຂອງໂນດຫຼັກຫ້າມເປັນ 127.0.0.1',
},
},
template: {
create: 'ສ້າງແມ່ແບບ',
edit: 'ແກ້ໄຂແມ່ແບບ',
name: 'ຊື່ແມ່ແບບ',
type: 'ປະເພດແມ່ແບບ',
single: 'ໄຟລ໌ດ່ຽວ',
multi: 'ຫລາຍໄຟລ໌ (zip)',
content: 'ເນື້ອຫາແມ່ແບບ',
variables: 'ການກຳນົດຕົວປ່ຽນ',
remark: 'ໝາຍເຫດ',
filePath: 'ເສັ້ນທາງໄຟລ໌',
upload: 'ອັບໂຫລດ zip',
preview: 'ເບິ່ງຕົວຍ່າງ',
previewEmpty: 'ເລືອກແມ່ແບບເພື່ອເບິ່ງຕົວຍ່າງແບບທັນທີ',
generate: 'ສ້າງຜົນງານ',
outputName: 'ຊື່ຜົນງານ',
outputList: 'ລາຍການຜົນງານ',
outputCreate: 'ສ້າງຜົນງານ',
confirmDelete: 'ທ່ານແນ່ໃຈບໍ່ທີ່ຈະລຶບແມ່ແບບນີ້? ຜົນງານທີ່ກ່ຽວຂ້ອງຈະຖືກລຶບນຳ',
confirmDeleteOutput: 'ທ່ານແນ່ໃຈບໍ່ທີ່ຈະລຶບຜົນງານນີ້?',
variableKey: 'ຊື່ຕົວປ່ຽນ',
variableLabel: 'ປ້າຍກຳກັບ',
variableType: 'ປະເພດ',
variableDefault: 'ຄ່າເລີ່ມຕົ້ນ',
variableOptions: 'ຕົວເລືອກ (ແຍກດ້ວຍເຄື່ອງໝາຍຈຸດ)',
variableRequired: 'ຈຳເປັນ',
varTypeText: 'ຂໍ້ຄວາມແຖວດຽວ',
varTypeTextHelper: 'ຊ່ອງປ້ອນຂໍ້ຄວາມທົ່ວໄປ',
varTypeTextarea: 'ຂໍ້ຄວາມຫລາຍແຖວ',
varTypeTextareaHelper: 'ເໝາະສົມສຳລັບຫຍໍ້ໜ້າ ແລະ ຂໍ້ຄວາມຍາວ',
varTypeNumber: 'ຕົວເລກ',
varTypeNumberHelper: 'ປ້ອນໄດ້ພຽງຕົວເລກເທົ່ານັ້ນ',
varTypeSelect: 'ເລືອກຈາກລາຍການ',
varTypeSelectHelper: 'ເລືອກຈາກຕົວເລືອກທີ່ກຳນົດໄວ້, ຕ້ອງປ້ອນຕົວເລືອກ',
varTypeColor: 'ສີ',
varTypeColorHelper: 'ເຄື່ອງມືເລືອກສີ',
autoDetectHelper: 'ຕົວປ່ຽນທີ່ຫໍ່ດ້ວຍວົງເລັບຄູ່ຈະຖືກກວດພົບໂດຍອັດຕະໂນມັດ ແລະ ເພີ່ມໃສ່ຕາຕະລາງ',
selectTemplate: 'ເລືອກແມ່ແບບ',
fillVariables: 'ປ້ອນຕົວປ່ຽນ',
noVariables: 'ແມ່ແບບນີ້ບໍ່ມີຕົວປ່ຽນທີ່ກຳນົດໄວ້',
outputSuccess: 'ສ້າງຜົນງານສຳເລັດແລ້ວ',
importButton: 'ນຳເຂົ້າຈາກແມ່ແບບ',
},
};
export default {
+44
View File
@@ -425,6 +425,7 @@ const message = {
app: 'Aplikasi',
msgCenter: 'Tugas',
disk: 'Disk',
template: 'Templat',
filter: 'Penapis',
},
home: {
@@ -6477,6 +6478,49 @@ const message = {
masterHostError: 'IP nod utama tidak boleh 127.0.0.1',
},
},
template: {
create: 'Cipta Templat',
edit: 'Sunting Templat',
name: 'Nama Templat',
type: 'Jenis Templat',
single: 'Fail Tunggal',
multi: 'Berbilang Fail (zip)',
content: 'Kandungan Templat',
variables: 'Definisi Pemboleh Ubah',
remark: 'Catatan',
filePath: 'Laluan Fail',
upload: 'Muat Naik Zip',
preview: 'Pratonton',
previewEmpty: 'Pilih templat untuk pratonton masa nyata',
generate: 'Jana Output',
outputName: 'Nama Output',
outputList: 'Senarai Output',
outputCreate: 'Jana Output',
confirmDelete: 'Adakah anda pasti mahu memadam templat ini? Output yang berkaitan juga akan dipadam',
confirmDeleteOutput: 'Adakah anda pasti mahu memadam output ini?',
variableKey: 'Nama Pemboleh Ubah',
variableLabel: 'Label',
variableType: 'Jenis',
variableDefault: 'Nilai Lalai',
variableOptions: 'Pilihan (dipisahkan koma)',
variableRequired: 'Wajib',
varTypeText: 'Teks Satu Baris',
varTypeTextHelper: 'Medan input teks biasa',
varTypeTextarea: 'Teks Berbilang Baris',
varTypeTextareaHelper: 'Sesuai untuk perenggan dan teks panjang',
varTypeNumber: 'Nombor',
varTypeNumberHelper: 'Input nombor sahaja',
varTypeSelect: 'Pilihan Juntai Bawah',
varTypeSelectHelper: 'Pilih daripada pilihan pratetap, pilihan perlu diisi',
varTypeColor: 'Warna',
varTypeColorHelper: 'Pemilih warna',
autoDetectHelper: 'Pemboleh ubah dalam kurungan kerinting berganda dikesan secara automatik dan ditambah ke jadual',
selectTemplate: 'Pilih Templat',
fillVariables: 'Isi Pemboleh Ubah',
noVariables: 'Tiada pemboleh ubah ditakrifkan dalam templat ini',
outputSuccess: 'Output berjaya dijana',
importButton: 'Import daripada Templat',
},
};
export default {
+44
View File
@@ -419,6 +419,7 @@ const message = {
app: 'Aplicativo',
msgCenter: 'Tarefas',
disk: 'Disco',
template: 'Modelo',
filter: 'Filtro',
},
home: {
@@ -6631,6 +6632,49 @@ const message = {
masterHostError: 'O IP do mestre não pode ser 127.0.0.1',
},
},
template: {
create: 'Criar modelo',
edit: 'Editar modelo',
name: 'Nome do modelo',
type: 'Tipo de modelo',
single: 'Arquivo único',
multi: 'Múltiplos arquivos (zip)',
content: 'Conteúdo do modelo',
variables: 'Definição de variáveis',
remark: 'Observação',
filePath: 'Caminho do arquivo',
upload: 'Enviar zip',
preview: 'Pré-visualização',
previewEmpty: 'Selecione um modelo para pré-visualizar em tempo real',
generate: 'Gerar saída',
outputName: 'Nome da saída',
outputList: 'Lista de saídas',
outputCreate: 'Gerar saída',
confirmDelete: 'Tem certeza de que deseja excluir este modelo? As saídas associadas também serão excluídas',
confirmDeleteOutput: 'Tem certeza de que deseja excluir esta saída?',
variableKey: 'Nome da variável',
variableLabel: 'Rótulo',
variableType: 'Tipo',
variableDefault: 'Valor padrão',
variableOptions: 'Opções (separadas por vírgula)',
variableRequired: 'Obrigatório',
varTypeText: 'Texto de linha única',
varTypeTextHelper: 'Campo de texto comum',
varTypeTextarea: 'Texto de múltiplas linhas',
varTypeTextareaHelper: 'Adequado para parágrafos e textos longos',
varTypeNumber: 'Número',
varTypeNumberHelper: 'Somente entrada numérica',
varTypeSelect: 'Seleção suspensa',
varTypeSelectHelper: 'Escolher entre opções predefinidas, as opções devem ser informadas',
varTypeColor: 'Cor',
varTypeColorHelper: 'Seletor de cores',
autoDetectHelper: 'Variáveis entre chaves duplas são detectadas automaticamente e adicionadas à tabela',
selectTemplate: 'Selecionar modelo',
fillVariables: 'Preencher variáveis',
noVariables: 'Este modelo não possui variáveis definidas',
outputSuccess: 'Saída gerada com sucesso',
importButton: 'Importar do modelo',
},
};
export default {
+44
View File
@@ -417,6 +417,7 @@ const message = {
app: 'Приложение',
msgCenter: 'Задачи',
disk: 'Диск',
template: 'Шаблон',
filter: 'Фильтр',
},
home: {
@@ -6477,6 +6478,49 @@ const message = {
masterHostError: 'IP главного узла не может быть 127.0.0.1',
},
},
template: {
create: 'Создать шаблон',
edit: 'Редактировать шаблон',
name: 'Имя шаблона',
type: 'Тип шаблона',
single: 'Один файл',
multi: 'Несколько файлов (zip)',
content: 'Содержимое шаблона',
variables: 'Определение переменных',
remark: 'Примечание',
filePath: 'Путь к файлу',
upload: 'Загрузить zip',
preview: 'Предпросмотр',
previewEmpty: 'Выберите шаблон для предпросмотра в реальном времени',
generate: 'Сгенерировать результат',
outputName: 'Имя результата',
outputList: 'Список результатов',
outputCreate: 'Сгенерировать результат',
confirmDelete: 'Вы уверены, что хотите удалить этот шаблон? Связанные результаты также будут удалены',
confirmDeleteOutput: 'Вы уверены, что хотите удалить этот результат?',
variableKey: 'Имя переменной',
variableLabel: 'Метка',
variableType: 'Тип',
variableDefault: 'Значение по умолчанию',
variableOptions: 'Варианты (через запятую)',
variableRequired: 'Обязательно',
varTypeText: 'Однострочный текст',
varTypeTextHelper: 'Обычное текстовое поле',
varTypeTextarea: 'Многострочный текст',
varTypeTextareaHelper: 'Подходит для абзацев и длинного текста',
varTypeNumber: 'Число',
varTypeNumberHelper: 'Только числовой ввод',
varTypeSelect: 'Выпадающий список',
varTypeSelectHelper: 'Выбор из заданных вариантов, требуется указать варианты',
varTypeColor: 'Цвет',
varTypeColorHelper: 'Палитра цветов',
autoDetectHelper: 'Переменные в двойных фигурных скобках распознаются автоматически и добавляются в таблицу',
selectTemplate: 'Выбрать шаблон',
fillVariables: 'Заполнить переменные',
noVariables: 'В этом шаблоне не определены переменные',
outputSuccess: 'Результат успешно сгенерирован',
importButton: 'Импорт из шаблона',
},
};
export default {
+44
View File
@@ -422,6 +422,7 @@ const message = {
msgCenter: 'Görevler',
filter: 'Filtre',
disk: 'Disk',
template: 'Şablon',
},
home: {
recommend: 'Önerilen',
@@ -6468,6 +6469,49 @@ const message = {
masterHostError: "Ana düğüm IP'si 127.0.0.1 olamaz",
},
},
template: {
create: 'Şablon Oluştur',
edit: 'Şablonu Düzenle',
name: 'Şablon Adı',
type: 'Şablon Türü',
single: 'Tek Dosya',
multi: 'Çoklu Dosya (zip)',
content: 'Şablon İçeriği',
variables: 'Değişken Tanımları',
remark: 'Açıklama',
filePath: 'Dosya Yolu',
upload: 'Zip Yükle',
preview: 'Önizleme',
previewEmpty: 'Gerçek zamanlı önizleme için bir şablon seçin',
generate: 'Çıktı Oluştur',
outputName: 'Çıktı Adı',
outputList: 'Çıktı Listesi',
outputCreate: 'Çıktı Oluştur',
confirmDelete: 'Bu şablonu silmek istediğinizden emin misiniz? İlişkili çıktılar da silinecektir',
confirmDeleteOutput: 'Bu çıktıyı silmek istediğinizden emin misiniz?',
variableKey: 'Değişken Adı',
variableLabel: 'Etiket',
variableType: 'Tür',
variableDefault: 'Varsayılan',
variableOptions: 'Seçenekler (virgülle ayrılmış)',
variableRequired: 'Zorunlu',
varTypeText: 'Tek Satır Metin',
varTypeTextHelper: 'Normal metin giriş alanı',
varTypeTextarea: 'Çok Satırlı Metin',
varTypeTextareaHelper: 'Paragraflar ve uzun metinler için uygundur',
varTypeNumber: 'Sayı',
varTypeNumberHelper: 'Yalnızca sayısal giriş',
varTypeSelect: 'Açılır Liste',
varTypeSelectHelper: 'Önceden tanımlanan seçeneklerden seçim, seçenekler girilmelidir',
varTypeColor: 'Renk',
varTypeColorHelper: 'Renk seçici',
autoDetectHelper: 'Çift süslü parantez içindeki değişkenler otomatik algılanır ve tabloya eklenir',
selectTemplate: 'Şablon Seç',
fillVariables: 'Değişkenleri Doldur',
noVariables: 'Bu şablonda tanımlanmış değişken yok',
outputSuccess: 'Çıktı başarıyla oluşturuldu',
importButton: 'Şablondan İçe Aktar',
},
};
export default {
+44
View File
@@ -403,6 +403,7 @@ const message = {
app: '應用',
msgCenter: '任務中心',
disk: '磁碟管理',
template: '模板',
filter: '篩選器',
},
home: {
@@ -5940,6 +5941,49 @@ const message = {
masterHostError: '主節點 IP 不能為 127.0.0.1',
},
},
template: {
create: '建立模板',
edit: '編輯模板',
name: '模板名稱',
type: '模板類型',
single: '單檔案',
multi: '多檔案(zip)',
content: '模板內容',
variables: '變數定義',
remark: '備註',
filePath: '檔案路徑',
upload: '上傳 zip',
preview: '預覽',
previewEmpty: '選擇模板後可即時預覽',
generate: '生成產物',
outputName: '產物名稱',
outputList: '產物列表',
outputCreate: '生成產物',
confirmDelete: '確定刪除此模板嗎關聯的產物也將被刪除',
confirmDeleteOutput: '確定刪除此產物嗎',
variableKey: '變數名',
variableLabel: '標籤',
variableType: '類型',
variableDefault: '預設值',
variableOptions: '選項(逗號分隔)',
variableRequired: '必填',
varTypeText: '單行文字',
varTypeTextHelper: '普通文字輸入框',
varTypeTextarea: '多行文字',
varTypeTextareaHelper: '適用於段落長文字',
varTypeNumber: '數字',
varTypeNumberHelper: '僅允許輸入數字',
varTypeSelect: '下拉選擇',
varTypeSelectHelper: '從預設選項中選擇,需填寫選項',
varTypeColor: '顏色',
varTypeColorHelper: '顏色選擇器',
autoDetectHelper: '自動識別內容中雙花括號包裹的變數並加入變數定義表格',
selectTemplate: '選擇模板',
fillVariables: '填寫變數',
noVariables: '此模板沒有定義變數',
outputSuccess: '產物生成成功',
importButton: '從模板匯入',
},
};
export default {
...getFuLocaleMessage('zh-Hant'),
+44
View File
@@ -393,6 +393,7 @@ const message = {
app: '应用',
msgCenter: '任务中心',
disk: '磁盘管理',
template: '模板',
},
home: {
dir: '目录',
@@ -5940,6 +5941,49 @@ const message = {
masterHostError: '主节点 IP 不能为 127.0.0.1',
},
},
template: {
create: '创建模板',
edit: '编辑模板',
name: '模板名称',
type: '模板类型',
single: '单文件',
multi: '多文件(zip)',
content: '模板内容',
variables: '变量定义',
remark: '备注',
filePath: '文件路径',
upload: '上传 zip',
preview: '预览',
previewEmpty: '选择模板后可实时预览',
generate: '生成产物',
outputName: '产物名称',
outputList: '产物列表',
outputCreate: '生成产物',
confirmDelete: '确定删除此模板吗关联的产物也将被删除',
confirmDeleteOutput: '确定删除此产物吗',
variableKey: '变量名',
variableLabel: '标签',
variableType: '类型',
variableDefault: '默认值',
variableOptions: '选项(逗号分隔)',
variableRequired: '必填',
varTypeText: '单行文本',
varTypeTextHelper: '普通文本输入框',
varTypeTextarea: '多行文本',
varTypeTextareaHelper: '适用于段落长文本',
varTypeNumber: '数字',
varTypeNumberHelper: '仅允许输入数字',
varTypeSelect: '下拉选择',
varTypeSelectHelper: '从预设选项中选择,需填写选项',
varTypeColor: '颜色',
varTypeColorHelper: '颜色选择器',
autoDetectHelper: '自动识别内容中双花括号包裹的变量并加入变量定义表格',
selectTemplate: '选择模板',
fillVariables: '填写变量',
noVariables: '此模板没有定义变量',
outputSuccess: '产物生成成功',
importButton: '从模板导入',
},
};
export default {
...getFuLocaleMessage('zh'),
+10
View File
@@ -43,6 +43,16 @@ const webSiteRouter = {
permission: 'website_cert_view',
},
},
{
path: '/websites/templates',
name: 'WebsiteTemplate',
component: () => import('@/views/website/template/index.vue'),
meta: {
icon: 'p-file-html',
title: 'menu.template',
permission: 'website_view',
},
},
{
path: '/websites/runtimes/php',
name: 'PHP',
@@ -0,0 +1,157 @@
<template>
<div>
<LayoutContent :title="$t('menu.template')">
<template #leftToolBar>
<el-button v-permission type="primary" @click="openCreate()">
{{ $t('template.create') }}
</el-button>
<el-button v-permission type="primary" plain @click="openOutputList()">
{{ $t('template.outputList') }}
</el-button>
</template>
<template #rightToolBar>
<TableSearch @search="search()" v-model:searchName="searchName" />
<TableRefresh @search="search()" />
</template>
<template #main>
<ComplexTable
:data="data"
:pagination-config="paginationConfig"
@search="search()"
v-loading="loading"
>
<el-table-column :label="$t('template.name')" prop="name" min-width="120px" show-overflow-tooltip />
<el-table-column :label="$t('template.type')" prop="type" width="150px">
<template #default="{ row }">
<el-tag>
{{ row.type === 'single' ? $t('template.single') : $t('template.multi') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('template.variables')" width="100px">
<template #default="{ row }">
{{ getVariableCount(row.variables) }}
</template>
</el-table-column>
<el-table-column
:label="$t('template.remark')"
prop="remark"
min-width="150px"
show-overflow-tooltip
/>
<el-table-column
prop="createdAt"
:label="$t('commons.table.date')"
:formatter="dateFormat"
width="180px"
/>
<fu-table-operations
:ellipsis="3"
:buttons="buttons"
:label="$t('commons.table.operate')"
fixed="right"
fix
/>
</ComplexTable>
</template>
</LayoutContent>
<OpDialog ref="opRef" @search="search" />
<TemplateOperate ref="operateRef" @search="search" />
<OutputCreate ref="outputCreateRef" @search="search" />
<OutputList ref="outputListRef" />
</div>
</template>
<script lang="ts" setup>
import { Website } from '@/api/interface/website';
import { deleteTemplate, searchTemplates } from '@/api/modules/website';
import TemplateOperate from '@/views/website/template/operate/index.vue';
import OutputCreate from '@/views/website/template/output/create.vue';
import OutputList from '@/views/website/template/output/index.vue';
import { dateFormat } from '@/utils/date';
import i18n from '@/lang';
import { reactive, ref, onMounted } from 'vue';
const loading = ref(false);
const data = ref<Website.Template[]>([]);
const searchName = ref('');
const opRef = ref();
const operateRef = ref();
const outputCreateRef = ref();
const outputListRef = ref();
const paginationConfig = reactive({
cacheSizeKey: 'website-template-page-size',
currentPage: 1,
pageSize: 20,
total: 0,
});
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
click: (row: Website.Template) => {
operateRef.value.acceptParams(row.id);
},
},
{
label: i18n.global.t('template.generate'),
click: (row: Website.Template) => {
outputCreateRef.value.acceptParams(row.id);
},
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: Website.Template) => {
deleteTemplateRow(row);
},
},
];
const getVariableCount = (variables: string) => {
try {
const arr = JSON.parse(variables || '[]');
return Array.isArray(arr) ? arr.length : 0;
} catch {
return 0;
}
};
const search = async () => {
loading.value = true;
try {
const res = await searchTemplates({
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
name: searchName.value,
type: '',
});
data.value = res.data.items || [];
paginationConfig.total = res.data.total;
} finally {
loading.value = false;
}
};
const openCreate = () => {
operateRef.value.acceptParams();
};
const openOutputList = () => {
outputListRef.value.acceptParams();
};
const deleteTemplateRow = (row: Website.Template) => {
opRef.value.acceptParams({
title: i18n.global.t('commons.button.delete'),
names: [row.name],
msg: i18n.global.t('template.confirmDelete'),
api: deleteTemplate,
params: { id: row.id },
});
};
onMounted(() => {
search();
});
</script>
@@ -0,0 +1,237 @@
<template>
<DrawerPro
v-model="open"
:header="isEdit ? $t('template.edit') : $t('template.create')"
size="large"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="$t('template.name')" prop="name">
<el-input v-model.trim="form.name" />
</el-form-item>
<el-form-item :label="$t('template.type')" prop="type">
<el-radio-group v-model="form.type" :disabled="isEdit">
<el-radio value="single">{{ $t('template.single') }}</el-radio>
<el-radio value="multi">{{ $t('template.multi') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.type === 'single'" :label="$t('template.content')" prop="content">
<el-input v-model="form.content" type="textarea" :rows="12" :placeholder="contentPlaceholder" />
<span class="input-help">{{ $t('template.autoDetectHelper') }}</span>
</el-form-item>
<el-form-item v-if="form.type === 'multi'" :label="$t('template.upload')">
<el-upload :show-file-list="false" :before-upload="handleUpload" accept=".zip" action="#">
<el-button type="primary" plain>{{ $t('template.upload') }}</el-button>
</el-upload>
<span v-if="form.filePath" class="ml-2 text-xs text-gray-500">{{ form.filePath }}</span>
<span class="input-help">{{ $t('template.autoDetectHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('template.remark')">
<el-input v-model="form.remark" />
</el-form-item>
<el-form-item :label="$t('template.variables')">
<el-button size="small" type="primary" plain @click="addVariable">
{{ $t('commons.button.add') }}
</el-button>
<el-table :data="variables" border class="mt-2">
<el-table-column :label="$t('template.variableKey')" min-width="110px">
<template #default="{ row }">
<el-input v-model.trim="row.key" />
</template>
</el-table-column>
<el-table-column :label="$t('template.variableLabel')" min-width="110px">
<template #default="{ row }">
<el-input v-model="row.label" />
</template>
</el-table-column>
<el-table-column :label="$t('template.variableType')" width="160px">
<template #default="{ row }">
<el-select v-model="row.type">
<el-option
v-for="item in variableTypes"
:key="item.value"
:value="item.value"
:label="$t('template.varType' + item.name)"
>
<div>
<span>{{ $t('template.varType' + item.name) }}</span>
<span class="ml-2 text-xs text-gray-400">
{{ $t('template.varType' + item.name + 'Helper') }}
</span>
</div>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column :label="$t('template.variableDefault')" min-width="110px">
<template #default="{ row }">
<el-input v-model="row.default" />
</template>
</el-table-column>
<el-table-column :label="$t('template.variableOptions')" min-width="130px">
<template #default="{ row }">
<el-input v-model="row.options" :disabled="row.type !== 'select'" />
</template>
</el-table-column>
<el-table-column :label="$t('template.variableRequired')" width="75px">
<template #default="{ row }">
<el-switch v-model="row.required" />
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="85px">
<template #default="{ $index }">
<el-button link type="danger" @click="variables.splice($index, 1)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" :loading="loading" @click="onSubmit">
{{ $t('commons.button.save') }}
</el-button>
</span>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { Website } from '@/api/interface/website';
import { createTemplate, getTemplate, updateTemplate, uploadTemplateZip } from '@/api/modules/website';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { reactive, ref, watch } from 'vue';
const open = ref(false);
const loading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const contentPlaceholder = '<html><body><h1>{{title}}</h1></body></html>';
const variableTypes = [
{ value: 'text', name: 'Text' },
{ value: 'textarea', name: 'Textarea' },
{ value: 'number', name: 'Number' },
{ value: 'select', name: 'Select' },
{ value: 'color', name: 'Color' },
];
const initForm = () => ({
id: 0,
name: '',
type: 'single',
content: '',
filePath: '',
variables: '',
remark: '',
});
const form = reactive(initForm());
const rules = {
name: [{ required: true, message: i18n.global.t('commons.rule.requiredInput'), trigger: 'blur' }],
type: [{ required: true, message: i18n.global.t('commons.rule.requiredSelect'), trigger: 'change' }],
};
const variables = ref<Website.TemplateVariable[]>([]);
const addVariable = (key?: string) => {
variables.value.push({
key: typeof key === 'string' ? key : '',
label: '',
type: 'text',
default: '',
options: '',
required: false,
});
};
const mergeDetectedKeys = (keys: string[]) => {
for (const key of keys) {
if (!variables.value.some((v) => v.key === key)) {
addVariable(key);
}
}
};
watch(
() => form.content,
(content) => {
if (form.type !== 'single' || !content) return;
const keys: string[] = [];
for (const match of content.matchAll(/\{\{(\w+)\}\}/g)) {
if (!keys.includes(match[1])) {
keys.push(match[1]);
}
}
mergeDetectedKeys(keys);
},
);
const handleUpload = async (file: globalThis.File) => {
try {
const res = await uploadTemplateZip(file);
form.filePath = res.data.filePath;
mergeDetectedKeys(res.data.variables || []);
MsgSuccess(i18n.global.t('commons.msg.uploadSuccess'));
} catch {}
return false;
};
const onSubmit = async () => {
await formRef.value.validate();
form.variables = JSON.stringify(variables.value.filter((v) => v.key));
loading.value = true;
try {
if (isEdit.value) {
await updateTemplate(form);
} else {
await createTemplate(form);
}
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
handleClose();
em('search');
} finally {
loading.value = false;
}
};
const em = defineEmits(['search']);
const handleClose = () => {
formRef.value?.resetFields();
Object.assign(form, initForm());
variables.value = [];
open.value = false;
};
const acceptParams = async (id?: number) => {
Object.assign(form, initForm());
variables.value = [];
isEdit.value = !!id;
if (id) {
const res = await getTemplate(id);
const tpl = res.data;
form.id = tpl.id;
form.name = tpl.name;
form.type = tpl.type;
form.content = tpl.content;
form.filePath = tpl.filePath;
form.variables = tpl.variables;
form.remark = tpl.remark;
try {
variables.value = JSON.parse(tpl.variables || '[]');
} catch {
variables.value = [];
}
}
open.value = true;
};
defineExpose({
acceptParams,
});
</script>
@@ -0,0 +1,234 @@
<template>
<DrawerPro v-model="open" :header="$t('template.outputCreate')" size="60%" @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="$t('template.selectTemplate')" prop="templateID">
<el-select v-model="form.templateID" class="w-full" filterable @change="onTemplateChange">
<el-option v-for="tpl in templates" :key="tpl.id" :value="tpl.id" :label="tpl.name" />
</el-select>
</el-form-item>
<el-form-item :label="$t('template.outputName')" prop="name">
<el-input v-model.trim="form.name" />
</el-form-item>
<div v-if="currentTemplate">
<el-divider content-position="left">
<el-text type="info" size="small">{{ $t('template.fillVariables') }}</el-text>
</el-divider>
<span v-if="!templateVariables.length" class="input-help">{{ $t('template.noVariables') }}</span>
<el-form-item
v-for="variable in templateVariables"
:key="variable.key"
:label="variable.label || variable.key"
:required="variable.required"
>
<el-input v-if="variable.type === 'text'" v-model="variableValues[variable.key]" />
<el-input
v-else-if="variable.type === 'textarea'"
v-model="variableValues[variable.key]"
type="textarea"
:rows="4"
/>
<el-input
v-else-if="variable.type === 'number'"
v-model="variableValues[variable.key]"
type="number"
/>
<el-select
v-else-if="variable.type === 'select'"
v-model="variableValues[variable.key]"
class="w-full"
>
<el-option v-for="opt in getOptions(variable)" :key="opt" :value="opt" :label="opt" />
</el-select>
<el-color-picker
v-else-if="variable.type === 'color'"
v-model="variableValues[variable.key]"
/>
<el-input v-else v-model="variableValues[variable.key]" />
</el-form-item>
<el-divider content-position="left">
<el-text type="info" size="small">{{ $t('template.preview') }}</el-text>
</el-divider>
<div class="preview-box">
<iframe
v-if="previewHTML"
:srcdoc="previewHTML"
class="preview-frame"
sandbox="allow-same-origin"
></iframe>
<div v-else class="preview-empty">{{ $t('template.previewEmpty') }}</div>
</div>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" :loading="loading" @click="onGenerate">
{{ $t('template.generate') }}
</el-button>
</span>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { Website } from '@/api/interface/website';
import { createTemplateOutput, previewTemplate, searchTemplates } from '@/api/modules/website';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { computed, reactive, ref, watch } from 'vue';
const open = ref(false);
const loading = ref(false);
const formRef = ref();
const templates = ref<Website.Template[]>([]);
const variableValues = reactive<Record<string, string>>({});
const previewHTML = ref('');
let previewTimer: ReturnType<typeof setTimeout> | null = null;
const form = reactive({
templateID: undefined as number | undefined,
name: '',
});
const rules = {
templateID: [{ required: true, message: i18n.global.t('commons.rule.requiredSelect'), trigger: 'change' }],
name: [{ required: true, message: i18n.global.t('commons.rule.requiredInput'), trigger: 'blur' }],
};
const currentTemplate = computed(() => templates.value.find((tpl) => tpl.id === form.templateID));
const templateVariables = computed<Website.TemplateVariable[]>(() => {
if (!currentTemplate.value) return [];
try {
const arr = JSON.parse(currentTemplate.value.variables || '[]');
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
});
const getOptions = (variable: Website.TemplateVariable) => {
return (variable.options || '')
.split(',')
.map((opt) => opt.trim())
.filter((opt) => opt);
};
const onTemplateChange = () => {
for (const key of Object.keys(variableValues)) {
delete variableValues[key];
}
for (const variable of templateVariables.value) {
variableValues[variable.key] = variable.default || '';
}
refreshPreview();
};
const renderLocal = (content: string) => {
let html = content;
for (const key of Object.keys(variableValues)) {
html = html.replace(new RegExp('\\{\\{' + key + '\\}\\}', 'g'), variableValues[key] || '');
}
return html.replace(/\{\{\w+\}\}/g, '');
};
const refreshPreview = () => {
if (!currentTemplate.value) {
previewHTML.value = '';
return;
}
if (currentTemplate.value.type === 'single') {
previewHTML.value = renderLocal(currentTemplate.value.content || '');
return;
}
if (previewTimer) {
clearTimeout(previewTimer);
}
previewTimer = setTimeout(async () => {
try {
const res = await previewTemplate({
templateID: form.templateID,
variableValues: { ...variableValues },
});
previewHTML.value = res.data.html;
} catch {
previewHTML.value = '';
}
}, 500);
};
watch(variableValues, () => {
refreshPreview();
});
const onGenerate = async () => {
await formRef.value.validate();
loading.value = true;
try {
await createTemplateOutput({
templateID: form.templateID,
name: form.name,
variableValues: { ...variableValues },
});
MsgSuccess(i18n.global.t('template.outputSuccess'));
handleClose();
em('search');
} finally {
loading.value = false;
}
};
const em = defineEmits(['search']);
const handleClose = () => {
formRef.value?.resetFields();
form.templateID = undefined;
form.name = '';
for (const key of Object.keys(variableValues)) {
delete variableValues[key];
}
previewHTML.value = '';
open.value = false;
};
const acceptParams = async (templateID?: number) => {
const res = await searchTemplates({ page: 1, pageSize: 1000, name: '', type: '' });
templates.value = res.data.items || [];
if (templateID && templates.value.some((tpl) => tpl.id === templateID)) {
form.templateID = templateID;
onTemplateChange();
}
open.value = true;
};
defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.preview-box {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
.preview-frame {
display: block;
width: 100%;
height: 420px;
border: none;
background-color: #fff;
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
</style>
@@ -0,0 +1,99 @@
<template>
<DrawerPro v-model="open" :header="$t('template.outputList')" size="60%" @close="handleClose">
<template #buttons>
<el-button type="primary" plain @click="openCreate()">
{{ $t('template.outputCreate') }}
</el-button>
</template>
<ComplexTable :data="data" :pagination-config="paginationConfig" @search="search()" v-loading="loading">
<el-table-column :label="$t('template.outputName')" prop="name" min-width="100px" show-overflow-tooltip />
<el-table-column :label="$t('template.name')" prop="templateName" min-width="100px" show-overflow-tooltip />
<el-table-column :label="$t('template.type')" width="110px">
<template #default="{ row }">
<el-tag>
{{ row.templateType === 'single' ? $t('template.single') : $t('template.multi') }}
</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('template.filePath')"
prop="outputPath"
min-width="160px"
show-overflow-tooltip
/>
<el-table-column prop="createdAt" :label="$t('commons.table.date')" :formatter="dateFormat" width="165px" />
<fu-table-operations :ellipsis="1" :buttons="buttons" :label="$t('commons.table.operate')" fixed="right" fix />
</ComplexTable>
<OpDialog ref="opRef" @search="search" />
<OutputCreate ref="outputCreateRef" @search="search" />
</DrawerPro>
</template>
<script lang="ts" setup>
import { Website } from '@/api/interface/website';
import { deleteTemplateOutput, searchTemplateOutputs } from '@/api/modules/website';
import OutputCreate from '@/views/website/template/output/create.vue';
import { dateFormat } from '@/utils/date';
import i18n from '@/lang';
import { reactive, ref } from 'vue';
const open = ref(false);
const loading = ref(false);
const data = ref<Website.TemplateOutputDTO[]>([]);
const opRef = ref();
const outputCreateRef = ref();
const paginationConfig = reactive({
cacheSizeKey: 'website-template-output-page-size',
currentPage: 1,
pageSize: 20,
total: 0,
});
const buttons = [
{
label: i18n.global.t('commons.button.delete'),
click: (row: Website.TemplateOutputDTO) => {
opRef.value.acceptParams({
title: i18n.global.t('commons.button.delete'),
names: [row.name],
msg: i18n.global.t('template.confirmDeleteOutput'),
api: deleteTemplateOutput,
params: { id: row.id },
});
},
},
];
const search = async () => {
loading.value = true;
try {
const res = await searchTemplateOutputs({
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
templateID: 0,
});
data.value = res.data.items || [];
paginationConfig.total = res.data.total;
} finally {
loading.value = false;
}
};
const openCreate = () => {
outputCreateRef.value.acceptParams();
};
const handleClose = () => {
open.value = false;
};
const acceptParams = () => {
open.value = true;
search();
};
defineExpose({
acceptParams,
});
</script>
@@ -271,6 +271,20 @@
</el-form-item>
</div>
</div>
<el-form-item
:label="$t('template.importButton')"
prop="templateOutputID"
v-if="website.type === 'static'"
>
<el-select v-model="website.templateOutputID" clearable class="p-w-200">
<el-option
v-for="output in templateOutputs"
:key="output.id"
:label="output.name + ' (' + output.templateName + ')'"
:value="output.id"
/>
</el-select>
</el-form-item>
<el-form-item prop="enableFtp" v-if="website.type === 'static' || website.type === 'runtime'">
<el-checkbox
@change="random"
@@ -443,6 +457,7 @@ import {
preCheck,
searchAcmeAccount,
getDirConfig,
searchTemplateOutputs,
} from '@/api/modules/website';
import { Rules, checkNumberRange } from '@/global/form-rules';
import i18n from '@/lang';
@@ -524,6 +539,7 @@ const initData = () => ({
domains: [],
parentWebsiteID: undefined,
siteDir: '',
templateOutputID: undefined,
streamPorts: '',
udp: false,
@@ -575,6 +591,7 @@ const appReq = reactive({
const apps = ref<App.AppItem[]>([]);
const preCheckRef = ref();
const staticPath = ref('');
const templateOutputs = ref<Website.TemplateOutputDTO[]>([]);
const runtimeResource = ref('appstore');
const initRuntimeReq = () => ({
page: 1,
@@ -791,6 +808,7 @@ const acceptParams = async (openrestyVersion: string) => {
runtimeResource.value = 'appstore';
runtimeReq.value = initRuntimeReq();
listAcmeAccount();
listTemplateOutputs();
steamConfig.value = initLbForm();
@@ -815,6 +833,12 @@ const listAcmeAccount = () => {
});
};
const listTemplateOutputs = () => {
searchTemplateOutputs({ page: 1, pageSize: 1000, templateID: 0 }).then((res) => {
templateOutputs.value = res.data.items || [];
});
};
const changeSSl = (sslid?: number) => {
if (!sslid) {
websiteSSL.value = undefined;
@@ -1029,6 +1053,9 @@ const submit = async (formEl: FormInstance | undefined) => {
website.value.algorithm = steamConfig.value.algorithm;
website.value.servers = steamConfig.value.servers;
}
if (website.value.type !== 'static' || !website.value.templateOutputID) {
website.value.templateOutputID = undefined;
}
const taskID = uuidv4();
website.value.taskID = taskID;
await createWebsite(website.value);