1
0
mirror of https://github.com/1Panel-dev/1Panel.git synced 2025-01-31 14:08:06 +08:00

style: 增加应用商店列表和同步功能

This commit is contained in:
zhengkunwang223 2022-09-22 16:16:04 +08:00 committed by zhengkunwang223
parent 12dc630c89
commit 367623293c
49 changed files with 16481 additions and 114 deletions

26
apps/list.json Normal file
View File

@ -0,0 +1,26 @@
{
"version": "0.1",
"tags": [
{
"key": "WebSite",
"name": "网站"
},
{
"key": "Datastore",
"name": "数据库"
}
],
"items": [
{
"key": "mysql",
"name": "Mysql",
"tags": ["Datastore"],
"versions": ["5.7.39","8.0.30"],
"short_desc": "常用关系型数据库",
"icon": "mysql.png",
"author": "Oracle",
"type": "internal",
"source": "https://www.mysql.com"
}
]
}

6
apps/mysql/5.7.39/.env Normal file
View File

@ -0,0 +1,6 @@
TZ=Asia/Shanghai
DATABASE=db
USER=mysql
PASSWORD=1qaz@WSX
ROOT_PASSWORD=1panel@mysql
PORT=3306

View File

@ -0,0 +1,20 @@
Copyright (c) 2000, 2022, Oracle and/or its affiliates.
This is a release of MySQL, an SQL database server.
License information can be found in the LICENSE file.
In test packages where this file is renamed README-test, the license
file is renamed LICENSE-test.
This distribution may include materials developed by third parties.
For license and attribution notices for these materials,
please refer to the LICENSE file.
For further information on MySQL or additional documentation, visit
http://dev.mysql.com/doc/
For additional downloads and the source of MySQL, visit
http://dev.mysql.com/downloads/
MySQL is brought to you by the MySQL team at Oracle.

View File

@ -0,0 +1,83 @@
[client]
port = 3306
socket = /var/run/mysqld/mysqld.sock
[mysqld_safe]
socket = /var/run/mysqld/mysqld.sock
nice = 0
[mysqld]
user = mysql
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
port = 3306
basedir = /usr
datadir = /var/lib/mysql
tmpdir = /tmp
lc-messages-dir = /usr/share/mysql
skip-external-locking
skip-character-set-client-handshake
default-storage-engine = InnoDB
character-set-server = utf8
transaction-isolation = READ-COMMITTED
bind-address = 127.0.0.1
key_buffer = 16M
max_allowed_packet = 16M
thread_stack = 192K
thread_cache_size = 16
myisam-recover = BACKUP
max_connections = 300
table_open_cache = 64
thread_concurrency = 10
table_open_cache = 32
thread_concurrency = 4
query_cache_type = 1
query_cache_limit = 1M
query_cache_size = 8M
general_log_file = /var/log/mysql/mysql.log
#general_log = 1
log_error = /var/log/mysql/error.log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
#log-queries-not-using-indexes
#server-id = 1
#log_bin = /var/log/mysql/mysql-bin.log
expire_logs_days = 14
max_binlog_size = 1G
#binlog_do_db = include_database_name
#binlog_ignore_db = include_database_name
# ssl-ca=/etc/mysql/cacert.pem
# ssl-cert=/etc/mysql/server-cert.pem
# ssl-key=/etc/mysql/server-key.pem
innodb_data_file_path = ibdata1:128M:autoextend
innodb_file_per_table = 1
skip-innodb_doublewrite
innodb_additional_mem_pool_size = 12M
innodb_buffer_pool_size = 256M
innodb_log_buffer_size = 8M
innodb_log_file_size = 8M
innodb_flush_log_at_trx_commit = 0
innodb_flush_method = O_DIRECT
innodb_support_xa = OFF
[mysqldump]
quick
quote-names
max_allowed_packet = 16M
[mysql]
#no-auto-rehash # faster start of mysql but no tab completition
[isamchk]
key_buffer = 16M

View File

@ -0,0 +1,23 @@
version: '3'
services:
mysql5.7:
image: mysql:5.7.39
container_name: 1panel-mysql
restart: always
environment:
TZ: ${TZ}
MYSQL_DATABASE: ${DATABASE}
MYSQL_USER: ${USER}
MYSQL_PASSWORD: ${PASSWORD}
MYSQL_ROOT_PASSWORD: ${ROOT_PASSWORD}
ports:
- ${PORT}:3306
volumes:
- ./data/:/var/lib/mysql
- ./conf/my.cnf:/etc/mysql/my.cnf
command:
--character-set-server=utf8mb4
--collation-server=utf8mb4_general_ci
--explicit_defaults_for_timestamp=true
--lower_case_table_names=1

View File

