feat: 应用商店对接远程应用服务

This commit is contained in:
zhengkunwang223 2023-05-15 22:40:05 +08:00 committed by zhengkunwang223
parent d443103e2c
commit aeabed70db
20 changed files with 457 additions and 333 deletions

View file

@ -31,20 +31,42 @@ type AppVersion struct {
DetailId uint `json:"detailId"` DetailId uint `json:"detailId"`
} }
//type AppList struct {
// Version string `json:"version"`
// Tags []Tag `json:"tags"`
// Items []AppDefine `json:"items"`
//}
type AppList struct { type AppList struct {
Version string `json:"version"` Valid bool `json:"valid"`
Tags []Tag `json:"tags"` Violations []string `json:"violations"`
Items []AppDefine `json:"items"` LastModified int `json:"lastModified"`
Apps []AppDefine `json:"apps"`
Extra ExtraProperties `json:"additionalProperties"`
} }
type AppDefine struct { type AppDefine struct {
Key string `json:"key"` Icon string `json:"icon"`
Name string `json:"name"`
ReadMe string `json:"readMe"`
LastModified int `json:"lastModified"`
AppProperty AppProperty `json:"additionalProperties"`
Versions []AppConfigVersion `json:"versions"`
}
type ExtraProperties struct {
Tags []Tag `json:"tags"`
}
type AppProperty struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Versions []string `json:"versions"`
ShortDescZh string `json:"shortDescZh"` ShortDescZh string `json:"shortDescZh"`
ShortDescEn string `json:"shortDescEn"` ShortDescEn string `json:"shortDescEn"`
Type string `json:"type"` Key string `json:"key"`
Required []string `json:"Required"` Required []string `json:"Required"`
CrossVersionUpdate bool `json:"crossVersionUpdate"` CrossVersionUpdate bool `json:"crossVersionUpdate"`
Limit int `json:"limit"` Limit int `json:"limit"`
@ -54,8 +76,16 @@ type AppDefine struct {
Document string `json:"document"` Document string `json:"document"`
} }
func (define AppDefine) GetRequired() string { type AppConfigVersion struct {
by, _ := json.Marshal(define.Required) Name string `json:"name"`
LastModified int `json:"lastModified"`
DownloadUrl string `json:"downloadUrl"`
DownloadCallBackUrl string `json:"downloadCallBackUrl"`
AppForm interface{} `json:"additionalProperties"`
}
func (config AppProperty) GetRequired() string {
by, _ := json.Marshal(config.Required)
return string(by) return string(by)
} }
@ -79,6 +109,7 @@ type AppFormFields struct {
Edit bool `json:"edit"` Edit bool `json:"edit"`
Rule string `json:"rule"` Rule string `json:"rule"`
Multiple bool `json:"multiple"` Multiple bool `json:"multiple"`
Child interface{} `json:"child"`
Values []AppFormValue `json:"values"` Values []AppFormValue `json:"values"`
} }

View file

