feat: Support import and export operations for quick commands (#10434)

Refs #7384
This commit is contained in:
ssongliu
2025-09-22 17:20:04 +08:00
committed by GitHub
parent 40e264e0d4
commit 4942c6c206
29 changed files with 451 additions and 10 deletions

View File

@@ -1,11 +1,102 @@
package v2
import (
"encoding/csv"
"errors"
"fmt"
"io"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/gin-gonic/gin"
)
// @Tags Command
// @Summary Export command
// @Success 200 {string} path
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/commands/upload [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"导出快速命令","formatEN":"export quick commands"}
func (b *BaseApi) UploadCommandCsv(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
helper.BadRequest(c, err)
return
}
files := form.File["file"]
if len(files) == 0 {
helper.BadRequest(c, errors.New("no such files"))
return
}
uploadFile, _ := files[0].Open()
reader := csv.NewReader(uploadFile)
if _, err := reader.Read(); err != nil {
helper.BadRequest(c, fmt.Errorf("read title failed, err: %v", err))
return
}
groupRepo := repo.NewIGroupRepo()
group, _ := groupRepo.Get(groupRepo.WithByDefault(true))
var commands []dto.CommandInfo
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
helper.BadRequest(c, fmt.Errorf("read content failed, err: %v", err))
return
}
if len(record) >= 2 {
commands = append(commands, dto.CommandInfo{
Name: record[0],
Type: "command",
GroupID: group.ID,
Command: record[1],
GroupBelong: group.Name,
})
}
}
helper.SuccessWithData(c, commands)
}
// @Tags Command
// @Summary Export command
// @Success 200 {string} path
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/commands/export [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"导出快速命令","formatEN":"export quick commands"}
func (b *BaseApi) ExportCommands(c *gin.Context) {
file, err := commandService.Export()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, file)
}
// @Tags Command
// @Summary Import command
// @Success 200 {string} path
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/commands/import [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"导入快速命令","formatEN":"import quick commands"}
func (b *BaseApi) ImportCommands(c *gin.Context) {
var req dto.CommandImport
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
for _, item := range req.Items {
_ = commandService.Create(item)
}
helper.Success(c)
}
// @Tags Command
// @Summary Create command
// @Accept json

View File