@ -0,0 +1,52 @@
{
"form_fields": [
{
"type": "text",
"label_zh": "时区",
"label_en": "TimeZone",
"required": true,
"default": "Asia/Shanghai",
"env_variable": "TZ"
},
{
"type": "text",
"label_zh": "数据库",
"label_en": "Database",
"required": true,
"default": "db",
"env_variable": "DATABASE"
},
{
"type": "text",
"label_zh": "普通用户",
"label_en": "User",
"required": true,
"default": "mysql",
"env_variable": "USER"
},
{
"type": "password",
"label_zh": "普通用户密码",
"label_en": "Password",
"required": true,
"default": "1qaz@WSX",
"env_variable": "PASSWORD"
},
{
"type": "password",
"label_zh": "Root用户密码",
"label_en": "RootPassword",
"required": true,
"default": "1panel@mysql",
"env_variable": "ROOT_PASSWORD"
},
{
"type": "number",
"label_zh": "端口",
"label_en": "Port",
"required": true,
"default": 3306,
"env_variable": "PORT"
}
]
}

View File

@ -2,6 +2,10 @@ system:
port: 9999
db_type: sqlite
level: debug
data_dir: /opt/1Panel/data
resource_dir: /opt/1Panel/data/resource
app_dir: /opt/1Panel/data/apps
app_oss:
mysql:
path: localhost

32
backend/app/api/v1/app.go Normal file
View File

@ -0,0 +1,32 @@
package v1
import (
"github.com/1Panel-dev/1Panel/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/app/dto"
"github.com/1Panel-dev/1Panel/constant"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) AppSearch(c *gin.Context) {
var req dto.AppRequest
if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return
}
list, err := appService.Page(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, list)
}
func (b *BaseApi) AppSync(c *gin.Context) {
if err := appService.Sync(); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, "")
}

View File

@ -18,4 +18,5 @@ var (
fileService = service.ServiceGroupApp.FileService
cronjobService = service.ServiceGroupApp.CronjobService
settingService = service.ServiceGroupApp.SettingService
appService = service.ServiceGroupApp.AppService
)

58
backend/app/dto/app.go Normal file
View File

@ -0,0 +1,58 @@
package dto
import "github.com/1Panel-dev/1Panel/app/model"
type AppRes struct {
Version string `json:"version"`
CanUpdate bool `json:"canUpdate"`
Items []*AppDTO `json:"items"`
Tags []model.Tag `json:"tags"`
Total int64 `json:"total"`
}
type AppDTO struct {
model.App
Tags []model.Tag `json:"tags"`
}
type AppList struct {
Version string `json:"version"`
Tags []Tag `json:"tags"`
Items []AppDefine `json:"items"`
}
type AppDefine struct {
Key string `json:"key"`
Name string `json:"name"`
Tags []string `json:"tags"`
Versions []string `json:"versions"`
Icon string `json:"icon"`
Author string `json:"author"`
Source string `json:"source"`
ShortDesc string `json:"short_desc"`
Type string `json:"type"`
}
type Tag struct {
Key string `json:"key"`
Name string `json:"name"`
}
type AppForm struct {
FormFields []AppFormFields `json:"form_fields"`
}
type AppFormFields struct {
Type string `json:"type"`
LabelZh string `json:"label_zh"`
LabelEn string `json:"label_en"`
Required string `json:"required"`
Default string `json:"default"`
EnvKey string `json:"env_variable"`
}
type AppRequest struct {
PageInfo
Name string `json:"name"`
Types []string `json:"types"`
}

View File