@ -1,6 +1,7 @@
package response package response
import ( import (
"github.com/1Panel-dev/1Panel/backend/app/dto"
"time" "time"
"github.com/1Panel-dev/1Panel/backend/app/model" "github.com/1Panel-dev/1Panel/backend/app/model"
@ -12,9 +13,11 @@ type AppRes struct {
} }
type AppUpdateRes struct { type AppUpdateRes struct {
Version string `json:"version"` //Version string `json:"version"`
CanUpdate bool `json:"canUpdate"` //DownloadPath string `json:"downloadPath"`
DownloadPath string `json:"downloadPath"` CanUpdate bool `json:"canUpdate"`
AppStoreLastModified int `json:"appStoreLastModified"`
List dto.AppList `json:"list"`
} }
type AppDTO struct { type AppDTO struct {

View file

@ -33,7 +33,8 @@ type SettingInfo struct {
WeChatVars string `json:"weChatVars"` WeChatVars string `json:"weChatVars"`
DingVars string `json:"dingVars"` DingVars string `json:"dingVars"`
AppStoreVersion string `json:"appStoreVersion"` AppStoreVersion string `json:"appStoreVersion"`
AppStoreLastModified string `json:"appStoreLastModified"`
} }
type SettingUpdate struct { type SettingUpdate struct {

View file

@ -2,22 +2,25 @@ package model
type App struct { type App struct {
BaseModel BaseModel
Name string `json:"name" gorm:"type:varchar(64);not null"` Name string `json:"name" gorm:"type:varchar(64);not null"`
Key string `json:"key" gorm:"type:varchar(64);not null;uniqueIndex"` Key string `json:"key" gorm:"type:varchar(64);not null;uniqueIndex"`
ShortDescZh string `json:"shortDescZh" gorm:"type:longtext;"` ShortDescZh string `json:"shortDescZh" gorm:"type:longtext;"`
ShortDescEn string `json:"shortDescEn" gorm:"type:longtext;"` ShortDescEn string `json:"shortDescEn" gorm:"type:longtext;"`
Icon string `json:"icon" gorm:"type:longtext;"` Icon string `json:"icon" gorm:"type:longtext;"`
Type string `json:"type" gorm:"type:varchar(64);not null"` Type string `json:"type" gorm:"type:varchar(64);not null"`
Status string `json:"status" gorm:"type:varchar(64);not null"` Status string `json:"status" gorm:"type:varchar(64);not null"`
Required string `json:"required" gorm:"type:varchar(64);not null"` Required string `json:"required" gorm:"type:varchar(64);not null"`
CrossVersionUpdate bool `json:"crossVersionUpdate"` CrossVersionUpdate bool `json:"crossVersionUpdate"`
Limit int `json:"limit" gorm:"type:Integer;not null"` Limit int `json:"limit" gorm:"type:Integer;not null"`
Website string `json:"website" gorm:"type:varchar(64);not null"` Website string `json:"website" gorm:"type:varchar(64);not null"`
Github string `json:"github" gorm:"type:varchar(64);not null"` Github string `json:"github" gorm:"type:varchar(64);not null"`
Document string `json:"document" gorm:"type:varchar(64);not null"` Document string `json:"document" gorm:"type:varchar(64);not null"`
Recommend int `json:"recommend" gorm:"type:Integer;not null"` Recommend int `json:"recommend" gorm:"type:Integer;not null"`
Resource string `json:"resource" gorm:"type:varchar;not null;default:remote"` Resource string `json:"resource" gorm:"type:varchar;not null;default:remote"`
Details []AppDetail `json:"-" gorm:"-:migration"` ReadMe string `json:"readMe" gorm:"type:varchar;"`
TagsKey []string `json:"-" gorm:"-"` LastModified int `json:"lastModified" gorm:"type:Integer;"`
AppTags []AppTag `json:"-" gorm:"-:migration"`
Details []AppDetail `json:"-" gorm:"-:migration"`
TagsKey []string `json:"-" gorm:"-"`
AppTags []AppTag `json:"-" gorm:"-:migration"`
} }

View file

@ -2,11 +2,14 @@ package model
type AppDetail struct { type AppDetail struct {
BaseModel BaseModel
AppId uint `json:"appId" gorm:"type:integer;not null"` AppId uint `json:"appId" gorm:"type:integer;not null"`
Version string `json:"version" gorm:"type:varchar(64);not null"` Version string `json:"version" gorm:"type:varchar(64);not null"`
Params string `json:"-" gorm:"type:longtext;"` Params string `json:"-" gorm:"type:longtext;"`
DockerCompose string `json:"-" gorm:"type:longtext;not null"` DockerCompose string `json:"-" gorm:"type:longtext;"`
Readme string `json:"readme" gorm:"type:longtext;"` Status string `json:"status" gorm:"type:varchar(64);not null"`
Status string `json:"status" gorm:"type:varchar(64);not null"` LastVersion string `json:"lastVersion" gorm:"type:varchar(64);"`
LastVersion string `json:"lastVersion" gorm:"type:varchar(64);"` LastModified int `json:"lastModified" gorm:"type:integer;"`
DownloadUrl string `json:"downloadUrl" gorm:"type:varchar;"`
DownloadCallBackUrl string `json:"downloadCallBackUrl" gorm:"type:longtext;"`
Update bool `json:"update"`
} }

View file

@ -24,6 +24,7 @@ type IAppRepo interface {
GetByKey(ctx context.Context, key string) (model.App, error) GetByKey(ctx context.Context, key string) (model.App, error)
Create(ctx context.Context, app *model.App) error Create(ctx context.Context, app *model.App) error
Save(ctx context.Context, app *model.App) error Save(ctx context.Context, app *model.App) error
BatchDelete(ctx context.Context, apps []model.App) error
} }
func NewIAppRepo() IAppRepo { func NewIAppRepo() IAppRepo {
@ -106,3 +107,7 @@ func (a AppRepo) Create(ctx context.Context, app *model.App) error {
func (a AppRepo) Save(ctx context.Context, app *model.App) error { func (a AppRepo) Save(ctx context.Context, app *model.App) error {
return getTx(ctx).Omit(clause.Associations).Save(app).Error return getTx(ctx).Omit(clause.Associations).Save(app).Error
} }
func (a AppRepo) BatchDelete(ctx context.Context, apps []model.App) error {
return getTx(ctx).Omit(clause.Associations).Delete(&apps).Error
}

View file

@ -5,14 +5,12 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/1Panel-dev/1Panel/backend/buserr" "github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/utils/docker" "github.com/1Panel-dev/1Panel/backend/utils/docker"
"io"
"net/http"
"path"
"strconv"
"github.com/1Panel-dev/1Panel/backend/app/dto" "github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/app/dto/request" "github.com/1Panel-dev/1Panel/backend/app/dto/request"
@ -253,7 +251,7 @@ func (a AppService) Install(ctx context.Context, req request.AppInstallCreate) (
} }
app, err = appRepo.GetFirst(commonRepo.WithByID(appDetail.AppId)) app, err = appRepo.GetFirst(commonRepo.WithByID(appDetail.AppId))
if err != nil { if err != nil {
return nil, err return
} }
if err = checkRequiredAndLimit(app); err != nil { if err = checkRequiredAndLimit(app); err != nil {
return return
@ -276,7 +274,7 @@ func (a AppService) Install(ctx context.Context, req request.AppInstallCreate) (
value, ok := composeMap["services"] value, ok := composeMap["services"]
if !ok { if !ok {
err = buserr.New("") err = buserr.New(constant.ErrFileParse)
return return
} }
servicesMap := value.(map[string]interface{}) servicesMap := value.(map[string]interface{})
@ -318,20 +316,11 @@ func (a AppService) Install(ctx context.Context, req request.AppInstallCreate) (
} }
} }
}() }()
if err = copyAppData(app.Key, appDetail.Version, req.Name, req.Params, app.Resource == constant.AppResourceLocal); err != nil {
return
}
fileOp := files.NewFileOp()
if err = fileOp.WriteFile(appInstall.GetComposePath(), strings.NewReader(string(composeByte)), 0775); err != nil {
return
}
paramByte, err = json.Marshal(req.Params) paramByte, err = json.Marshal(req.Params)
if err != nil { if err != nil {
return return
} }
appInstall.Env = string(paramByte) appInstall.Env = string(paramByte)
if err = appInstallRepo.Create(ctx, appInstall); err != nil { if err = appInstallRepo.Create(ctx, appInstall); err != nil {
return return
} }
@ -341,7 +330,13 @@ func (a AppService) Install(ctx context.Context, req request.AppInstallCreate) (
if err = upAppPre(app, appInstall); err != nil { if err = upAppPre(app, appInstall); err != nil {
return return
} }
go upApp(appInstall) go func() {
if err = downloadApp(app, appDetail, appInstall, req); err != nil {
_ = appInstallRepo.Save(ctx, appInstall)
return
}
upApp(appInstall)
}()
go updateToolApp(appInstall) go updateToolApp(appInstall)
return return
} }
@ -354,7 +349,7 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
versionUrl := fmt.Sprintf("%s/%s/%s/appstore/apps.json", global.CONF.System.RepoUrl, global.CONF.System.Mode, setting.SystemVersion) versionUrl := fmt.Sprintf("%s/%s/1panel.json", global.CONF.System.AppRepo, global.CONF.System.Mode)
versionRes, err := http.Get(versionUrl) versionRes, err := http.Get(versionUrl)
if err != nil { if err != nil {
return nil, err return nil, err
@ -368,162 +363,165 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
if err = json.Unmarshal(body, list); err != nil { if err = json.Unmarshal(body, list); err != nil {
return nil, err return nil, err
} }
res.Version = list.Version res.AppStoreLastModified = list.LastModified
if setting.AppStoreVersion == "" || common.CompareVersion(list.Version, setting.AppStoreVersion) { res.List = *list
appStoreLastModified, _ := strconv.Atoi(setting.AppStoreLastModified)
if setting.AppStoreLastModified == "" || list.LastModified > appStoreLastModified {
res.CanUpdate = true res.CanUpdate = true
res.DownloadPath = fmt.Sprintf("%s/%s/%s/appstore/apps-%s.tar.gz", global.CONF.System.RepoUrl, global.CONF.System.Mode, setting.SystemVersion, list.Version)
return res, err return res, err
} }
res.CanUpdate = true
return res, nil return res, nil
} }
func (a AppService) SyncAppListFromLocal() { func (a AppService) SyncAppListFromLocal() {
fileOp := files.NewFileOp() //fileOp := files.NewFileOp()
appDir := constant.LocalAppResourceDir //appDir := constant.LocalAppResourceDir
listFile := path.Join(appDir, "list.json") //listFile := path.Join(appDir, "list.json")
if !fileOp.Stat(listFile) { //if !fileOp.Stat(listFile) {
return // return
} //}
global.LOG.Infof("start sync local apps...") //global.LOG.Infof("start sync local apps...")
content, err := fileOp.GetContent(listFile) //content, err := fileOp.GetContent(listFile)
if err != nil { //if err != nil {
global.LOG.Errorf("get list.json content failed %s", err.Error()) // global.LOG.Errorf("get list.json content failed %s", err.Error())
return // return
} //}
list := &dto.AppList{} //list := &dto.AppList{}
if err := json.Unmarshal(content, list); err != nil { //if err := json.Unmarshal(content, list); err != nil {
global.LOG.Errorf("unmarshal list.json failed %s", err.Error()) // global.LOG.Errorf("unmarshal list.json failed %s", err.Error())
return // return
} //}
oldApps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceLocal)) //oldApps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceLocal))
appsMap := getApps(oldApps, list.Items, true) //appsMap := getApps(oldApps, list.Apps, true)
for _, l := range list.Items { //for _, l := range list.Apps {
localKey := "local" + l.Key // localKey := "local" + l.Config.Key
app := appsMap[localKey] // app := appsMap[localKey]
icon, err := os.ReadFile(path.Join(appDir, l.Key, "metadata", "logo.png")) // icon, err := os.ReadFile(path.Join(appDir, l.Config.Key, "metadata", "logo.png"))
if err != nil { // if err != nil {
global.LOG.Errorf("get [%s] icon error: %s", l.Name, err.Error()) // global.LOG.Errorf("get [%s] icon error: %s", l.Name, err.Error())
continue // continue
} // }
iconStr := base64.StdEncoding.EncodeToString(icon) // iconStr := base64.StdEncoding.EncodeToString(icon)
app.Icon = iconStr // app.Icon = iconStr
app.TagsKey = append(l.Tags, "Local") // app.TagsKey = append(l.Tags, "Local")
app.Recommend = 9999 // app.Recommend = 9999
versions := l.Versions // versions := l.Versions
detailsMap := getAppDetails(app.Details, versions) // detailsMap := getAppDetails(app.Details, versions)
//
for _, v := range versions { // for _, v := range versions {
detail := detailsMap[v] // detail := detailsMap[v]
detailPath := path.Join(appDir, l.Key, "versions", v) // detailPath := path.Join(appDir, l.Key, "versions", v)
if _, err := os.Stat(detailPath); err != nil { // if _, err := os.Stat(detailPath); err != nil {
global.LOG.Errorf("get [%s] folder error: %s", detailPath, err.Error()) // global.LOG.Errorf("get [%s] folder error: %s", detailPath, err.Error())
continue // continue
} // }
readmeStr, err := os.ReadFile(path.Join(detailPath, "README.md")) // readmeStr, err := os.ReadFile(path.Join(detailPath, "README.md"))
if err != nil { // if err != nil {
global.LOG.Errorf("get [%s] README error: %s", detailPath, err.Error()) // global.LOG.Errorf("get [%s] README error: %s", detailPath, err.Error())
} // }
detail.Readme = string(readmeStr) // detail.Readme = string(readmeStr)
dockerComposeStr, err := os.ReadFile(path.Join(detailPath, "docker-compose.yml")) // dockerComposeStr, err := os.ReadFile(path.Join(detailPath, "docker-compose.yml"))
if err != nil { // if err != nil {
global.LOG.Errorf("get [%s] docker-compose.yml error: %s", detailPath, err.Error()) // global.LOG.Errorf("get [%s] docker-compose.yml error: %s", detailPath, err.Error())
continue // continue
} // }
detail.DockerCompose = string(dockerComposeStr) // detail.DockerCompose = string(dockerComposeStr)
paramStr, err := os.ReadFile(path.Join(detailPath, "config.json")) // paramStr, err := os.ReadFile(path.Join(detailPath, "config.json"))
if err != nil { // if err != nil {
global.LOG.Errorf("get [%s] form.json error: %s", detailPath, err.Error()) // global.LOG.Errorf("get [%s] form.json error: %s", detailPath, err.Error())
} // }
detail.Params = string(paramStr) // detail.Params = string(paramStr)
detailsMap[v] = detail // detailsMap[v] = detail
} // }
var newDetails []model.AppDetail // var newDetails []model.AppDetail
for _, v := range detailsMap { // for _, v := range detailsMap {
newDetails = append(newDetails, v) // newDetails = append(newDetails, v)
} // }
app.Details = newDetails // app.Details = newDetails
appsMap[localKey] = app // appsMap[localKey] = app
} //}
var ( //var (
addAppArray []model.App // addAppArray []model.App
updateArray []model.App // updateArray []model.App
appIds []uint // appIds []uint
) //)
for _, v := range appsMap { //for _, v := range appsMap {
if v.ID == 0 { // if v.ID == 0 {
addAppArray = append(addAppArray, v) // addAppArray = append(addAppArray, v)
} else { // } else {
updateArray = append(updateArray, v) // updateArray = append(updateArray, v)
appIds = append(appIds, v.ID) // appIds = append(appIds, v.ID)
} // }
} //}
tx, ctx := getTxAndContext() //tx, ctx := getTxAndContext()
if len(addAppArray) > 0 { //if len(addAppArray) > 0 {
if err := appRepo.BatchCreate(ctx, addAppArray); err != nil { // if err := appRepo.BatchCreate(ctx, addAppArray); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} // }
} //}
for _, update := range updateArray { //for _, update := range updateArray {
if err := appRepo.Save(ctx, &update); err != nil { // if err := appRepo.Save(ctx, &update); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} // }
} //}
if err := appTagRepo.DeleteByAppIds(ctx, appIds); err != nil { //if err := appTagRepo.DeleteByAppIds(ctx, appIds); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} //}
apps := append(addAppArray, updateArray...) //apps := append(addAppArray, updateArray...)
var ( //var (
addDetails []model.AppDetail // addDetails []model.AppDetail
updateDetails []model.AppDetail // updateDetails []model.AppDetail
appTags []*model.AppTag // appTags []*model.AppTag
) //)
tags, _ := tagRepo.All() //tags, _ := tagRepo.All()
tagMap := make(map[string]uint, len(tags)) //tagMap := make(map[string]uint, len(tags))
for _, app := range tags { //for _, app := range tags {
tagMap[app.Key] = app.ID // tagMap[app.Key] = app.ID
} //}
for _, a := range apps { //for _, a := range apps {
for _, t := range a.TagsKey { // for _, t := range a.TagsKey {
tagId, ok := tagMap[t] // tagId, ok := tagMap[t]
if ok { // if ok {
appTags = append(appTags, &model.AppTag{ // appTags = append(appTags, &model.AppTag{
AppId: a.ID, // AppId: a.ID,
TagId: tagId, // TagId: tagId,
}) // })
} // }
} // }
for _, d := range a.Details { // for _, d := range a.Details {
d.AppId = a.ID // d.AppId = a.ID
if d.ID == 0 { // if d.ID == 0 {
addDetails = append(addDetails, d) // addDetails = append(addDetails, d)
} else { // } else {
updateDetails = append(updateDetails, d) // updateDetails = append(updateDetails, d)
} // }
} // }
} //}
if len(addDetails) > 0 { //if len(addDetails) > 0 {
if err := appDetailRepo.BatchCreate(ctx, addDetails); err != nil { // if err := appDetailRepo.BatchCreate(ctx, addDetails); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} // }
} //}
for _, u := range updateDetails { //for _, u := range updateDetails {
if err := appDetailRepo.Update(ctx, u); err != nil { // if err := appDetailRepo.Update(ctx, u); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} // }
} //}
if len(appTags) > 0 { //if len(appTags) > 0 {
if err := appTagRepo.BatchCreate(ctx, appTags); err != nil { // if err := appTagRepo.BatchCreate(ctx, appTags); err != nil {
tx.Rollback() // tx.Rollback()
return // return
} // }
} //}
tx.Commit() //tx.Commit()
global.LOG.Infof("sync local apps success") //global.LOG.Infof("sync local apps success")
} }
func (a AppService) SyncAppListFromRemote() error { func (a AppService) SyncAppListFromRemote() error {
updateRes, err := a.GetAppUpdate() updateRes, err := a.GetAppUpdate()
@ -531,28 +529,15 @@ func (a AppService) SyncAppListFromRemote() error {
return err return err
} }
if !updateRes.CanUpdate { if !updateRes.CanUpdate {
global.LOG.Infof("The latest version is [%s] The app store is already up to date", updateRes.Version) //global.LOG.Infof("The latest version is [%s] The app store is already up to date", updateRes.Version)
return nil return nil
} }
if err := getAppFromRepo(updateRes.DownloadPath, updateRes.Version); err != nil {
global.LOG.Errorf("get app from oss error: %s", err.Error())
return err
}
appDir := constant.AppResourceDir
listFile := path.Join(appDir, "list.json")
content, err := os.ReadFile(listFile)
if err != nil {
return err
}
list := &dto.AppList{}
if err := json.Unmarshal(content, list); err != nil {
return err
}
var ( var (
tags []*model.Tag tags []*model.Tag
appTags []*model.AppTag appTags []*model.AppTag
list = updateRes.List
) )
for _, t := range list.Tags { for _, t := range list.Extra.Tags {
tags = append(tags, &model.Tag{ tags = append(tags, &model.Tag{
Key: t.Key, Key: t.Key,
Name: t.Name, Name: t.Name,
@ -562,70 +547,86 @@ func (a AppService) SyncAppListFromRemote() error {
if err != nil { if err != nil {
return err return err
} }
appsMap := getApps(oldApps, list.Items, false) baseRemoteUrl := fmt.Sprintf("%s/%s/1panel", global.CONF.System.AppRepo, global.CONF.System.Mode)
for _, l := range list.Items { appsMap := getApps(oldApps, list.Apps, false)
app := appsMap[l.Key] for _, l := range list.Apps {
icon, err := os.ReadFile(path.Join(appDir, l.Key, "metadata", "logo.png")) app := appsMap[l.AppProperty.Key]
iconRes, err := http.Get(l.Icon)
if err != nil { if err != nil {
global.LOG.Errorf("get [%s] icon error: %s", l.Name, err.Error()) return err
continue
} }
iconStr := base64.StdEncoding.EncodeToString(icon) body, err := io.ReadAll(iconRes.Body)
if err != nil {
return err
}
iconStr := base64.StdEncoding.EncodeToString(body)
app.Icon = iconStr app.Icon = iconStr
app.TagsKey = l.Tags app.TagsKey = l.AppProperty.Tags
if l.Recommend > 0 { if l.AppProperty.Recommend > 0 {
app.Recommend = l.Recommend app.Recommend = l.AppProperty.Recommend
} else { } else {
app.Recommend = 9999 app.Recommend = 9999
} }
app.ReadMe = l.ReadMe
app.LastModified = l.LastModified
versions := l.Versions versions := l.Versions
detailsMap := getAppDetails(app.Details, versions) detailsMap := getAppDetails(app.Details, versions)
for _, v := range versions { for _, v := range versions {
detail := detailsMap[v] version := v.Name
detailPath := path.Join(appDir, l.Key, "versions", v) detail := detailsMap[version]
if _, err := os.Stat(detailPath); err != nil {
global.LOG.Errorf("get [%s] folder error: %s", detailPath, err.Error()) dockerComposeUrl := fmt.Sprintf("%s/%s/%s/%s", baseRemoteUrl, app.Key, version, "docker-compose.yml")
continue composeRes, err := http.Get(dockerComposeUrl)
}
readmeStr, err := os.ReadFile(path.Join(detailPath, "README.md"))
if err != nil { if err != nil {
global.LOG.Errorf("get [%s] README error: %s", detailPath, err.Error()) return err
} }
detail.Readme = string(readmeStr) bodyContent, err := io.ReadAll(composeRes.Body)
dockerComposeStr, err := os.ReadFile(path.Join(detailPath, "docker-compose.yml"))
if err != nil { if err != nil {
global.LOG.Errorf("get [%s] docker-compose.yml error: %s", detailPath, err.Error()) return err
continue
} }
detail.DockerCompose = string(dockerComposeStr) detail.DockerCompose = string(bodyContent)
paramStr, err := os.ReadFile(path.Join(detailPath, "config.json"))
if err != nil { paramByte, _ := json.Marshal(v.AppForm)
global.LOG.Errorf("get [%s] form.json error: %s", detailPath, err.Error()) detail.Params = string(paramByte)
detail.DownloadUrl = v.DownloadUrl
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
if v.LastModified > detail.LastModified {
detail.Update = true
detail.LastModified = v.LastModified
} }
detail.Params = string(paramStr) detailsMap[version] = detail
detailsMap[v] = detail
} }
var newDetails []model.AppDetail var newDetails []model.AppDetail
for _, v := range detailsMap { for _, detail := range detailsMap {
newDetails = append(newDetails, v) newDetails = append(newDetails, detail)
} }
app.Details = newDetails app.Details = newDetails
appsMap[l.Key] = app appsMap[l.AppProperty.Key] = app
} }
var ( var (
addAppArray []model.App addAppArray []model.App
updateArray []model.App updateAppArray []model.App
tagMap = make(map[string]uint, len(tags)) deleteAppArray []model.App
deleteIds []uint
tagMap = make(map[string]uint, len(tags))
) )
for _, v := range appsMap { for _, v := range appsMap {
if v.ID == 0 { if v.ID == 0 {
addAppArray = append(addAppArray, v) addAppArray = append(addAppArray, v)
} else { } else {
updateArray = append(updateArray, v) if v.Status == constant.AppTakeDown {
installs, _ := appInstallRepo.ListBy(appInstallRepo.WithAppId(v.ID))
if len(installs) > 0 {
updateAppArray = append(updateAppArray, v)
continue
}
deleteAppArray = append(deleteAppArray, v)
deleteIds = append(deleteIds, v.ID)
} else {
updateAppArray = append(updateAppArray, v)
}
} }
} }
tx, ctx := getTxAndContext() tx, ctx := getTxAndContext()
@ -635,6 +636,16 @@ func (a AppService) SyncAppListFromRemote() error {
return err return err
} }
} }
if len(deleteAppArray) > 0 {
if err := appRepo.BatchDelete(ctx, deleteAppArray); err != nil {
tx.Rollback()
return err
}
if err := appDetailRepo.DeleteByAppIds(ctx, deleteIds); err != nil {
tx.Rollback()
return err
}
}
if err := tagRepo.DeleteAll(ctx); err != nil { if err := tagRepo.DeleteAll(ctx); err != nil {
tx.Rollback() tx.Rollback()
return err return err
@ -648,34 +659,39 @@ func (a AppService) SyncAppListFromRemote() error {
tagMap[t.Key] = t.ID tagMap[t.Key] = t.ID
} }
} }
for _, update := range updateArray { for _, update := range updateAppArray {
if err := appRepo.Save(ctx, &update); err != nil { if err := appRepo.Save(ctx, &update); err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
} }
apps := append(addAppArray, updateArray...) apps := append(addAppArray, updateAppArray...)
var ( var (
addDetails []model.AppDetail addDetails []model.AppDetail
updateDetails []model.AppDetail updateDetails []model.AppDetail
deleteDetails []model.AppDetail
) )
for _, a := range apps { for _, app := range apps {
for _, t := range a.TagsKey { for _, t := range app.TagsKey {
tagId, ok := tagMap[t] tagId, ok := tagMap[t]
if ok { if ok {
appTags = append(appTags, &model.AppTag{ appTags = append(appTags, &model.AppTag{
AppId: a.ID, AppId: app.ID,
TagId: tagId, TagId: tagId,
}) })
} }
} }
for _, d := range a.Details { for _, d := range app.Details {
d.AppId = a.ID d.AppId = app.ID
if d.ID == 0 { if d.ID == 0 {
addDetails = append(addDetails, d) addDetails = append(addDetails, d)
} else { } else {
updateDetails = append(updateDetails, d) if d.Status == constant.AppTakeDown {
deleteDetails = append(deleteDetails, d)
} else {
updateDetails = append(updateDetails, d)
}
} }
} }
} }
@ -691,6 +707,7 @@ func (a AppService) SyncAppListFromRemote() error {
return err return err
} }
} }
if err := appTagRepo.DeleteAll(ctx); err != nil { if err := appTagRepo.DeleteAll(ctx); err != nil {
tx.Rollback() tx.Rollback()
return err return err
@ -702,5 +719,8 @@ func (a AppService) SyncAppListFromRemote() error {
} }
} }
tx.Commit() tx.Commit()
if err := NewISettingService().Update("AppStoreLastModified", strconv.Itoa(list.LastModified)); err != nil {
return err
}
return nil return nil
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/1Panel-dev/1Panel/backend/utils/files"
"math" "math"
"os" "os"
"path" "path"
@ -183,6 +184,9 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil { if err != nil {
return err return err
} }
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
return buserr.New(constant.ErrInstallDirNotFound)
}
dockerComposePath := install.GetComposePath() dockerComposePath := install.GetComposePath()
switch req.Operate { switch req.Operate {
case constant.Rebuild: case constant.Rebuild:

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper" "github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto/request"
"github.com/compose-spec/compose-go/types" "github.com/compose-spec/compose-go/types"
"github.com/subosito/gotenv" "github.com/subosito/gotenv"
"math" "math"
@ -225,7 +226,7 @@ func upgradeInstall(installId uint, detailId uint) error {
return err return err
} }
detailDir := path.Join(constant.ResourceDir, "apps", install.App.Key, "versions", detail.Version) detailDir := path.Join(constant.ResourceDir, "apps", install.App.Resource, install.App.Key, detail.Version)
if install.App.Resource == constant.AppResourceLocal { if install.App.Resource == constant.AppResourceLocal {
detailDir = path.Join(constant.ResourceDir, "localApps", strings.TrimPrefix(install.App.Key, "local"), "versions", detail.Version) detailDir = path.Join(constant.ResourceDir, "localApps", strings.TrimPrefix(install.App.Key, "local"), "versions", detail.Version)
} }
@ -359,24 +360,48 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
} }
} }
func copyAppData(key, version, installName string, params map[string]interface{}, isLocal bool) (err error) { func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, req request.AppInstallCreate) (err error) {
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
appResourceDir := constant.AppResourceDir appResourceDir := path.Join(constant.AppResourceDir, app.Resource)
installAppDir := path.Join(constant.AppInstallDir, key)
appKey := key if app.Resource == constant.AppResourceRemote && appDetail.Update {
if isLocal { appDownloadDir := path.Join(appResourceDir, app.Key)
if !fileOp.Stat(appDownloadDir) {
_ = fileOp.CreateDir(appDownloadDir, 0755)
}
appVersionDir := path.Join(appDownloadDir, appDetail.Version)
if !fileOp.Stat(appVersionDir) {
_ = fileOp.CreateDir(appVersionDir, 0755)
}
global.LOG.Infof("download app[%s] from %s", app.Name, appDetail.DownloadUrl)
filePath := path.Join(appVersionDir, appDetail.Version+".tar.gz")
if err = fileOp.DownloadFile(appDetail.DownloadUrl, filePath); err != nil {
appInstall.Status = constant.DownloadErr
global.LOG.Errorf("download app[%s] error %v", app.Name, err)
return
}
if err = fileOp.Decompress(filePath, appVersionDir, files.TarGz); err != nil {
global.LOG.Errorf("decompress app[%s] error %v", app.Name, err)
appInstall.Status = constant.DownloadErr
return
}
_ = fileOp.DeleteFile(filePath)
}
appKey := app.Key
installAppDir := path.Join(constant.AppInstallDir, app.Key)
if app.Resource == constant.AppResourceLocal {
appResourceDir = constant.LocalAppResourceDir appResourceDir = constant.LocalAppResourceDir
appKey = strings.TrimPrefix(key, "local") appKey = strings.TrimPrefix(app.Resource, "local")
installAppDir = path.Join(constant.LocalAppInstallDir, appKey) installAppDir = path.Join(constant.LocalAppInstallDir, appKey)
} }
resourceDir := path.Join(appResourceDir, appKey, "versions", version) resourceDir := path.Join(appResourceDir, appKey, appDetail.Version)
if !fileOp.Stat(installAppDir) { if !fileOp.Stat(installAppDir) {
if err = fileOp.CreateDir(installAppDir, 0755); err != nil { if err = fileOp.CreateDir(installAppDir, 0755); err != nil {
return return
} }
} }
appDir := path.Join(installAppDir, installName) appDir := path.Join(installAppDir, req.Name)
if fileOp.Stat(appDir) { if fileOp.Stat(appDir) {
if err = fileOp.DeleteDir(appDir); err != nil { if err = fileOp.DeleteDir(appDir); err != nil {
return return
@ -385,14 +410,14 @@ func copyAppData(key, version, installName string, params map[string]interface{}
if err = fileOp.Copy(resourceDir, installAppDir); err != nil { if err = fileOp.Copy(resourceDir, installAppDir); err != nil {
return return
} }
versionDir := path.Join(installAppDir, version) versionDir := path.Join(installAppDir, appDetail.Version)
if err = fileOp.Rename(versionDir, appDir); err != nil { if err = fileOp.Rename(versionDir, appDir); err != nil {
return return
} }
envPath := path.Join(appDir, ".env") envPath := path.Join(appDir, ".env")
envParams := make(map[string]string, len(params)) envParams := make(map[string]string, len(req.Params))
handleMap(params, envParams) handleMap(req.Params, envParams)
if err = env.Write(envParams, envPath); err != nil { if err = env.Write(envParams, envPath); err != nil {
return return
} }
@ -473,21 +498,21 @@ func rebuildApp(appInstall model.AppInstall) error {
return syncById(appInstall.ID) return syncById(appInstall.ID)
} }
func getAppDetails(details []model.AppDetail, versions []string) map[string]model.AppDetail { func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) map[string]model.AppDetail {
appDetails := make(map[string]model.AppDetail, len(details)) appDetails := make(map[string]model.AppDetail, len(details))
for _, old := range details { for _, old := range details {
old.Status = constant.AppTakeDown old.Status = constant.AppTakeDown
appDetails[old.Version] = old appDetails[old.Version] = old
} }
for _, v := range versions { for _, v := range versions {
detail, ok := appDetails[v] version := v.Name
detail, ok := appDetails[version]
if ok { if ok {
detail.Status = constant.AppNormal detail.Status = constant.AppNormal
appDetails[v] = detail appDetails[version] = detail
} else { } else {
appDetails[v] = model.AppDetail{ appDetails[version] = model.AppDetail{
Version: v, Version: version,
Status: constant.AppNormal, Status: constant.AppNormal,
} }
} }
@ -502,7 +527,8 @@ func getApps(oldApps []model.App, items []dto.AppDefine, isLocal bool) map[strin
apps[old.Key] = old apps[old.Key] = old
} }
for _, item := range items { for _, item := range items {
key := item.Key config := item.AppProperty
key := config.Key
if isLocal { if isLocal {
key = "local" + key key = "local" + key
} }
@ -516,17 +542,19 @@ func getApps(oldApps []model.App, items []dto.AppDefine, isLocal bool) map[strin
app.Resource = constant.AppResourceRemote app.Resource = constant.AppResourceRemote
} }
app.Name = item.Name app.Name = item.Name
app.Limit = item.Limit app.Limit = config.Limit
app.Key = key app.Key = key
app.ShortDescZh = item.ShortDescZh app.ShortDescZh = config.ShortDescZh
app.ShortDescEn = item.ShortDescEn app.ShortDescEn = config.ShortDescEn
app.Website = item.Website app.Website = config.Website
app.Document = item.Document app.Document = config.Document
app.Github = item.Github app.Github = config.Github
app.Type = item.Type app.Type = config.Type
app.CrossVersionUpdate = item.CrossVersionUpdate app.CrossVersionUpdate = config.CrossVersionUpdate
app.Required = item.GetRequired() app.Required = config.GetRequired()
app.Status = constant.AppNormal app.Status = constant.AppNormal
app.LastModified = item.LastModified
app.ReadMe = item.ReadMe
apps[key] = app apps[key] = app
} }
return apps return apps

View file

@ -1,23 +1,24 @@
package configs package configs
type System struct { type System struct {
Port string `mapstructure:"port"` Port string `mapstructure:"port"`
SSL string `mapstructure:"ssl"` SSL string `mapstructure:"ssl"`
DbFile string `mapstructure:"db_file"` DbFile string `mapstructure:"db_file"`
DbPath string `mapstructure:"db_path"` DbPath string `mapstructure:"db_path"`
LogPath string `mapstructure:"log_path"` LogPath string `mapstructure:"log_path"`
DataDir string `mapstructure:"data_dir"` DataDir string `mapstructure:"data_dir"`
TmpDir string `mapstructure:"tmp_dir"` TmpDir string `mapstructure:"tmp_dir"`
Cache string `mapstructure:"cache"` Cache string `mapstructure:"cache"`
Backup string `mapstructure:"backup"` Backup string `mapstructure:"backup"`
EncryptKey string `mapstructure:"encrypt_key"` EncryptKey string `mapstructure:"encrypt_key"`
BaseDir string `mapstructure:"base_dir"` BaseDir string `mapstructure:"base_dir"`
Mode string `mapstructure:"mode"` Mode string `mapstructure:"mode"`
RepoUrl string `mapstructure:"repo_url"` RepoUrl string `mapstructure:"repo_url"`
Version string `mapstructure:"version"` Version string `mapstructure:"version"`
Username string `mapstructure:"username"` Username string `mapstructure:"username"`
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
Entrance string `mapstructure:"entrance"` Entrance string `mapstructure:"entrance"`
IsDemo bool `mapstructure:"is_demo"` IsDemo bool `mapstructure:"is_demo"`
AppRepo string `mapstructure:"app_repo"`
ChangeUserInfo bool `mapstructure:"change_user_info"` ChangeUserInfo bool `mapstructure:"change_user_info"`
} }

View file

@ -1,12 +1,14 @@
package constant package constant
const ( const (
Running = "Running" Running = "Running"
UnHealthy = "UnHealthy" UnHealthy = "UnHealthy"
Error = "Error" Error = "Error"
Stopped = "Stopped" Stopped = "Stopped"
Installing = "Installing" Installing = "Installing"
Syncing = "Syncing" Syncing = "Syncing"
DownloadErr = "DownloadErr"
DirNotFound = "DirNotFound"
ContainerPrefix = "1Panel-" ContainerPrefix = "1Panel-"

View file

@ -30,23 +30,16 @@ var (
ErrInvalidParams = errors.New("ErrInvalidParams") ErrInvalidParams = errors.New("ErrInvalidParams")
ErrTokenParse = errors.New("ErrTokenParse") ErrTokenParse = errors.New("ErrTokenParse")
ErrPageGenerate = errors.New("generate page info failed")
ErrRepoNotValid = "ErrRepoNotValid"
) )
// api // api
var ( var (
ErrTypeInternalServer = "ErrInternalServer" ErrTypeInternalServer = "ErrInternalServer"
ErrTypeInvalidParams = "ErrInvalidParams" ErrTypeInvalidParams = "ErrInvalidParams"
ErrTypeToken = "ErrToken"
ErrTypeTokenTimeOut = "ErrTokenTimeOut"
ErrTypeNotLogin = "ErrNotLogin" ErrTypeNotLogin = "ErrNotLogin"
ErrTypePasswordExpired = "ErrPasswordExpired" ErrTypePasswordExpired = "ErrPasswordExpired"
ErrTypeNotSafety = "ErrNotSafety"
ErrNameIsExist = "ErrNameIsExist" ErrNameIsExist = "ErrNameIsExist"
ErrDemoEnvironment = "ErrDemoEnvironment" ErrDemoEnvironment = "ErrDemoEnvironment"
ErrInitUser = "ErrInitUser"
) )
// app // app
@ -55,20 +48,20 @@ var (
ErrAppLimit = "ErrAppLimit" ErrAppLimit = "ErrAppLimit"
ErrAppRequired = "ErrAppRequired" ErrAppRequired = "ErrAppRequired"
ErrFileCanNotRead = "ErrFileCanNotRead" ErrFileCanNotRead = "ErrFileCanNotRead"
ErrFileToLarge = "ErrFileToLarge"
ErrNotInstall = "ErrNotInstall" ErrNotInstall = "ErrNotInstall"
ErrPortInOtherApp = "ErrPortInOtherApp" ErrPortInOtherApp = "ErrPortInOtherApp"
ErrDbUserNotValid = "ErrDbUserNotValid" ErrDbUserNotValid = "ErrDbUserNotValid"
ErrUpdateBuWebsite = "ErrUpdateBuWebsite" ErrUpdateBuWebsite = "ErrUpdateBuWebsite"
Err1PanelNetworkFailed = "Err1PanelNetworkFailed" Err1PanelNetworkFailed = "Err1PanelNetworkFailed"
ErrCmdTimeout = "ErrCmdTimeout" ErrCmdTimeout = "ErrCmdTimeout"
ErrFileParse = "ErrFileParse"
ErrInstallDirNotFound = "ErrInstallDirNotFound"
) )
// website // website
var ( var (
ErrDomainIsExist = "ErrDomainIsExist" ErrDomainIsExist = "ErrDomainIsExist"
ErrAliasIsExist = "ErrAliasIsExist" ErrAliasIsExist = "ErrAliasIsExist"
ErrAppDelete = "ErrAppDelete"
ErrGroupIsUsed = "ErrGroupIsUsed" ErrGroupIsUsed = "ErrGroupIsUsed"
ErrUsernameIsExist = "ErrUsernameIsExist" ErrUsernameIsExist = "ErrUsernameIsExist"
ErrUsernameIsNotExist = "ErrUsernameIsNotExist" ErrUsernameIsNotExist = "ErrUsernameIsNotExist"

View file

@ -29,6 +29,8 @@ ErrDbUserNotValid: "Stock database, username and password do not match"
ErrDockerComposeNotValid: "docker-compose file format error!" ErrDockerComposeNotValid: "docker-compose file format error!"
ErrUpdateBuWebsite: 'The application was updated successfully, but the modification of the website configuration file failed, please check the configuration!' ErrUpdateBuWebsite: 'The application was updated successfully, but the modification of the website configuration file failed, please check the configuration!'
Err1PanelNetworkFailed: 'Default container network creation failed! {{ .detail }}' Err1PanelNetworkFailed: 'Default container network creation failed! {{ .detail }}'
ErrFileParse: 'Application docker-compose file parsing failed!'
ErrInstallDirNotFound: 'installation directory does not exist'
#file #file
ErrFileCanNotRead: "File can not read" ErrFileCanNotRead: "File can not read"

View file

@ -29,6 +29,8 @@ ErrDbUserNotValid: "存量数据库,用户名密码不匹配!"
ErrDockerComposeNotValid: "docker-compose 文件格式错误" ErrDockerComposeNotValid: "docker-compose 文件格式错误"
ErrUpdateBuWebsite: '应用更新成功,但是网站配置文件修改失败,请检查配置!' ErrUpdateBuWebsite: '应用更新成功,但是网站配置文件修改失败,请检查配置!'
Err1PanelNetworkFailed: '默认容器网络创建失败!{{ .detail }}' Err1PanelNetworkFailed: '默认容器网络创建失败!{{ .detail }}'
ErrFileParse: '应用 docker-compose 文件解析失败!'
ErrInstallDirNotFound: '安装目录不存在'
#file #file
ErrFileCanNotRead: "此文件不支持预览" ErrFileCanNotRead: "此文件不支持预览"

View file

@ -26,6 +26,8 @@ func Init() {
migrations.UpdateTableHost, migrations.UpdateTableHost,
migrations.UpdateTableWebsite, migrations.UpdateTableWebsite,
migrations.AddEntranceAndSSL, migrations.AddEntranceAndSSL,
migrations.UpdateTableSetting,
migrations.UpdateTableAppDetail,
}) })
if err := m.Migrate(); err != nil { if err := m.Migrate(); err != nil {
global.LOG.Error(err) global.LOG.Error(err)

View file

@ -312,3 +312,26 @@ var AddEntranceAndSSL = &gormigrate.Migration{
return tx.AutoMigrate(&model.Website{}) return tx.AutoMigrate(&model.Website{})
}, },
} }
var UpdateTableSetting = &gormigrate.Migration{
ID: "20200511-update-table-setting",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.App{}); err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "AppStoreLastModified", Value: ""}).Error; err != nil {
return err
}
return nil
},
}
var UpdateTableAppDetail = &gormigrate.Migration{
ID: "20200513-update-table-app-detail",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.AppDetail{}); err != nil {
return err
}
return nil
},
}

