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

feat: Mysql 数据库删除增加强制删除、删除备份选项

This commit is contained in:
ssongliu 2022-12-26 14:47:08 +08:00 committed by ssongliu
parent 9ce02b14c8
commit cfeb158d0a
25 changed files with 324 additions and 202 deletions

View File

@ -217,7 +217,7 @@ func (b *BaseApi) DeleteCheckMysql(c *gin.Context) {
} }
func (b *BaseApi) DeleteMysql(c *gin.Context) { func (b *BaseApi) DeleteMysql(c *gin.Context) {
var req dto.OperateByID var req dto.MysqlDBDelete
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err) helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
return return
@ -227,7 +227,7 @@ func (b *BaseApi) DeleteMysql(c *gin.Context) {
return return
} }
if err := mysqlService.Delete(req.ID); err != nil { if err := mysqlService.Delete(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return return
} }

View File

@ -28,6 +28,12 @@ type MysqlDBCreate struct {
Description string `json:"description"` Description string `json:"description"`
} }
type MysqlDBDelete struct {
ID uint `json:"id" validate:"required"`
ForceDelete bool `json:"forceDelete"`
DeleteBackup bool `json:"deleteBackup"`
}
type MysqlStatus struct { type MysqlStatus struct {
AbortedClients string `json:"Aborted_clients"` AbortedClients string `json:"Aborted_clients"`
AbortedConnects string `json:"Aborted_connects"` AbortedConnects string `json:"Aborted_connects"`

View File

@ -44,7 +44,7 @@ type IMysqlService interface {
Recover(db dto.RecoverDB) error Recover(db dto.RecoverDB) error
DeleteCheck(id uint) ([]string, error) DeleteCheck(id uint) ([]string, error)
Delete(id uint) error Delete(req dto.MysqlDBDelete) error
LoadStatus() (*dto.MysqlStatus, error) LoadStatus() (*dto.MysqlStatus, error)
LoadVariables() (*dto.MysqlVariables, error) LoadVariables() (*dto.MysqlVariables, error)
LoadBaseInfo() (*dto.DBBaseInfo, error) LoadBaseInfo() (*dto.DBBaseInfo, error)
@ -256,36 +256,37 @@ func (u *MysqlService) DeleteCheck(id uint) ([]string, error) {
return appInUsed, nil return appInUsed, nil
} }
func (u *MysqlService) Delete(id uint) error { func (u *MysqlService) Delete(req dto.MysqlDBDelete) error {
app, err := appInstallRepo.LoadBaseInfo("mysql", "") app, err := appInstallRepo.LoadBaseInfo("mysql", "")
if err != nil { if err != nil && !req.ForceDelete {
return err return err
} }
db, err := mysqlRepo.Get(commonRepo.WithByID(id)) db, err := mysqlRepo.Get(commonRepo.WithByID(req.ID))
if err != nil { if err != nil && !req.ForceDelete {
return err return err
} }
if err := excuteSql(app.ContainerName, app.Password, fmt.Sprintf("drop user if exists '%s'@'%s'", db.Name, db.Permission)); err != nil { if err := excuteSql(app.ContainerName, app.Password, fmt.Sprintf("drop user if exists '%s'@'%s'", db.Name, db.Permission)); err != nil && !req.ForceDelete {
return err return err
} }
if err := excuteSql(app.ContainerName, app.Password, fmt.Sprintf("drop database if exists `%s`", db.Name)); err != nil { if err := excuteSql(app.ContainerName, app.Password, fmt.Sprintf("drop database if exists `%s`", db.Name)); err != nil && !req.ForceDelete {
return err return err
} }
uploadDir := fmt.Sprintf("%s/uploads/%s/mysql/%s", constant.DefaultDataDir, app.Name, db.Name) uploadDir := fmt.Sprintf("%s/uploads/database/mysql/%s/%s", constant.DefaultDataDir, app.Name, db.Name)
if _, err := os.Stat(uploadDir); err == nil { if _, err := os.Stat(uploadDir); err == nil {
_ = os.RemoveAll(uploadDir) _ = os.RemoveAll(uploadDir)
} }
if req.DeleteBackup {
localDir, err := loadLocalDir() localDir, err := loadLocalDir()
if err != nil { if err != nil && !req.ForceDelete {
return err return err
} }
backupDir := fmt.Sprintf("%s/database/mysql/%s/%s", localDir, db.MysqlName, db.Name) backupDir := fmt.Sprintf("%s/database/mysql/%s/%s", localDir, db.MysqlName, db.Name)
if _, err := os.Stat(backupDir); err == nil { if _, err := os.Stat(backupDir); err == nil {
_ = os.RemoveAll(backupDir) _ = os.RemoveAll(backupDir)
}
} }
_ = backupRepo.DeleteRecord(context.Background(), commonRepo.WithByType("database-mysql"), commonRepo.WithByName(app.Name), backupRepo.WithByDetailName(db.Name)) _ = backupRepo.DeleteRecord(context.Background(), commonRepo.WithByType("database-mysql"), commonRepo.WithByName(app.Name), backupRepo.WithByDetailName(db.Name))

View File

@ -68,18 +68,16 @@ func OperationLog() gin.HandlerFunc {
if len(operationDic.BeforeFuntions) != 0 { if len(operationDic.BeforeFuntions) != 0 {
for _, funcs := range operationDic.BeforeFuntions { for _, funcs := range operationDic.BeforeFuntions {
for key, value := range formatMap { for key, value := range formatMap {
if funcs.Info == key { if funcs.InputValue == key {
var names []string var names []string
if funcs.IsList { if funcs.IsList {
if key == "ids" { sql := fmt.Sprintf("SELECT %s FROM %s where %s in (?);", funcs.OutputColume, funcs.DB, funcs.InputColume)
sql := fmt.Sprintf("SELECT %s FROM %s where id in (?);", funcs.Key, funcs.DB) fmt.Println(value)
fmt.Println(value) _ = global.DB.Raw(sql, value).Scan(&names)
_ = global.DB.Raw(sql, value).Scan(&names)
}
} else { } else {
_ = global.DB.Raw(fmt.Sprintf("select %s from %s where %s = ?;", funcs.Key, funcs.DB, key), value).Scan(&names) _ = global.DB.Raw(fmt.Sprintf("select %s from %s where %s = ?;", funcs.OutputColume, funcs.DB, funcs.InputColume), value).Scan(&names)
} }
formatMap[funcs.Value] = strings.Join(names, ",") formatMap[funcs.OutputValue] = strings.Join(names, ",")
break break
} }
} }
@ -136,11 +134,12 @@ type operationJson struct {
FormatEN string `json:"formatEN"` FormatEN string `json:"formatEN"`
} }
type functionInfo struct { type functionInfo struct {
Info string `json:"info"` InputColume string `json:"input_colume"`
IsList bool `json:"isList"` InputValue string `json:"input_value"`
DB string `json:"db"` IsList bool `json:"isList"`
Key string `json:"key"` DB string `json:"db"`
Value string `json:"value"` OutputColume string `json:"output_colume"`
OutputValue string `json:"output_value"`
} }
type response struct { type response struct {

View File

@ -44,11 +44,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_column": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "image_repos", "db": "image_repos",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "更新镜像仓库 [name]", "formatZH": "更新镜像仓库 [name]",
@ -63,11 +64,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "image_repos", "db": "image_repos",
"key": "name", "output_colume": "name",
"value": "names" "output_value": "names"
} }
], ],
"formatZH": "删除镜像仓库 [names]", "formatZH": "删除镜像仓库 [names]",
@ -116,11 +118,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "compose_templates", "db": "compose_templates",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "更新 compose 模版 [name]", "formatZH": "更新 compose 模版 [name]",
@ -146,11 +149,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "compose_templates", "db": "compose_templates",
"key": "name", "output_colume": "name",
"value": "names" "output_value": "names"
} }
], ],
"formatZH": "删除 compose 模版 [names]", "formatZH": "删除 compose 模版 [names]",
@ -166,11 +170,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "repoID", "input_colume": "id",
"input_value": "repoID",
"isList": false, "isList": false,
"db": "image_repos", "db": "image_repos",
"key": "name", "output_colume": "name",
"value": "reponame" "output_value": "reponame"
} }
], ],
"formatZH": "镜像拉取 [reponame][imageName]", "formatZH": "镜像拉取 [reponame][imageName]",
@ -187,11 +192,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "repoID", "input_colume": "id",
"input_value": "repoID",
"isList": false, "isList": false,
"db": "image_repos", "db": "image_repos",
"key": "name", "output_colume": "name",
"value": "reponame" "output_value": "reponame"
} }
], ],
"formatZH": "[tagName] 推送到 [reponame][name]", "formatZH": "[tagName] 推送到 [reponame][name]",
@ -242,11 +248,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "repoID", "input_colume": "id",
"input_value": "repoID",
"isList": false, "isList": false,
"db": "image_repos", "db": "image_repos",
"key": "name", "output_colume": "name",
"value": "reponame" "output_value": "reponame"
} }
], ],
"formatZH": "tag 镜像 [reponame][targetName]", "formatZH": "tag 镜像 [reponame][targetName]",
@ -345,11 +352,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "cronjobs", "db": "cronjobs",
"key": "name", "output_colume": "name",
"value": "names" "output_value": "names"
} }
], ],
"formatZH": "删除计划任务 [names]", "formatZH": "删除计划任务 [names]",
@ -364,11 +372,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "cronjobs", "db": "cronjobs",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "更新计划任务 [name]", "formatZH": "更新计划任务 [name]",
@ -384,11 +393,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "cronjobs", "db": "cronjobs",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "修改计划任务 [name] 状态为 [status]", "formatZH": "修改计划任务 [name] 状态为 [status]",
@ -403,11 +413,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "cronjobs", "db": "cronjobs",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "手动执行计划任务 [name]", "formatZH": "手动执行计划任务 [name]",
@ -472,11 +483,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "database_mysqls", "db": "database_mysqls",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "删除 mysql 数据库 [name]", "formatZH": "删除 mysql 数据库 [name]",
@ -489,11 +501,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "database_mysqls", "db": "database_mysqls",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "mysql 数据库 [name] 描述信息修改 [description]", "formatZH": "mysql 数据库 [name] 描述信息修改 [description]",
@ -595,11 +608,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "commands", "db": "commands",
"key": "name", "output_colume": "name",
"value": "names" "output_value": "names"
} }
], ],
"formatZH": "删除快捷命令 [names]", "formatZH": "删除快捷命令 [names]",
@ -636,11 +650,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "backup_accounts", "db": "backup_accounts",
"key": "type", "output_colume": "type",
"value": "types" "output_value": "types"
} }
], ],
"formatZH": "删除备份账号 [types]", "formatZH": "删除备份账号 [types]",
@ -678,11 +693,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "backup_records", "db": "backup_records",
"key": "file_name", "output_colume": "file_name",
"value": "files" "output_value": "files"
} }
], ],
"formatZH": "删除备份记录 [files]", "formatZH": "删除备份记录 [files]",
@ -697,11 +713,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "groups", "db": "groups",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "删除组 [name]", "formatZH": "删除组 [name]",
@ -740,11 +757,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "hosts", "db": "hosts",
"key": "addr", "output_colume": "addr",
"value": "addr" "output_value": "addr"
} }
], ],
"formatZH": "删除主机 [addr]", "formatZH": "删除主机 [addr]",
@ -849,18 +867,19 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "name", "input_colume": "name",
"input_value": "name",
"isList": false, "isList": false,
"db": "app_installs", "db": "app_installs",
"key": "app_id", "output_colume": "app_id",
"value": "appId" "output_value": "appId"
}, },
{ {
"info": "appId", "info": "appId",
"isList": false, "isList": false,
"db": "apps", "db": "apps",
"key": "key", "output_colume": "key",
"value": "appKey" "output_value": "appKey"
} }
], ],
"formatZH": "安装应用 [appKey]-[name]", "formatZH": "安装应用 [appKey]-[name]",
@ -876,29 +895,32 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "installId", "input_colume": "id",
"input_value": "installId",
"isList": false, "isList": false,
"db": "app_installs", "db": "app_installs",
"key": "app_id", "output_colume": "app_id",
"value": "appId" "output_value": "appId"
}, },
{ {
"info": "installId", "input_colume": "id",
"input_value": "installId",
"isList": false, "isList": false,
"db": "app_installs", "db": "app_installs",
"key": "name", "output_colume": "name",
"value": "appName" "output_value": "appName"
}, },
{ {
"info": "appId", "input_colume": "id",
"input_value": "appId",
"isList": false, "isList": false,
"db": "apps", "db": "apps",
"key": "key", "output_colume": "key",
"value": "appKey" "output_value": "appKey"
} }
], ],
"formatZH": "应用 [appKey]-[appName] [operate]", "formatZH": "[appKey] 应用 [appName] [operate]",
"formatEN": "App [appKey]-[appName] [operate]" "formatEN": "[appKey] App [appName] [operate]"
}, },
{ {
"api": "/api/v1/apps/installed/sync", "api": "/api/v1/apps/installed/sync",
@ -918,11 +940,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "ids", "input_colume": "id",
"input_value": "ids",
"isList": true, "isList": true,
"db": "app_install_backups", "db": "app_install_backups",
"key": "name", "output_colume": "name",
"value": "names" "output_value": "names"
} }
], ],
"formatZH": "删除应用备份 [names]", "formatZH": "删除应用备份 [names]",
@ -1089,11 +1112,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "websiteId", "input_colume": "id",
"input_value": "websiteId",
"isList": false, "isList": false,
"db": "websites", "db": "websites",
"key": "primary_domain", "output_colume": "primary_domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "更新 nginx 配置 [domain]", "formatZH": "更新 nginx 配置 [domain]",
@ -1128,11 +1152,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "website_acme_accounts", "db": "website_acme_accounts",
"key": "email", "output_colume": "email",
"value": "email" "output_value": "email"
} }
], ],
"formatZH": "删除网站 acme [email]", "formatZH": "删除网站 acme [email]",
@ -1169,11 +1194,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "website_dns_accounts", "db": "website_dns_accounts",
"key": "name", "output_colume": "name",
"value": "name" "output_value": "name"
} }
], ],
"formatZH": "删除网站 dns [name]", "formatZH": "删除网站 dns [name]",
@ -1210,11 +1236,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "SSLId", "input_colume": "id",
"input_value": "SSLId",
"isList": false, "isList": false,
"db": "website_ssls", "db": "website_ssls",
"key": "primary_domain", "output_colume": "primary_domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "重置 ssl [domain]", "formatZH": "重置 ssl [domain]",
@ -1251,11 +1278,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "websites", "db": "websites",
"key": "primary_domain", "output_colume": "primary_domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "删除网站 [domain]", "formatZH": "删除网站 [domain]",
@ -1295,11 +1323,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "id", "input_colume": "id",
"input_value": "id",
"isList": false, "isList": false,
"db": "website_domains", "db": "website_domains",
"key": "domain", "output_colume": "domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "删除域名 [domain]", "formatZH": "删除域名 [domain]",
@ -1325,11 +1354,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "websiteId", "input_colume": "id",
"input_value": "websiteId",
"isList": false, "isList": false,
"db": "websites", "db": "websites",
"key": "primary_domain", "output_colume": "primary_domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "nginx 配置修改 [domain]", "formatZH": "nginx 配置修改 [domain]",
@ -1344,11 +1374,12 @@
"paramKeys": [], "paramKeys": [],
"BeforeFuntions": [ "BeforeFuntions": [
{ {
"info": "websiteId", "input_colume": "id",
"input_value": "websiteId",
"isList": false, "isList": false,
"db": "websites", "db": "websites",
"key": "primary_domain", "output_colume": "primary_domain",
"value": "domain" "output_value": "domain"
} }
], ],
"formatZH": "WAF 配置修改 [domain]", "formatZH": "WAF 配置修改 [domain]",