@ -92,7 +92,7 @@ type FileProcess struct {
}
type FileProcessReq struct {
Key string
Key string `json:"key"`
}
type FileProcessKeys struct {

15
backend/app/model/app.go Normal file
View File

@ -0,0 +1,15 @@
package model
type App struct {
BaseModel
Name string `json:"name" gorm:"type:varchar(64);not null"`
Key string `json:"key" gorm:"type:varchar(64);not null;uniqueIndex"`
ShortDesc string `json:"shortDesc" gorm:"type:longtext;"`
Icon string `json:"icon" gorm:"type:longtext;"`
Author string `json:"author" gorm:"type:varchar(64);not null"`
Source string `json:"source" gorm:"type:varchar(64);not null"`
Type string `json:"type" gorm:"type:varchar(64);not null" `
Details []*AppDetail `json:"-"`
TagsKey []string `json:"-" gorm:"-"`
AppTags []AppTag `json:"-"`
}

View File

@ -0,0 +1,8 @@
package model
type AppConfig struct {
BaseModel
Version string `json:"version"`
OssPath string `json:"ossPath"`
CanUpdate bool `json:"canUpdate"`
}

View File

@ -0,0 +1,10 @@
package model
type AppDetail struct {
BaseModel
AppId uint `json:"appId" gorm:"type:integer;not null"`
Version string `json:"version" gorm:"type:varchar(64);not null"`
FormFields string `json:"formFields" gorm:"type:longtext;"`
DockerCompose string `json:"dockerCompose" gorm:"type:longtext;not null"`
Readme string `json:"readme" gorm:"type:longtext;not null"`
}

View File

@ -0,0 +1,7 @@
package model
type AppTag struct {
BaseModel
AppId uint `json:"appId" gorm:"type:integer;not null"`
TagId uint `json:"tagId" gorm:"type:integer;not null"`
}

7
backend/app/model/tag.go Normal file
View File

@ -0,0 +1,7 @@
package model
type Tag struct {
BaseModel
Key string `json:"key" gorm:"type:varchar(64);not null"`
Name string `json:"name" gorm:"type:varchar(64);not null"`
}

61
backend/app/repo/app.go Normal file
View File

@ -0,0 +1,61 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/app/model"
"github.com/1Panel-dev/1Panel/global"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AppRepo struct {
}
func (a AppRepo) WithInTypes(types []string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("type in (?)", types)
}
}
func (a AppRepo) Page(page, size int, opts ...DBOption) (int64, []model.App, error) {
var apps []model.App
db := global.DB.Model(&model.App{})
for _, opt := range opts {
db = opt(db)
}
count := int64(0)
db = db.Count(&count)
err := db.Debug().Limit(size).Offset(size * (page - 1)).Preload("AppTags").Find(&apps).Error
return count, apps, err
}
func (a AppRepo) BatchCreate(ctx context.Context, apps []*model.App) error {
db := ctx.Value("db").(*gorm.DB)
return db.Omit(clause.Associations).Create(apps).Error
}
func (a AppRepo) GetByKey(ctx context.Context, key string) (model.App, error) {
db := ctx.Value("db").(*gorm.DB)
var app model.App
if err := db.Where("key = ?", key).First(&app).Error; err != nil {
return app, err
}
return app, nil
}
func (a AppRepo) Create(ctx context.Context, app *model.App) error {
db := ctx.Value("db").(*gorm.DB)
return db.Omit(clause.Associations).Create(app).Error
}
func (a AppRepo) Save(ctx context.Context, app *model.App) error {
db := ctx.Value("db").(*gorm.DB)
return db.Omit(clause.Associations).Save(app).Error
}
func (a AppRepo) UpdateAppConfig(ctx context.Context, app *model.AppConfig) error {
db := ctx.Value("db").(*gorm.DB)
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
}).Create(app).Error
}

View File

@ -0,0 +1,29 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/app/model"
"gorm.io/gorm"
)
type AppDetailRepo struct {
}
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []*model.AppDetail) error {
db := ctx.Value("db").(*gorm.DB)
return db.Model(&model.AppDetail{}).Create(&details).Error
}
func (a AppDetailRepo) DeleteByAppIds(ctx context.Context, appIds []uint) error {
db := ctx.Value("db").(*gorm.DB)
return db.Where("app_id in (?)", appIds).Delete(&model.AppDetail{}).Error
}
func (a AppDetailRepo) GetByAppId(ctx context.Context, appId string) ([]model.AppDetail, error) {
db := ctx.Value("db").(*gorm.DB)
var details []model.AppDetail
if err := db.Where("app_id = ?", appId).Find(&details).Error; err != nil {
return nil, err
}
return details, nil
}

View File

@ -0,0 +1,29 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/app/model"
"github.com/1Panel-dev/1Panel/global"
"gorm.io/gorm"
)
type AppTagRepo struct {
}
func (a AppTagRepo) BatchCreate(ctx context.Context, tags []*model.AppTag) error {
db := ctx.Value("db").(*gorm.DB)
return db.Create(&tags).Error
}
func (a AppTagRepo) DeleteByAppIds(ctx context.Context, appIds []uint) error {
db := ctx.Value("db").(*gorm.DB)
return db.Where("app_id in (?)", appIds).Delete(&model.AppTag{}).Error
}
func (a AppTagRepo) GetByAppId(appId uint) ([]model.AppTag, error) {
var appTags []model.AppTag
if err := global.DB.Where("app_id = ?", appId).Find(&appTags).Error; err != nil {
return nil, err
}
return appTags, nil
}

View File

@ -9,6 +9,10 @@ type RepoGroup struct {
CommonRepo
CronjobRepo
SettingRepo
AppRepo
AppTagRepo
TagRepo
AppDetailRepo
}
var RepoGroupApp = new(RepoGroup)

37
backend/app/repo/tag.go Normal file
View File