View file

@ -3,6 +3,7 @@ system:
base_dir: /opt base_dir: /opt
mode: dev mode: dev
repo_url: https://resource.fit2cloud.com/1panel/package repo_url: https://resource.fit2cloud.com/1panel/package
app_repo: https://apps-assets.fit2cloud.com
is_demo: false is_demo: false
port: 9999 port: 9999
username: admin username: admin

View file

@ -11,6 +11,7 @@ export namespace App {
author: string; author: string;
source: string; source: string;
type: string; type: string;
status: string;
} }
export interface AppDTO extends App { export interface AppDTO extends App {

View file

@ -84,6 +84,9 @@
{{ language == 'zh' ? tag.name : tag.key }} {{ language == 'zh' ? tag.name : tag.key }}
</span> </span>
</el-tag> </el-tag>
<el-tag v-if="app.status === 'TakeDown'" style="margin-right: 5px">
<span style="color: red">已废弃</span>
</el-tag>
</div> </div>
</div> </div>
</el-col> </el-col>

View file

@ -82,12 +82,8 @@
</el-row> </el-row>
</div> </div>
</div> </div>
<div v-loading="loadingDetail" style="margin-left: 10px"> <div style="margin-left: 10px">
<MdEditor <MdEditor v-model="app.readMe" previewOnly :theme="globalStore.$state.themeConfig.theme || 'light'" />
v-model="appDetail.readme"
previewOnly
:theme="globalStore.$state.themeConfig.theme || 'light'"
/>
</div> </div>
</template> </template>
</LayoutContent> </LayoutContent>