View File

@ -54,6 +54,11 @@ export namespace Database {
permission: string; permission: string;
description: string; description: string;
} }
export interface MysqlDBDelete {
id: number;
forceDelete: boolean;
deleteBackup: boolean;
}
export interface MysqlVariables { export interface MysqlVariables {
mysqlName: string; mysqlName: string;
binlog_cache_size: number; binlog_cache_size: number;

View File

@ -37,8 +37,8 @@ export const updateMysqlConfByFile = (params: Database.MysqlConfUpdateByFile) =>
export const deleteCheckMysqlDB = (id: number) => { export const deleteCheckMysqlDB = (id: number) => {
return http.post<Array<string>>(`/databases/del/check`, { id: id }); return http.post<Array<string>>(`/databases/del/check`, { id: id });
}; };
export const deleteMysqlDB = (id: number) => { export const deleteMysqlDB = (params: Database.MysqlDBDelete) => {
return http.post(`/databases/del`, { id: id }); return http.post(`/databases/del`, params);
}; };
export const loadMysqlBaseInfo = () => { export const loadMysqlBaseInfo = () => {

View File

@ -225,6 +225,8 @@ export default {
logout: 'Logout', logout: 'Logout',
}, },
database: { database: {
delete: 'Delete operation cannot be rolled back, please input "',
deleteHelper: '" to delete this database',
create: 'Create database', create: 'Create database',
noMysql: 'No {0} database is detected, please go to App Store and click Install!', noMysql: 'No {0} database is detected, please go to App Store and click Install!',
goInstall: 'Go to install', goInstall: 'Go to install',
@ -591,7 +593,7 @@ export default {
detail: { detail: {
users: 'User', users: 'User',
hosts: 'Host', hosts: 'Host',
groups: 'Group', apps: 'App',
containers: 'Container', containers: 'Container',
commands: 'Command', commands: 'Command',
backups: 'Backup Account', backups: 'Backup Account',

View File

@ -230,6 +230,8 @@ export default {
logout: '退出登录', logout: '退出登录',
}, },
database: { database: {
delete: '删除操作无法回滚请输入 "',
deleteHelper: '" 删除此数据库',
create: '创建数据库', create: '创建数据库',
noMysql: '当前未检测到 {0} 数据库请进入应用商店点击安装', noMysql: '当前未检测到 {0} 数据库请进入应用商店点击安装',
mysqlBadStatus: '当前 mysql 应用状态异常请在', mysqlBadStatus: '当前 mysql 应用状态异常请在',
@ -603,7 +605,7 @@ export default {
detail: { detail: {
users: '用户', users: '用户',
hosts: '主机', hosts: '主机',
groups: '组', apps: '应用',
containers: '容器', containers: '容器',
commands: '快捷命令', commands: '快捷命令',
backups: '备份账号', backups: '备份账号',

View File

@ -9,23 +9,18 @@
<el-form ref="deleteForm" label-position="left"> <el-form ref="deleteForm" label-position="left">
<el-form-item> <el-form-item>
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('app.forceDelete')" /> <el-checkbox v-model="deleteReq.forceDelete" :label="$t('app.forceDelete')" />
</el-form-item>
<div class="helper">
<span class="input-help"> <span class="input-help">
{{ $t('app.forceDeleteHelper') }} {{ $t('app.forceDeleteHelper') }}
</span> </span>
</div> </el-form-item>
<el-form-item> <el-form-item>
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" /> <el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
</el-form-item>
<div class="helper">
<span class="input-help"> <span class="input-help">
{{ $t('app.deleteBackupHelper') }} {{ $t('app.deleteBackupHelper') }}
</span> </span>
</div> </el-form-item>
<br />
<span v-html="deleteHelper"></span>
<el-form-item> <el-form-item>
<span v-html="deleteHelper"></span>
<el-input v-model="deleteInfo" :placeholder="appInstallName" /> <el-input v-model="deleteInfo" :placeholder="appInstallName" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -98,9 +93,3 @@ defineExpose({
acceptParams, acceptParams,
}); });
</script> </script>
<style lang="scss">
.helper {
margin-top: -20px;
}
</style>

View File

@ -36,7 +36,7 @@
<el-form-item v-if="form.from === 'edit'"> <el-form-item v-if="form.from === 'edit'">
<codemirror <codemirror
:autofocus="true" :autofocus="true"
placeholder="None data" placeholder="#Define or paste the content of your docker-compose file here"
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 500px; width: 100%; min-height: 200px" style="max-height: 500px; width: 100%; min-height: 200px"

View File

@ -2,13 +2,20 @@
<div v-loading="loading"> <div v-loading="loading">
<LayoutContent :header="composeName" back-name="Compose" :reload="true"> <LayoutContent :header="composeName" back-name="Compose" :reload="true">
<div v-if="createdBy === '1Panel'"> <div v-if="createdBy === '1Panel'">
<el-button-group> <el-card>
<el-button @click="onComposeOperate('start')">{{ $t('container.start') }}</el-button> <template #header>
<el-button @click="onComposeOperate('stop')">{{ $t('container.stop') }}</el-button> <div class="card-header">
<el-button @click="onComposeOperate('down')"> <span>{{ $t('container.compose') }}</span>
{{ $t('container.remove') }} </div>
</el-button> </template>
</el-button-group> <el-button-group>
<el-button @click="onComposeOperate('start')">{{ $t('container.start') }}</el-button>
<el-button @click="onComposeOperate('stop')">{{ $t('container.stop') }}</el-button>
<el-button @click="onComposeOperate('down')">
{{ $t('container.remove') }}
</el-button>
</el-button-group>
</el-card>
</div> </div>
<div v-else> <div v-else>
<el-alert :closable="false" show-icon :title="$t('container.composeDetailHelper')" type="info" /> <el-alert :closable="false" show-icon :title="$t('container.composeDetailHelper')" type="info" />

View File

@ -8,7 +8,7 @@
<div v-loading="loading"> <div v-loading="loading">
<codemirror <codemirror
:autofocus="true" :autofocus="true"
placeholder="None data" placeholder="#Define or paste the content of your docker-compose file here"
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 500px; width: 100%; min-height: 200px" style="max-height: 500px; width: 100%; min-height: 200px"

View File

@ -24,7 +24,7 @@
<el-form-item v-if="form.from === 'edit'" :rules="Rules.requiredInput"> <el-form-item v-if="form.from === 'edit'" :rules="Rules.requiredInput">
<codemirror <codemirror
:autofocus="true" :autofocus="true"
placeholder="None data" placeholder="#Define or paste the content of your Dockerfile here"
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 500px; width: 100%; min-height: 200px" style="max-height: 500px; width: 100%; min-height: 200px"
@ -57,7 +57,7 @@
<codemirror <codemirror
v-if="logVisiable" v-if="logVisiable"
:autofocus="true" :autofocus="true"
placeholder="Wait for build output..." placeholder="Waiting for build output..."
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 300px" style="max-height: 300px"

View File

@ -35,7 +35,7 @@
<codemirror <codemirror
v-if="logVisiable" v-if="logVisiable"
:autofocus="true" :autofocus="true"
placeholder="Wait for pull output..." placeholder="Waiting for pull output..."
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 300px" style="max-height: 300px"

View File

@ -32,7 +32,7 @@
<codemirror <codemirror
v-if="logVisiable" v-if="logVisiable"
:autofocus="true" :autofocus="true"
placeholder="Wait for pull output..." placeholder="Waiting for push output..."
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 300px" style="max-height: 300px"

View File

@ -15,7 +15,7 @@
<el-form-item> <el-form-item>
<codemirror <codemirror
:autofocus="true" :autofocus="true"
placeholder="None data" placeholder="#Define or paste the content of your docker-compose file here"
:indent-with-tab="true" :indent-with-tab="true"
:tabSize="4" :tabSize="4"
style="max-height: 500px; width: 100%; min-height: 200px" style="max-height: 500px; width: 100%; min-height: 200px"

View File

@ -0,0 +1,94 @@
<template>
<el-dialog
v-model="dialogVisiable"
:title="$t('commons.button.delete') + ' - ' + dbName"
width="30%"
:close-on-click-modal="false"
>
<el-form ref="deleteForm">
<el-form-item>
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('app.forceDelete')" />
<span class="input-help">
{{ $t('app.forceDeleteHelper') }}
</span>
</el-form-item>
<el-form-item>
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('app.deleteBackup')" />
<span class="input-help">
{{ $t('app.deleteBackupHelper') }}
</span>
</el-form-item>
<el-form-item>
<div>
<span style="font-size: 12px">{{ $t('database.delete') }}</span>
<span style="font-size: 12px; color: red; font-weight: 500">{{ dbName }}</span>
<span style="font-size: 12px">{{ $t('database.deleteHelper') }}</span>
</div>
<el-input v-model="deleteInfo" :placeholder="dbName"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisiable = false" :loading="loading">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="submit" :loading="loading" :disabled="deleteInfo != dbName">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ElMessage, FormInstance } from 'element-plus';
import { ref } from 'vue';
import i18n from '@/lang';
import { deleteMysqlDB } from '@/api/modules/database';
let deleteReq = ref({
id: 0,
deleteBackup: false,
forceDelete: false,
});
let dialogVisiable = ref(false);
let loading = ref(false);
let deleteInfo = ref('');
let dbName = ref('');
const deleteForm = ref<FormInstance>();
interface DialogProps {
id: number;
name: string;
}
const emit = defineEmits<{ (e: 'search'): void }>();
const acceptParams = async (prop: DialogProps) => {
deleteReq.value = {
id: prop.id,
deleteBackup: false,
forceDelete: false,
};
dbName.value = prop.name;
deleteInfo.value = '';
dialogVisiable.value = true;
};
const submit = async () => {
loading.value = true;
deleteMysqlDB(deleteReq.value)
.then(() => {
loading.value = false;
emit('search');
ElMessage.success(i18n.global.t('commons.msg.deleteSuccess'));
dialogVisiable.value = false;
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@ -19,12 +19,7 @@
</el-card> </el-card>
<div v-if="mysqlIsExist" :class="{ mask: mysqlStatus != 'Running' }"> <div v-if="mysqlIsExist" :class="{ mask: mysqlStatus != 'Running' }">
<el-card v-if="!isOnSetting" style="margin-top: 20px"> <el-card v-if="!isOnSetting" style="margin-top: 20px">
<ComplexTable <ComplexTable :pagination-config="paginationConfig" @search="search" :data="data">
:pagination-config="paginationConfig"
v-model:selects="selects"
@search="search"
:data="data"
>
<template #toolbar> <template #toolbar>
<el-button type="primary" icon="Plus" @click="onOpenDialog()"> <el-button type="primary" icon="Plus" @click="onOpenDialog()">
{{ $t('commons.button.create') }} {{ $t('commons.button.create') }}
@ -37,7 +32,6 @@
</el-button> </el-button>
<el-button @click="goDashboard" type="primary" plain icon="Position">phpMyAdmin</el-button> <el-button @click="goDashboard" type="primary" plain icon="Position">phpMyAdmin</el-button>
</template> </template>
<el-table-column type="selection" fix />
<el-table-column :label="$t('commons.table.name')" prop="name" /> <el-table-column :label="$t('commons.table.name')" prop="name" />
<el-table-column :label="$t('commons.login.username')" prop="username" /> <el-table-column :label="$t('commons.login.username')" prop="username" />
<el-table-column :label="$t('commons.login.password')" prop="password"> <el-table-column :label="$t('commons.login.password')" prop="password">
@ -170,10 +164,11 @@
<RootPasswordDialog ref="rootPasswordRef" /> <RootPasswordDialog ref="rootPasswordRef" />
<RemoteAccessDialog ref="remoteAccessRef" /> <RemoteAccessDialog ref="remoteAccessRef" />
<UploadDialog ref="uploadRef" /> <UploadDialog ref="uploadRef" />
<OperatrDialog @search="search" ref="dialogRef" /> <OperateDialog @search="search" ref="dialogRef" />
<BackupRecords ref="dialogBackupRef" /> <BackupRecords ref="dialogBackupRef" />
<AppResources ref="checkRef"></AppResources> <AppResources ref="checkRef"></AppResources>
<DeleteDialog ref="deleteRef" @search="search" />
<ConfirmDialog ref="confirmDialogRef" @confirm="onSubmit"></ConfirmDialog> <ConfirmDialog ref="confirmDialogRef" @confirm="onSubmit"></ConfirmDialog>
</div> </div>
@ -181,7 +176,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import ComplexTable from '@/components/complex-table/index.vue'; import ComplexTable from '@/components/complex-table/index.vue';
import OperatrDialog from '@/views/database/mysql/create/index.vue'; import OperateDialog from '@/views/database/mysql/create/index.vue';
import DeleteDialog from '@/views/database/mysql/delete/index.vue';
import RootPasswordDialog from '@/views/database/mysql/password/index.vue'; import RootPasswordDialog from '@/views/database/mysql/password/index.vue';
import RemoteAccessDialog from '@/views/database/mysql/remote/index.vue'; import RemoteAccessDialog from '@/views/database/mysql/remote/index.vue';
import BackupRecords from '@/views/database/mysql/backup/index.vue'; import BackupRecords from '@/views/database/mysql/backup/index.vue';
@ -195,7 +191,6 @@ import { dateFromat } from '@/utils/util';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { import {
deleteCheckMysqlDB, deleteCheckMysqlDB,
deleteMysqlDB,
loadRemoteAccess, loadRemoteAccess,
searchMysqlDBs, searchMysqlDBs,
updateMysqlAccess, updateMysqlAccess,
@ -203,7 +198,6 @@ import {
updateMysqlPassword, updateMysqlPassword,
} from '@/api/modules/database'; } from '@/api/modules/database';
import i18n from '@/lang'; import i18n from '@/lang';
import { useDeleteData } from '@/hooks/use-delete-data';
import { ElForm, ElMessage } from 'element-plus'; import { ElForm, ElMessage } from 'element-plus';
import { Database } from '@/api/interface/database'; import { Database } from '@/api/interface/database';
import { Rules } from '@/global/form-rules'; import { Rules } from '@/global/form-rules';
@ -213,11 +207,11 @@ import router from '@/routers';
const loading = ref(false); const loading = ref(false);
const selects = ref<any>([]);
const mysqlName = ref(); const mysqlName = ref();
const isOnSetting = ref<boolean>(); const isOnSetting = ref<boolean>();
const checkRef = ref(); const checkRef = ref();
const deleteRef = ref();
const phpadminPort = ref(); const phpadminPort = ref();
const phpVisiable = ref(false); const phpVisiable = ref(false);
@ -403,8 +397,7 @@ const onDelete = async (row: Database.MysqlDBInfo) => {
if (res.data && res.data.length > 0) { if (res.data && res.data.length > 0) {
checkRef.value.acceptParams({ items: res.data }); checkRef.value.acceptParams({ items: res.data });
} else { } else {
await useDeleteData(deleteMysqlDB, row.id, 'app.deleteWarn'); deleteRef.value.acceptParams({ id: row.id, name: row.name });
search();
} }
}; };
@ -446,6 +439,7 @@ const buttons = [
if (row.permission === '%' || row.permission === 'localhost') { if (row.permission === '%' || row.permission === 'localhost') {
changeForm.privilege = row.permission; changeForm.privilege = row.permission;
} else { } else {
changeForm.privilegeIPs = row.permission;
changeForm.privilege = 'ip'; changeForm.privilege = 'ip';
} }
changeVisiable.value = true; changeVisiable.value = true;

View File

@ -75,7 +75,7 @@ import { File } from '@/api/interface/file';
import { BatchDeleteFile, GetFilesList, UploadFileData } from '@/api/modules/files'; import { BatchDeleteFile, GetFilesList, UploadFileData } from '@/api/modules/files';
const selects = ref<any>([]); const selects = ref<any>([]);
const baseDir = '/opt/1Panel/data/uploads/database/'; const baseDir = ref();
const data = ref(); const data = ref();
const paginationConfig = reactive({ const paginationConfig = reactive({
@ -94,6 +94,7 @@ interface DialogProps {
const acceptParams = (params: DialogProps): void => { const acceptParams = (params: DialogProps): void => {
mysqlName.value = params.mysqlName; mysqlName.value = params.mysqlName;
dbName.value = params.dbName; dbName.value = params.dbName;
baseDir.value = '/opt/1Panel/data/uploads/database/mysql/' + mysqlName.value + '/' + dbName.value + '/';
upVisiable.value = true; upVisiable.value = true;
search(); search();
}; };
@ -102,7 +103,7 @@ const search = async () => {
let params = { let params = {
page: paginationConfig.currentPage, page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize, pageSize: paginationConfig.pageSize,
path: baseDir, path: baseDir.value,
expand: true, expand: true,
}; };
const res = await GetFilesList(params); const res = await GetFilesList(params);
@ -114,7 +115,7 @@ const onRecover = async (row: File.File) => {
let params = { let params = {
mysqlName: mysqlName.value, mysqlName: mysqlName.value,
dbName: dbName.value, dbName: dbName.value,
fileDir: baseDir, fileDir: baseDir.value,
fileName: row.name, fileName: row.name,
}; };
await recoverByUpload(params); await recoverByUpload(params);
@ -156,7 +157,7 @@ const onSubmit = () => {
if (uploaderFiles.value[0]!.raw != undefined) { if (uploaderFiles.value[0]!.raw != undefined) {
formData.append('file', uploaderFiles.value[0]!.raw); formData.append('file', uploaderFiles.value[0]!.raw);
} }
formData.append('path', baseDir); formData.append('path', baseDir.value);
UploadFileData(formData, {}).then(() => { UploadFileData(formData, {}).then(() => {
ElMessage.success(i18n.global.t('file.uploadSuccess')); ElMessage.success(i18n.global.t('file.uploadSuccess'));
handleClose(); handleClose();
@ -167,10 +168,10 @@ const onSubmit = () => {
const onBatchDelete = async (row: File.File | null) => { const onBatchDelete = async (row: File.File | null) => {
let files: Array<string> = []; let files: Array<string> = [];
if (row) { if (row) {
files.push(baseDir + row.name); files.push(baseDir.value + row.name);
} else { } else {
selects.value.forEach((item: File.File) => { selects.value.forEach((item: File.File) => {
files.push(baseDir + item.name); files.push(baseDir.value + item.name);
}); });
} }
await useDeleteData(BatchDeleteFile, { paths: files, isDir: false }, 'commons.msg.delete'); await useDeleteData(BatchDeleteFile, { paths: files, isDir: false }, 'commons.msg.delete');

View File

@ -21,7 +21,7 @@
<el-button style="margin-top: 10px" @click="getDefaultConfig()"> <el-button style="margin-top: 10px" @click="getDefaultConfig()">
{{ $t('app.defaultConfig') }} {{ $t('app.defaultConfig') }}
</el-button> </el-button>
<el-button type="primary" @click="onSaveFile" style="margin-top: 5px"> <el-button type="primary" @click="onSaveFile" style="margin-top: 10px">
{{ $t('commons.button.save') }} {{ $t('commons.button.save') }}
</el-button> </el-button>
<el-row> <el-row>
@ -98,6 +98,7 @@
</LayoutContent> </LayoutContent>
</el-card> </el-card>
<ConfirmDialog ref="confirmDialogRef" @confirm="submtiFile"></ConfirmDialog>
<ConfirmDialog ref="confirmFileRef" @confirm="submtiFile"></ConfirmDialog> <ConfirmDialog ref="confirmFileRef" @confirm="submtiFile"></ConfirmDialog>
<ConfirmDialog ref="confirmFormRef" @confirm="submtiForm"></ConfirmDialog> <ConfirmDialog ref="confirmFormRef" @confirm="submtiForm"></ConfirmDialog>
<ConfirmDialog ref="confirmPortRef" @confirm="onChangePort(formRef)"></ConfirmDialog> <ConfirmDialog ref="confirmPortRef" @confirm="onChangePort(formRef)"></ConfirmDialog>

View File

@ -15,11 +15,11 @@
<el-table-column min-width="40" :label="$t('logs.loginStatus')" prop="status"> <el-table-column min-width="40" :label="$t('logs.loginStatus')" prop="status">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row.status === 'Success'"> <div v-if="row.status === 'Success'">
<el-tag type="success">{{ row.status }}</el-tag> <el-tag type="success">{{ $t('commons.status.success') }}</el-tag>
</div> </div>
<div v-else> <div v-else>
<el-tooltip class="box-item" effect="dark" :content="row.message" placement="top-start"> <el-tooltip class="box-item" effect="dark" :content="row.message" placement="top-start">
<el-tag type="danger">{{ row.status }}</el-tag> <el-tag type="danger">{{ $t('commons.status.failed') }}</el-tag>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>

View File

@ -19,6 +19,7 @@
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input
type="password" type="password"
clearable
v-model="registerForm.password" v-model="registerForm.password"
show-password show-password
:placeholder="$t('commons.login.password')" :placeholder="$t('commons.login.password')"
@ -33,6 +34,7 @@
<el-form-item prop="rePassword"> <el-form-item prop="rePassword">
<el-input <el-input
type="password" type="password"
clearable
v-model="registerForm.rePassword" v-model="registerForm.rePassword"
show-password show-password
:placeholder="$t('commons.login.rePassword')" :placeholder="$t('commons.login.rePassword')"
@ -113,6 +115,7 @@
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input
type="password" type="password"
clearable
v-model="loginForm.password" v-model="loginForm.password"
show-password show-password
:placeholder="$t('commons.login.password')" :placeholder="$t('commons.login.password')"

View File

@ -42,7 +42,7 @@
prop="credential" prop="credential"
:rules="Rules.requiredInput" :rules="Rules.requiredInput"
> >
<el-input show-password v-model="dialogData.rowData!.credential" /> <el-input show-password clearable v-model="dialogData.rowData!.credential" />
</el-form-item> </el-form-item>
<el-form-item <el-form-item
v-if="dialogData.rowData!.type === 'S3'" v-if="dialogData.rowData!.type === 'S3'"
@ -99,7 +99,7 @@
<el-input v-model="dialogData.rowData!.accessKey" /> <el-input v-model="dialogData.rowData!.accessKey" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('setting.password')" prop="credential" :rules="[Rules.requiredInput]"> <el-form-item :label="$t('setting.password')" prop="credential" :rules="[Rules.requiredInput]">
<el-input type="password" show-password v-model="dialogData.rowData!.credential" /> <el-input type="password" clearable show-password v-model="dialogData.rowData!.credential" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('setting.path')" prop="bucket"> <el-form-item :label="$t('setting.path')" prop="bucket">
<el-input v-model="dialogData.rowData!.bucket" /> <el-input v-model="dialogData.rowData!.bucket" />

View File

@ -10,31 +10,24 @@
<el-form ref="deleteForm" label-position="left"> <el-form ref="deleteForm" label-position="left">
<el-form-item> <el-form-item>
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('website.forceDelete')" /> <el-checkbox v-model="deleteReq.forceDelete" :label="$t('website.forceDelete')" />
</el-form-item>
<div class="helper">
<span class="input-help"> <span class="input-help">
{{ $t('website.forceDeleteHelper') }} {{ $t('website.forceDeleteHelper') }}
</span> </span>
</div> </el-form-item>
<el-form-item v-if="type === 'deployment'"> <el-form-item v-if="type === 'deployment'">
<el-checkbox v-model="deleteReq.deleteApp" :label="$t('website.deleteApp')" /> <el-checkbox v-model="deleteReq.deleteApp" :label="$t('website.deleteApp')" />
</el-form-item>
<div class="helper" v-if="type === 'deployment'">
<span class="input-help"> <span class="input-help">
{{ $t('website.deleteAppHelper') }} {{ $t('website.deleteAppHelper') }}
</span> </span>
</div> </el-form-item>
<el-form-item> <el-form-item>
<el-checkbox v-model="deleteReq.deleteBackup" :label="$t('website.deleteBackup')" /> <el-checkbox v-model="deleteReq.deleteBackup" :label="$t('website.deleteBackup')" />
</el-form-item>
<div class="helper">
<span class="input-help"> <span class="input-help">
{{ $t('website.deleteBackupHelper') }} {{ $t('website.deleteBackupHelper') }}
</span> </span>
</div> </el-form-item>
<br />
<span v-html="deleteHelper"></span>
<el-form-item> <el-form-item>
<span v-html="deleteHelper"></span>
<el-input v-model="deleteInfo" :placeholder="websiteName" /> <el-input v-model="deleteInfo" :placeholder="websiteName" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -114,9 +107,3 @@ defineExpose({
acceptParams, acceptParams,
}); });
</script> </script>
<style lang="scss">
.helper {
margin-top: -20px;
}
</style>