@ -0,0 +1,37 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/app/model"
"github.com/1Panel-dev/1Panel/global"
"gorm.io/gorm"
)
type TagRepo struct {
}
func (t TagRepo) BatchCreate(ctx context.Context, tags []*model.Tag) error {
db := ctx.Value("db").(*gorm.DB)
return db.Create(&tags).Error
}
func (t TagRepo) DeleteAll(ctx context.Context) error {
db := ctx.Value("db").(*gorm.DB)
return db.Where("1 = 1 ").Delete(&model.Tag{}).Error
}
func (t TagRepo) All() ([]model.Tag, error) {
var tags []model.Tag
if err := global.DB.Where("1 = 1 ").Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}
func (t TagRepo) GetByIds(ids []uint) ([]model.Tag, error) {
var tags []model.Tag
if err := global.DB.Where("id in (?)", ids).Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}

238
backend/app/service/app.go Normal file
View File

@ -0,0 +1,238 @@
package service
import (
"encoding/base64"
"encoding/json"
"github.com/1Panel-dev/1Panel/app/dto"
"github.com/1Panel-dev/1Panel/app/model"
"github.com/1Panel-dev/1Panel/app/repo"
"github.com/1Panel-dev/1Panel/global"
"golang.org/x/net/context"
"os"
"path"
"reflect"
)
type AppService struct {
}
func (a AppService) Page(req dto.AppRequest) (interface{}, error) {
var opts []repo.DBOption
opts = append(opts, commonRepo.WithOrderBy("name"))
if req.Name != "" {
opts = append(opts, commonRepo.WithLikeName(req.Name))
}
if len(req.Types) != 0 {
opts = append(opts, appRepo.WithInTypes(req.Types))
}
var res dto.AppRes
total, apps, err := appRepo.Page(req.Page, req.PageSize, opts...)
if err != nil {
return nil, err
}
var appDTOs []*dto.AppDTO
for _, a := range apps {
appDTO := &dto.AppDTO{
App: a,
}
appDTOs = append(appDTOs, appDTO)
appTags, err := appTagRepo.GetByAppId(a.ID)
if err != nil {
continue
}
var tagIds []uint
for _, at := range appTags {
tagIds = append(tagIds, at.TagId)
}
tags, err := tagRepo.GetByIds(tagIds)
if err != nil {
continue
}
appDTO.Tags = tags
}
res.Items = appDTOs
res.Total = total
tags, err := tagRepo.All()
if err != nil {
return nil, err
}
res.Tags = tags
return res, nil
}
func (a AppService) Sync() error {
//TODO 从 oss 拉取最新列表
var appConfig model.AppConfig
appConfig.OssPath = global.CONF.System.AppOss
appDir := path.Join(global.CONF.System.ResourceDir, "apps")
iconDir := path.Join(appDir, "icons")
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
}
appConfig.Version = list.Version
appConfig.CanUpdate = false
var (
tags []*model.Tag
addApps []*model.App
updateApps []*model.App
appTags []*model.AppTag
)
for _, t := range list.Tags {
tags = append(tags, &model.Tag{
Key: t.Key,
Name: t.Name,
})
}
db := global.DB
dbCtx := context.WithValue(context.Background(), "db", db)
for _, l := range list.Items {
icon, err := os.ReadFile(path.Join(iconDir, l.Icon))
if err != nil {
global.LOG.Errorf("get [%s] icon error: %s", l.Name, err.Error())
continue
}
iconStr := base64.StdEncoding.EncodeToString(icon)
app := &model.App{
Name: l.Name,
Key: l.Key,
ShortDesc: l.ShortDesc,
Author: l.Author,
Source: l.Source,
Icon: iconStr,
Type: l.Type,
}
app.TagsKey = l.Tags
old, _ := appRepo.GetByKey(dbCtx, l.Key)
if reflect.DeepEqual(old, &model.App{}) {
addApps = append(addApps, app)
} else {
app.ID = old.ID
updateApps = append(updateApps, app)
}
versions := l.Versions
for _, v := range versions {
detail := &model.AppDetail{
Version: v,
}
detailPath := path.Join(appDir, l.Key, v)
if _, err := os.Stat(detailPath); err != nil {
global.LOG.Errorf("get [%s] folder error: %s", detailPath, err.Error())
continue
}
readmeStr, err := os.ReadFile(path.Join(detailPath, "README.md"))
if err != nil {
global.LOG.Errorf("get [%s] README error: %s", detailPath, err.Error())
}
detail.Readme = string(readmeStr)
dockerComposeStr, err := os.ReadFile(path.Join(detailPath, "docker-compose.yml"))
if err != nil {
global.LOG.Errorf("get [%s] docker-compose.yml error: %s", detailPath, err.Error())
continue
}
detail.DockerCompose = string(dockerComposeStr)
formStr, err := os.ReadFile(path.Join(detailPath, "form.json"))
if err != nil {
global.LOG.Errorf("get [%s] form.json error: %s", detailPath, err.Error())
}
detail.FormFields = string(formStr)
app.Details = append(app.Details, detail)
}
}
tx := global.DB.Begin()
ctx := context.WithValue(context.Background(), "db", tx)
if len(addApps) > 0 {
if err := appRepo.BatchCreate(ctx, addApps); err != nil {
tx.Rollback()
return err
}
}
if err := tagRepo.DeleteAll(ctx); err != nil {
tx.Rollback()
return err
}
if len(tags) > 0 {
if err := tagRepo.BatchCreate(ctx, tags); err != nil {
tx.Rollback()
return err
}
}
tagMap := make(map[string]uint, len(tags))
for _, t := range tags {
tagMap[t.Key] = t.ID
}
for _, a := range updateApps {
if err := appRepo.Save(ctx, a); err != nil {
tx.Rollback()
return err
}
}
apps := append(addApps, updateApps...)
var (
appDetails []*model.AppDetail
appIds []uint
)
for _, a := range apps {
for _, t := range a.TagsKey {
tagId, ok := tagMap[t]
if ok {
appTags = append(appTags, &model.AppTag{
AppId: a.ID,
TagId: tagId,
})
}
}
for _, d := range a.Details {
d.AppId = a.ID
appDetails = append(appDetails, d)
}
appIds = append(appIds, a.ID)
}
if err := appDetailRepo.DeleteByAppIds(ctx, appIds); err != nil {
tx.Rollback()
return err
}
if len(appDetails) > 0 {
if err := appDetailRepo.BatchCreate(ctx, appDetails); err != nil {
tx.Rollback()
return err
}
}
if err := appTagRepo.DeleteByAppIds(ctx, appIds); err != nil {
tx.Rollback()
return err
}
if len(appTags) > 0 {
if err := appTagRepo.BatchCreate(ctx, appTags); err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
return nil
}