@@ -9,6 +9,10 @@ type SearchCommandWithPage struct {
Info string `json:"info"`
}
type CommandImport struct {
Items []CommandOperate `json:"items"`
}
type CommandOperate struct {
ID uint `json:"id"`
Type string `json:"type"`

View File

@@ -1,10 +1,17 @@
package service
import (
"fmt"
"os"
"path"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/csv"
"github.com/jinzhu/copier"
)
@@ -17,6 +24,8 @@ type ICommandService interface {
Create(req dto.CommandOperate) error
Update(req dto.CommandOperate) error
Delete(ids []uint) error
Export() (string, error)
}
func NewICommandService() ICommandService {
@@ -98,6 +107,28 @@ func (u *CommandService) SearchWithPage(req dto.SearchCommandWithPage) (int64, i
return total, dtoCommands, err
}
func (u *CommandService) Export() (string, error) {
commands, err := commandRepo.List(repo.WithByType("command"))
if err != nil {
return "", err
}
var list []csv.CommandTemplate
for _, item := range commands {
list = append(list, csv.CommandTemplate{
Name: item.Name,
Command: item.Command,
})
}
tmpFileName := path.Join(global.CONF.Base.InstallDir, "1panel/tmp/export/commands", fmt.Sprintf("1panel-commands-%s.csv", time.Now().Format(constant.DateTimeSlimLayout)))
if _, err := os.Stat(path.Dir(tmpFileName)); err != nil {
_ = os.MkdirAll(path.Dir(tmpFileName), constant.DirPerm)
}
if err := csv.ExportCommands(tmpFileName, list); err != nil {
return "", err
}
return tmpFileName, err
}
func (u *CommandService) Create(req dto.CommandOperate) error {
command, _ := commandRepo.Get(repo.WithByName(req.Name), repo.WithByType(req.Type))
if command.ID != 0 {

View File

@@ -230,4 +230,8 @@ MasterNodePortNotAvailable: "Node {{ .name }} port {{ .port }} connectivity veri
ClusterMasterNotExist: "The master node of the cluster is disconnected, please delete the child nodes."
#ssl
ErrReqFailed: "{{.name}} request failed: {{ .err }}"
ErrReqFailed: "{{.name}} request failed: {{ .err }}"
#command
Name: "Name"
Command: "Command"

View File

@@ -231,3 +231,7 @@ ClusterMasterNotExist: "El nodo principal del clúster está desconectado, elimi
#ssl
ErrReqFailed: "{{.name}} petición fallida: {{ .err }}"
#command
Name: "Nombre"
Command: "Comando"

View File

@@ -231,4 +231,8 @@ MasterNodePortNotAvailable: "ノード {{ .name }} のポート {{ .port }} の
ClusterMasterNotExist: "クラスタのマスターノードが切断されています。子ノードを削除してください。"
#ssl
ErrReqFailed: "{{.name}} リクエスト失敗: {{ .err }}"
ErrReqFailed: "{{.name}} リクエスト失敗: {{ .err }}"
#command
Name: "名前"
Command: "コマンド"

View File

@@ -230,4 +230,8 @@ MasterNodePortNotAvailable: "노드 {{ .name }} 포트 {{ .port }} 연결성 검
ClusterMasterNotExist: "클러스터의 마스터 노드가 연결이 끊어졌습니다. 자식 노드를 삭제하세요."
#ssl
ErrReqFailed: "{{.name}} 요청 실패: {{ .err }}"
ErrReqFailed: "{{.name}} 요청 실패: {{ .err }}"
#command
Name: "이름"
Command: "명령어"

View File

@@ -225,4 +225,8 @@ MasterNodePortNotAvailable: "Pengesahan kesambungan pelabuhan {{ .name }} nod {{
ClusterMasterNotExist: "Node utama kluster terputus, sila padamkan nod anak."
#ssl
ErrReqFailed: "{{.name}} permintaan gagal: {{ .err }}"
ErrReqFailed: "{{.name}} permintaan gagal: {{ .err }}"
#command
Name: "Nama"
Command: "Arahan"

View File

@@ -230,4 +230,8 @@ MasterNodePortNotAvailable: "A verificação de conectividade da porta {{ .port
ClusterMasterNotExist: "O nó mestre do cluster está desconectado, por favor, exclua os nós filhos."
#ssl
ErrReqFailed: "{{.name}} solicitação falhou: {{ .err }}
ErrReqFailed: "{{.name}} solicitação falhou: {{ .err }}"
#command
Name: "Название"
Command: "Команда"

View File

@@ -230,4 +230,8 @@ MasterNodePortNotAvailable: "Проверка подключения порта
ClusterMasterNotExist: "Основной узел кластера отключен, пожалуйста, удалите дочерние узлы."
#ssl
ErrReqFailed: "{{.name}} запрос не удался: {{ .err }}"
ErrReqFailed: "{{.name}} запрос не удался: {{ .err }}"
#command
Name: "Название"
Command: "Команда"

View File

@@ -229,4 +229,8 @@ MasterNodePortNotAvailable: "Düğüm {{ .name }} portu {{ .port }} bağlantı d
ClusterMasterNotExist: "Küme ana düğümü bağlantısı kesildi, lütfen alt düğümleri silin."
#ssl
ErrReqFailed: "{{.name}} istek başarısız: {{ .err }}"
ErrReqFailed: "{{.name}} istek başarısız: {{ .err }}"
#command
Name: "Ad"
Command: "Komut"

View File

@@ -241,3 +241,7 @@ ClusterMasterNotExist: "叢集主節點失聯,請刪除子節點"
#ssl
ErrReqFailed: "{{.name}} 請求失敗: {{ .err }}"
#command
Name: "名稱"
Command: "命令"

View File

@@ -239,4 +239,8 @@ MasterNodePortNotAvailable: "节点 {{ .name }} 端口 {{ .port }} 连通性校
ClusterMasterNotExist: "集群主节点失联,请删除子节点"
#ssl
ErrReqFailed: "{{.name}} 请求失败: {{ .err }}"
ErrReqFailed: "{{.name}} 请求失败: {{ .err }}"
#command
Name: "名称"
Command: "命令"

View File

@@ -20,5 +20,8 @@ func (s *CommandRouter) InitRouter(Router *gin.RouterGroup) {
commandRouter.POST("/search", baseApi.SearchCommand)
commandRouter.POST("/tree", baseApi.SearchCommandTree)
commandRouter.POST("/update", baseApi.UpdateCommand)
commandRouter.POST("/export", baseApi.ExportCommands)
commandRouter.POST("/upload", baseApi.UploadCommandCsv)
commandRouter.POST("/import", baseApi.ImportCommands)
}
}

43
core/utils/csv/command.go Normal file
View File

@@ -0,0 +1,43 @@
package csv
import (
"encoding/csv"
"os"
"github.com/1Panel-dev/1Panel/core/i18n"
)
type CommandTemplate struct {
Name string `json:"name"`
Command string `json:"command"`
}
func ExportCommands(filename string, commands []CommandTemplate) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
if err := writer.Write([]string{
i18n.GetMsgByKey("Name"),
i18n.GetMsgByKey("Command"),
}); err != nil {
return err
}
for _, log := range commands {
record := []string{
log.Name,
log.Command,
}
if err := writer.Write(record); err != nil {
return err
}
}
return nil
}

View File

@@ -153,7 +153,7 @@ class RequestHttp {
download<BlobPart>(url: string, params?: object, _object = {}): Promise<BlobPart> {
return this.service.post(url, params, _object);
}
upload<T>(url: string, params: object = {}, config?: AxiosRequestConfig): Promise<T> {
upload<T>(url: string, params: object = {}, config?: AxiosRequestConfig): Promise<ResultData<T>> {
return this.service.post(url, params, config);
}
}

View File

@@ -5,6 +5,15 @@ import { Command } from '../interface/command';
export const getCommandList = (type: string) => {
return http.post<Array<Command.CommandInfo>>(`/core/commands/list`, { type: type });
};
export const exportCommands = () => {
return http.post<string>(`/core/commands/export`);
};
export const uploadCommands = (params: FormData) => {
return http.upload<Array<Command.CommandInfo>>(`/core/commands/upload`, params);
};
export const importCommands = (list: Array<Command.CommandOperate>) => {
return http.post(`/core/commands/import`, { items: list });
};
export const getCommandPage = (params: SearchWithPage) => {
return http.post<ResPage<Command.CommandInfo>>(`/core/commands/search`, params);
};

View File

@@ -1184,6 +1184,7 @@ const message = {
fold: 'All contract',
batchInput: 'Batch processing',
quickCommand: 'Quick command | Quick commands',
noSuchCommand: 'No quick command data found in the imported CSV file, please check and try again!',
quickCommandHelper: 'You can use the quick commands at the bottom of the "Terminals -> Terminals".',
groupDeleteHelper:
'After the group is removed, all connections in the group will be migrated to the default group. Do you want to continue?',

View File

@@ -1185,6 +1185,8 @@ const message = {
fold: 'Contraer todo',
batchInput: 'Procesamiento por lotes',
quickCommand: 'Comando rápido | Comandos rápidos',
noSuchCommand:
'No se encontraron datos de comandos rápidos en el archivo CSV importado, ¡compruebe e inténtelo de nuevo!',
quickCommandHelper: 'Puede usar comandos rápidos en la parte inferior de "Terminales -> Terminales".',
groupDeleteHelper:
'Después de eliminar el grupo, todas las conexiones pasarán al grupo predeterminado. ¿Desea continuar?',

View File

@@ -1146,6 +1146,8 @@ const message = {
fold: 'すべての契約',
batchInput: 'バッチ処理',
quickCommand: 'クイックコマンド|クイックコマンド',
noSuchCommand:
'インポートしたCSVファイルにクイックコマンドデータが見つかりませんでした。確認して再試行してください',
quickCommandHelper: '「端末 - >端子」の下部にあるクイックコマンドを使用できます。',
groupDeleteHelper:
'グループが削除された後、グループ内のすべての接続がデフォルトグループに移行されます。続けたいですか?',

View File

@@ -1138,6 +1138,7 @@ const message = {
fold: '모두 축소',
batchInput: '배치 처리',
quickCommand: '빠른 명령 | 빠른 명령들',
noSuchCommand: '가져온 CSV 파일에서 빠른 명령어 데이터를 찾을 수 없습니다. 확인 후 다시 시도하세요!',
quickCommandHelper: '"터미널 -> 터미널" 하단에서 빠른 명령을 사용할 수 있습니다.',
groupDeleteHelper: '그룹을 제거하면 해당 그룹의 모든 연결이 기본 그룹으로 이동됩니다. 계속하시겠습니까?',
command: '명령',

View File

@@ -1174,6 +1174,7 @@ const message = {
fold: 'Kontrak semua',
batchInput: 'Pemprosesan kelompok',
quickCommand: 'Arahan pantas | Arahan pantas',
noSuchCommand: 'Tiada data arahan pantas ditemui dalam fail CSV yang diimport, sila periksa dan cuba lagi!',
quickCommandHelper: 'Anda boleh menggunakan arahan pantas di bahagian bawah "Terminal -> Terminal".',
groupDeleteHelper:
'Selepas kumpulan dikeluarkan, semua sambungan dalam kumpulan akan dipindahkan ke kumpulan lalai. Adakah anda mahu meneruskan?',

View File

@@ -1167,6 +1167,8 @@ const message = {
fold: 'Contrair tudo',
batchInput: 'Processamento em lote',
quickCommand: 'Comando rápido | Comandos rápidos',
noSuchCommand:
'Nenhum dado de comando rápido encontrado no arquivo CSV importado, verifique e tente novamente!',
quickCommandHelper: 'Você pode usar os comandos rápidos na parte inferior de "Terminais -> Terminais".',
groupDeleteHelper:
'Após o grupo ser removido, todas as conexões no grupo serão migradas para o grupo padrão. Você deseja continuar?',

View File

@@ -1170,6 +1170,7 @@ const message = {
fold: 'Свернуть все',
batchInput: 'Пакетная обработка',
quickCommand: 'Быстрая команда | Быстрые команды',
noSuchCommand: 'В импортированном CSV-файле не найдены данные быстрых команд, проверьте и повторите попытку!',
quickCommandHelper: 'Вы можете использовать быстрые команды внизу страницы "Терминалы -> Терминалы".',
groupDeleteHelper:
'После удаления группы все подключения в группе будут перемещены в группу по умолчанию. Хотите продолжить?',

View File

@@ -1196,6 +1196,8 @@ const message = {
fold: 'Tümünü daralt',
batchInput: 'Toplu işleme',
quickCommand: 'Hızlı komut | Hızlı komutlar',
noSuchCommand:
'İçe aktarılan CSV dosyasında hızlı komut verisi bulunamadı, lütfen kontrol edip tekrar deneyin!',
quickCommandHelper: '"Terminaller -> Terminaller" altındaki hızlı komutları kullanabilirsiniz.',
groupDeleteHelper:
'Grup kaldırıldıktan sonra, gruptaki tüm bağlantılar varsayılan gruba taşınacaktır. Devam etmek istiyor musunuz?',

View File

@@ -1128,6 +1128,7 @@ const message = {
fold: '全部收縮',
batchInput: '批次輸入',
quickCommand: '快速指令',
noSuchCommand: '導入的CSV文件中未能發現快速命令數據請檢查後重試',
quickCommandHelper: '常用命令列表用於在終端介面底部快速選擇',
groupDeleteHelper: '移除組後組內所有連接將遷移到 default 組內是否繼續',
command: '指令',

View File

@@ -1128,6 +1128,7 @@ const message = {
fold: '全部收缩',
batchInput: '批量输入',
quickCommand: '快速命令',
noSuchCommand: '导入的 csv 文件中未能发现快速命令数据请检查后重试',
quickCommandHelper: '常用命令列表用于在终端界面底部快速选择',
groupDeleteHelper: '移除组后组内所有连接将迁移到 default 组内是否继续',
command: '命令',

View File

@@ -0,0 +1,175 @@
<template>
<DialogPro v-model="visible" :title="$t('commons.button.import')" size="large">
<div>
<el-upload
action="#"
:auto-upload="false"
ref="uploadRef"
class="float-left mt-2"
:show-file-list="false"
:limit="1"
accept=".csv"
:on-change="fileOnChange"
:on-exceed="handleExceed"
v-model:file-list="uploaderFiles"
>
<el-button class="float-left" type="primary">{{ $t('commons.button.upload') }}</el-button>
</el-upload>
<el-button :disabled="selects.length === 0" @click="onImport" class="ml-2 mt-2">
{{ $t('commons.button.import') }}
</el-button>
<el-select
filterable
:placeholder="$t('terminal.groupChange')"
v-model="currentGroup"
@change="changeGroup"
class="p-w-200 ml-2 mt-2"
>
<div v-for="item in groupList" :key="item.id">
<el-option v-if="item.name === 'Default'" :label="$t('commons.table.default')" :value="item.id" />
<el-option v-else :label="item.name" :value="item.id" />
</div>
</el-select>
<el-card class="mt-2 w-full" v-loading="loading">
<el-table :data="data" @selection-change="handleSelectionChange">
<el-table-column type="selection" fix />
<el-table-column
:label="$t('commons.table.name')"
:min-width="80"
prop="name"
show-overflow-tooltip
/>
<el-table-column
:label="$t('commons.table.group')"
show-overflow-tooltip
min-width="80"
prop="groupBelong"
fix
>
<template #default="{ row }">
<span v-if="row.groupBelong === 'Default'">{{ $t('commons.table.default') }}</span>
<span v-else>{{ row.groupBelong }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('terminal.command')"
:min-width="120"
prop="command"
show-overflow-tooltip
/>
</el-table>
</el-card>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">
{{ $t('commons.button.cancel') }}
</el-button>
</span>
</template>
</DialogPro>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { genFileId, UploadFile, UploadFiles, UploadProps, UploadRawFile } from 'element-plus';
import { importCommands, uploadCommands } from '@/api/modules/command';
import { getGroupList } from '@/api/modules/group';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const emit = defineEmits<{ (e: 'search'): void }>();
const visible = ref(false);
const loading = ref();
const selects = ref<any>([]);
const data = ref([]);
const uploadRef = ref();
const uploaderFiles = ref();
const currentGroup = ref();
const groupList = ref();
const acceptParams = (): void => {
visible.value = true;
loadGroups();
data.value = [];
};
const loadGroups = async () => {
const res = await getGroupList('command');
groupList.value = res.data || [];
};
const changeGroup = () => {
let itemGroup;
for (const g of groupList.value) {
if (g.id === currentGroup.value) {
itemGroup = g;
break;
}
}
for (const item of data.value) {
item.groupID = currentGroup.value;
item.groupBelong = itemGroup.name;
}
};
const handleSelectionChange = (val: any) => {
selects.value = val;
};
const fileOnChange = async (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
uploaderFiles.value = uploadFiles;
if (uploaderFiles.value.length !== 1) {
return;
}
const file = uploaderFiles.value[0];
const formData = new FormData();
formData.append('file', file.raw);
loading.value = true;
await uploadCommands(formData)
.then((res) => {
loading.value = false;
uploadRef.value!.clearFiles();
uploaderFiles.value = [];
data.value = res.data || [];
if (data.value.length === 0) {
MsgError(i18n.global.t('terminal.noSuchCommand'));
}
})
.catch(() => {
loading.value = false;
uploadRef.value!.clearFiles();
uploaderFiles.value = [];
});
};
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
};
const onImport = async () => {
loading.value = true;
importCommands(selects.value)
.then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
loading.value = false;
emit('search');
visible.value = false;
})
.catch(() => {
loading.value = false;
});
};
defineExpose({
acceptParams,
});
</script>

View File

@@ -14,6 +14,15 @@
<el-button type="primary" plain :disabled="selects.length === 0" @click="batchDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
<el-button-group>
<el-button @click="onImport">
{{ $t('commons.button.import') }}
</el-button>
<el-button @click="onExport">
{{ $t('commons.button.export') }}
</el-button>
</el-button-group>
</template>
<template #rightToolBar>
<el-select v-model="group" @change="search()" clearable class="p-w-200">
@@ -85,6 +94,7 @@
<OpDialog ref="opRef" @search="search" />
<OperateDialog @search="search" ref="dialogRef" />
<ImportDialog @search="search" ref="importDialogRef" />
<GroupDialog @search="loadGroups" ref="dialogGroupRef" />
</div>
</template>
@@ -93,11 +103,13 @@
import { Command } from '@/api/interface/command';
import GroupDialog from '@/components/group/index.vue';
import OperateDialog from '@/views/terminal/command/operate/index.vue';
import { editCommand, deleteCommand, getCommandPage } from '@/api/modules/command';
import ImportDialog from '@/views/terminal/command/import/index.vue';
import { editCommand, deleteCommand, getCommandPage, exportCommands } from '@/api/modules/command';
import { reactive, ref } from 'vue';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { getGroupList } from '@/api/modules/group';
import { downloadFile } from '@/utils/util';
const loading = ref();
const data = ref();
@@ -115,6 +127,7 @@ const info = ref();
const group = ref<string>('');
const dialogRef = ref();
const opRef = ref();
const importDialogRef = ref();
const acceptParams = () => {
search();
@@ -150,6 +163,24 @@ const updateGroup = async (row: any) => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
};
const onExport = async () => {
loading.value = true;
await exportCommands()
.then((res) => {
if (res.data) {
loading.value = false;
downloadFile(res.data, 'local');
}
})
.catch(() => {
loading.value = false;
});
};
const onImport = () => {
importDialogRef.value.acceptParams();
};
const batchDelete = async (row: Command.CommandInfo | null) => {
let names = [];
let ids = [];