View File

@ -12,6 +12,7 @@ type ServiceGroup struct {
FileService
CronjobService
SettingService
AppService
}
var ServiceGroupApp = new(ServiceGroup)
@ -25,4 +26,8 @@ var (
commonRepo = repo.RepoGroupApp.CommonRepo
cronjobRepo = repo.RepoGroupApp.CronjobRepo
settingRepo = repo.RepoGroupApp.SettingRepo
appRepo = repo.RepoGroupApp.AppRepo
appTagRepo = repo.RepoGroupApp.AppTagRepo
appDetailRepo = repo.RepoGroupApp.AppDetailRepo
tagRepo = repo.RepoGroupApp.TagRepo
)

View File

@ -1,7 +1,11 @@
package configs
type System struct {
Port int `mapstructure:"port"`
DbType string `mapstructure:"db_type"`
Level string `mapstructure:"level"`
Port int `mapstructure:"port"`
DbType string `mapstructure:"db_type"`
Level string `mapstructure:"level"`
DataDir string `mapstructure:"data_dir"`
ResourceDir string `mapstructure:"resource_dir"`
AppDir string `mapstructure:"app_dir"`
AppOss string `mapstructure:"app_oss"`
}

View File

@ -15,6 +15,7 @@ func Init() {
migrations.AddTableSetting,
migrations.AddTableBackupAccount,
migrations.AddTableCronjob,
migrations.AddTableApp,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@ -146,3 +146,10 @@ var AddTableCronjob = &gormigrate.Migration{
return tx.AutoMigrate(&model.Cronjob{}, &model.JobRecords{})
},
}
var AddTableApp = &gormigrate.Migration{
ID: "20200921-add-table-app",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.Tag{}, &model.AppTag{}, &model.AppConfig{})
},
}

View File

@ -54,6 +54,7 @@ func Routers() *gin.Engine {
systemRouter.InitFileRouter(PrivateGroup)
systemRouter.InitCronjobRouter(PrivateGroup)
systemRouter.InitSettingRouter(PrivateGroup)
systemRouter.InitAppRouter(PrivateGroup)
}
return Router

View File

@ -12,6 +12,7 @@ type RouterGroup struct {
TerminalRouter
CronjobRouter
SettingRouter
AppRouter
}
var RouterGroupApp = new(RouterGroup)

21
backend/router/ro_app.go Normal file
View File

@ -0,0 +1,21 @@
package router
import (
v1 "github.com/1Panel-dev/1Panel/app/api/v1"
"github.com/1Panel-dev/1Panel/middleware"
"github.com/gin-gonic/gin"
)
type AppRouter struct {
}
func (a *AppRouter) InitAppRouter(Router *gin.RouterGroup) {
appRouter := Router.Group("apps")
appRouter.Use(middleware.JwtAuth()).Use(middleware.SessionAuth())
baseApi := v1.ApiGroupApp.BaseApi
{
appRouter.POST("/sync", baseApi.AppSync)
appRouter.POST("/search", baseApi.AppSearch)
}
}

View File

@ -0,0 +1,30 @@
package compose
import "os/exec"
func Up(filePath string) (string, error) {
cmd := exec.Command("docker-compose", "-f", filePath, "up", "-d")
stdout, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(stdout), nil
}
func Down(filePath string) (string, error) {
cmd := exec.Command("docker-compose", "-f", filePath, "down")
stdout, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(stdout), nil
}
func Restart(filePath string) (string, error) {
cmd := exec.Command("docker-compose", "-f", filePath, "restart")
stdout, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(stdout), nil
}

15329
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,13 +21,14 @@
},
"dependencies": {
"@element-plus/icons-vue": "^1.1.4",
"@kangc/v-md-editor": "^2.3.15",
"@vueuse/core": "^8.0.1",
"axios": "^0.27.2",
"echarts": "^5.3.0",
"echarts-liquidfill": "^3.1.0",
"js-base64": "^3.7.2",
"element-plus": "^2.2.13",
"fit2cloud-ui-plus": "^0.0.1-beta.15",
"js-base64": "^3.7.2",
"js-md5": "^0.7.3",
"monaco-editor": "^0.34.0",
"nprogress": "^0.2.0",

View File

@ -0,0 +1,42 @@
import { ReqPage } from '.';
export namespace App {
export interface App {
name: string;
icon: string;
key: string;
tags: Tag[];
shortDesc: string;
author: string;
source: string;
type: string;
}
export interface Tag {
key: string;
name: string;
}
export interface AppResPage {
total: number;
canUpdate: boolean;
version: string;
items: App.App[];
tags: App.Tag[];
}
export interface AppDetail {
name: string;
icon: string;
description: string;
sourceLink: string;
versions: string[];
readme: string;
athor: string;
}
export interface AppReq extends ReqPage {
name: string;
types: string[];
}
}

View File

@ -0,0 +1,11 @@
// export const GetAppList = ()
import http from '@/api';
import { App } from '../interface/app';
export const SyncApp = () => {
return http.post<any>('apps/sync', {});
};
export const SearchApp = (req: App.AppReq) => {
return http.post<App.AppResPage>('apps/search', req);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -396,4 +396,9 @@ export default {
star: 'Star',
description: 'A modern Linux panel tool',
},
app: {
installed: 'Installed',
all: 'All',
version: 'Version',
},
};

View File

@ -388,4 +388,9 @@ export default {
star: '点亮 Star',
description: '一个现代化的 Linux 面板工具',
},
app: {
installed: '已安装',
all: '全部',
version: '版本',
},
};

View File

@ -17,6 +17,16 @@ import router from '@/routers/index';
import I18n from '@/lang/index';
import pinia from '@/store/index';
import SvgIcon from './components/svg-icon/svg-icon.vue';
import VMdPreview from '@kangc/v-md-editor/lib/preview';
import '@kangc/v-md-editor/lib/style/preview.css';
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js';
import '@kangc/v-md-editor/lib/theme/style/github.css';
import hljs from 'highlight.js';
VMdPreview.use(githubTheme, {
hljs,
});
const app = createApp(App);
app.component('SvgIcon', SvgIcon);
app.use(ElementPlus);
@ -29,4 +39,5 @@ app.use(router);
app.use(I18n);
app.use(pinia);
app.use(directives);
app.use(VMdPreview);
app.mount('#app');

View File

@ -16,6 +16,25 @@ const appStoreRouter = {
component: () => import('@/views/app-store/index.vue'),
meta: {},
},
// {
// path: '/apps/detail/:name',
// name: 'AppDetail',
// component: () => import('@/views/app-store/detail/index.vue'),
// meta: {
// hidden: true,
// title: 'menu.apps',
// },
// },
{
path: '/apps/detail/:name',
name: 'AppDetail',
props: true,
hidden: true,
component: () => import('@/views/app-store/detail/index.vue'),
meta: {
activeMenu: '/apps',
},
},
],
};

2
frontend/src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module '@kangc/v-md-editor/lib/preview';
declare module '@kangc/v-md-editor/lib/theme/github.js';

View File

@ -0,0 +1,3 @@
export function getAssetsFile(url: string) {
return new URL(`../assets/apps/${url}`, import.meta.url).href;
}

View File

@ -0,0 +1,29 @@
{
"data": [
{
"name": "Mysql",
"icon": "mysql.png",
"description": "常用的关系型数据库",
"tags": ["数据库"]
},
{
"name": "Redis",
"icon": "redis.png",
"description": "缓存数据库",
"tags": ["数据库"]
},
{
"name": "Wordpress",
"icon": "wordpress.png",
"description": "老牌博客平台",
"tags": ["网站"]
},
{
"name": "Halo",
"icon": "halo.png",
"description": "现代化的博客平台",
"tags": ["网站"]
}
],
"tags": ["数据库", "网站", "测试", "开发"]
}

View File

@ -0,0 +1,10 @@
{
"name": "Halo",
"description": "更好用的博客模版",
"versions": ["0.0.1", "0.0.2"],
"sourceLink": "",
"athor": "halo",
"status": "",
"readme": "",
"icon": "halo.png"
}

View File

@ -0,0 +1,148 @@
<template>
<LayoutContent>
<div class="brief">
<el-row :gutter="20">
<el-col :span="4">
<div class="icon">
<el-image class="image" :src="getImageUrl(appDetail.icon)"></el-image>
</div>
</el-col>
<el-col :span="20">
<div class="a-detail">
<div class="a-name">
<font size="5" style="font-weight: 800">{{ appDetail.name }}</font>
</div>
<div class="a-description">
<span>
<font>
{{ appDetail.description }}
</font>
</span>
</div>
<el-descriptions :column="1">
<el-descriptions-item :label="$t('app.version')">
<el-select v-model="appSelect.version">
<el-option
v-for="(v, index) in appDetail.versions"
:key="index"
:value="v"
:label="v"
>
{{ v }}
</el-option>
</el-select>
</el-descriptions-item>
<el-descriptions-item :label="'链接'">
<el-link>source</el-link>
</el-descriptions-item>
<el-descriptions-item :label="'来源'">FIT2CLOUD</el-descriptions-item>
</el-descriptions>
<div>
<el-button type="primary">安装</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<el-divider border-style="double" />
<div class="detail">
<v-md-preview :text="readme"></v-md-preview>
</div>
</LayoutContent>
</template>
<script lang="ts" setup>
import LayoutContent from '@/layout/layout-content.vue';
import { getAssetsFile } from '@/utils/image';
import { reactive, ref } from 'vue';
import detail from './detail.json';
let appDetail = ref<any>();
appDetail.value = detail;
let appSelect = reactive({
version: '',
});
appSelect.version = appDetail.value.versions[0];
let readme = ref<string>(`<p align="center">
<a href="https://halo.run" target="_blank" rel="noopener noreferrer">
<img width="100" src="https://halo.run/logo" alt="Halo logo" />
</a>
</p>
<p align="center"><b>Halo</b> [ˈheɪloʊ]一款现代化的开源博客/CMS系统值得一试</p>
<p align="center">
<a href="https://github.com/halo-dev/halo/releases"><img alt="GitHub release" src="https://img.shields.io/github/release/halo-dev/halo.svg?style=flat-square" /></a>
<a href="https://github.com/halo-dev/halo/releases"><img alt="GitHub All Releases" src="https://img.shields.io/github/downloads/halo-dev/halo/total.svg?style=flat-square" /></a>
<a href="https://hub.docker.com/r/halohub/halo"><img alt="Docker pulls" src="https://img.shields.io/docker/pulls/halohub/halo?style=flat-square" /></a>
<a href="https://github.com/halo-dev/halo/commits"><img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/halo-dev/halo.svg?style=flat-square" /></a>
<a href="https://github.com/halo-dev/halo/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/workflow/status/halo-dev/halo/Halo%20CI?style=flat-square" /></a>
<br />
<a href="https://halo.run">官网</a>
<a href="https://docs.halo.run">文档</a>
<a href="https://bbs.halo.run">社区</a>
<a href="https://gitee.com/halo-dev">Gitee</a>
<a href="https://t.me/halo_dev">Telegram 频道</a>
</p>
---
## 快速开始
详细部署文档请查阅<https://docs.halo.run>
## 在线体验
- 环境地址<https://demo.halo.run>
- 后台地址<https://demo.halo.run/admin>
- 用户名demo
- 密码P@ssw0rd123..
- 使用前请阅读<https://demo.halo.run/archives/tips>
## 生态
| 项目 | 状态 | 描述 |
| --- | --- | --- |
| [halo-admin](https://github.com/halo-dev/halo-admin) | <a href="https://github.com/halo-dev/halo-admin/releases"><img alt="GitHub release" src="https://img.shields.io/github/release/halo-dev/halo-admin.svg?style=flat-square" /></a> | Web UI |
| [js-sdk](https://github.com/halo-dev/js-sdk) | <a href="https://github.com/halo-dev/js-sdk"><img alt="npm release" src="https://img.shields.io/npm/v/@halo-dev/content-api?style=flat-square"/></a> | JavaScript SDK |
| [halo-comment](https://github.com/halo-dev/halo-comment) | <a href="https://www.npmjs.com/package/halo-comment"><img alt="npm release" src="https://img.shields.io/npm/v/halo-comment?style=flat-square"/></a> | 便 |
| [halo-comment-normal](https://github.com/halo-dev/halo-comment-normal) | <a href="https://www.npmjs.com/package/halo-comment-normal"><img alt="npm release" src="https://img.shields.io/npm/v/halo-comment-normal?style=flat-square"/></a> | |
| [halo-mobile-app](https://github.com/halo-dev/halo-mobile-app) | | APP |
| [tencent-cloudbase-halo](https://github.com/halo-dev/tencent-cloudbase-halo) | | CloudBase |
| [halo-theme-\*](https://github.com/topics/halo-theme) | | GitHub Halo |
## 许可证
[![license](https://img.shields.io/github/license/halo-dev/halo.svg?style=flat-square)](https://github.com/halo-dev/halo/blob/master/LICENSE)
Halo 使用 GPL-v3.0 协议开源请遵守开源协议
## 贡献
参考 [CONTRIBUTING](https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md)
<a href="https://github.com/halo-dev/halo/graphs/contributors"><img src="https://opencollective.com/halo/contributors.svg?width=890&button=false" /></a>
## 状态
![Repobeats analytics](https://repobeats.axiom.co/api/embed/ad008b2151c22e7cf734d2688befaa795d593b95.svg 'Repobeats analytics image')
`);
const getImageUrl = (name: string) => {
return getAssetsFile(name);
};
</script>
<style lang="scss">
.brief {
height: 30vh;
.icon {
.image {
width: auto;
height: 20vh;
}
}
}
</style>

View File

@ -1,7 +1,155 @@
<template>
<LayoutContent></LayoutContent>
<LayoutContent>
<el-row :gutter="20">
<!-- <el-col :span="24">
<div class="header">
<el-radio-group v-model="activeName">
<el-radio-button label="all">
{{ $t('app.all') }}
</el-radio-button>
<el-radio-button label="installed">
{{ $t('app.installed') }}
</el-radio-button>
</el-radio-group>
</div>
</el-col> -->
<el-col :span="12">
<el-input></el-input>
</el-col>
<el-col :span="12">
<el-select v-model="selectTags" multiple style="width: 100%">
<el-option v-for="item in tags" :key="item.key" :label="item.name" :value="item.key"></el-option>
</el-select>
</el-col>
<el-button @click="sync">同步</el-button>
</el-row>
<el-row :gutter="20">
<el-col v-for="(app, index) in apps" :key="index" :xs="8" :sm="8" :lg="4">
<div @click="getAppDetail(app.name)">
<el-card :body-style="{ padding: '0px' }" class="a-card">
<el-row :gutter="24">
<el-col :span="8">
<div class="icon">
<el-image class="image" :src="'data:image/png;base64,' + app.icon"></el-image>
</div>
</el-col>
<el-col :span="16">
<div class="a-detail">
<div class="d-name">
<font size="3" style="font-weight: 700">{{ app.name }}</font>
</div>
<div class="d-description">
<font size="1">
<span>
{{ app.shortDesc }}
</span>
</font>
</div>
<div class="d-tag">
<el-tag v-for="(tag, ind) in app.tags" :key="ind" round :colr="getColor(ind)">
{{ tag.name }}
</el-tag>
</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
</el-col>
</el-row>
</LayoutContent>
</template>
<script lang="ts" setup>
import { App } from '@/api/interface/app';
import LayoutContent from '@/layout/layout-content.vue';
import { onMounted, ref } from 'vue';
import router from '@/routers';
import { SyncApp } from '@/api/modules/app';
import { SearchApp } from '@/api/modules/app';
let req = ref<App.AppReq>({
name: '',
types: [],
page: 1,
pageSize: 50,
});
let apps = ref<App.App[]>([]);
let tags = ref<App.Tag[]>([]);
let selectTags = ref<string[]>([]);
const colorArr = ['#6495ED', '#54FF9F', '#BEBEBE', '#FFF68F', '#FFFF00', '#8B0000'];
const getColor = (index: number) => {
return colorArr[index];
};
const sync = () => {
SyncApp().then((res) => {
console.log(res);
});
};
const search = async (req: App.AppReq) => {
await SearchApp(req).then((res) => {
apps.value = res.data.items;
tags.value = res.data.tags;
});
};
const getAppDetail = (name: string) => {
let params: { [key: string]: any } = {
name: name,
};
router.push({ name: 'AppDetail', params });
};
onMounted(() => {
search(req.value);
});
</script>
<style lang="scss">
.header {
padding-bottom: 10px;
}
.a-card {
height: 100px;
margin-top: 10px;
cursor: pointer;
padding: 1px;
.icon {
width: 100%;
height: 80%;
padding: 10%;
margin-top: 5px;
.image {
width: auto;
height: auto;
}
}
.a-detail {
margin-top: 10px;
height: 100%;
width: 100%;
.d-name {
height: 20%;
}
.d-description {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
.a-card:hover {
transform: scale(1.1);
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB