支持实例从csv&json导入

This commit is contained in:
chaos-zhu 2024-07-29 11:48:00 +08:00
parent a95caf6d2f
commit ac7f4eb509
9 changed files with 440 additions and 74 deletions

View File

@ -86,38 +86,38 @@ async function removeHost({
if (hostIdx === -1) return res.fail({ msg: `${ host }不存在` })
hostList.splice(hostIdx, 1)
writeHostList(hostList)
// 查询是否存在ssh记录
// let sshRecord = await readSSHRecord()
// let sshIdx = sshRecord.findIndex(item => item.host === host)
// let flag = sshIdx !== -1
// if (flag) sshRecord.splice(sshIdx, 1)
// writeSSHRecord(sshRecord)
// res.success({ data: `${ host }已移除, ${ flag ? '并移除ssh记录' : '' }` })
res.success({ data: `${ host }已移除` })
}
// 原手动排序接口-废弃
// async function updateHostSort({ res, request }) {
// let { body: { list } } = request
// if (!list) return res.fail({ msg: '参数错误' })
// let hostList = await readHostList()
// if (hostList.length !== list.length) return res.fail({ msg: '失败: host数量不匹配' })
// let sortResult = []
// for (let i = 0; i < list.length; i++) {
// const curHost = list[i]
// let temp = hostList.find(({ host }) => curHost.host === host)
// if (!temp) return res.fail({ msg: `查找失败: ${ curHost.name }` })
// sortResult.push(temp)
// }
// writeHostList(sortResult)
// res.success({ msg: 'success' })
// }
async function importHost({
res, request
}) {
let { body: { importHost } } = request
if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
let hostList = await readHostList()
// 过滤已存在的host
let hostListSet = new Set(hostList.map(item => item.host))
let newHostList = importHost.filter(item => !hostListSet.has(item.host))
if (newHostList.length === 0) return res.fail({ msg: '导入的实例已存在' })
let extraFiels = {
expired: null, expiredNotify: false, group: 'default', consoleUrl: '', remark: '',
authType: 'privateKey', password: '', privateKey: '', credential: '', command: ''
}
newHostList = newHostList.map((item, index) => {
item.port = Number(item.port) || 0
item.index = hostList.length + index + 1
return Object.assign(item, { ...extraFiels })
})
hostList.push(...newHostList)
writeHostList(hostList)
res.success({ data: { len: newHostList.length } })
}
module.exports = {
getHostList,
addHost,
updateHost,
removeHost
// updateHostSort
removeHost,
importHost
}

View File

@ -1,5 +1,5 @@
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh')
const { getHostList, addHost, updateHost, removeHost } = require('../controller/host')
const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controller/user')
const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
@ -51,12 +51,12 @@ const host = [
method: 'post',
path: '/host-remove',
controller: removeHost
},
{
method: 'post',
path: '/import-host',
controller: importHost
}
// {
// method: 'put',
// path: '/host-sort',
// controller: updateHostSort
// }
]
const user = [
{

View File

@ -4,7 +4,7 @@
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes">
<title>EasyNode</title>
<script src="//at.alicdn.com/t/c/font_3309550_ezojxo1wj3.js"></script>
<script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script>
</head>
<body>

View File

@ -32,6 +32,7 @@
"axios": "^1.7.2",
"codemirror": "^6.0.1",
"crypto-js": "^4.2.0",
"csv-parse": "^5.5.6",
"element-plus": "^2.7.6",
"jsencrypt": "^3.3.2",
"pinia": "^2.1.7",

View File

@ -37,6 +37,9 @@ export default {
removeHost(data) {
return axios({ url: '/host-remove', method: 'post', data })
},
importHost(data) {
return axios({ url: '/import-host', method: 'post', data })
},
getPubPem() {
return axios({ url: '/get-pub-pem', method: 'get' })
},

View File

@ -1,28 +1,78 @@
<template>
<el-dialog v-model="visible" width="600px" top="45px" modal-class="host_form_dialog" append-to-body :title="title"
:close-on-click-modal="false" @open="setDefaultData" @closed="handleClosed">
<el-form ref="formRef" :model="hostForm" :rules="rules" :hide-required-asterisk="true" label-suffix=""
label-width="100px" :show-message="false">
<el-dialog
v-model="visible"
width="600px"
top="45px"
modal-class="host_form_dialog"
append-to-body
:title="title"
:close-on-click-modal="false"
@open="setDefaultData"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="hostForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="100px"
:show-message="false"
>
<transition-group name="list" mode="out-in" tag="div">
<el-form-item key="group" label="分组" prop="group">
<el-select v-model="hostForm.group" placeholder="实例分组" style="width: 100%;">
<el-option v-for="item in groupList" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in groupList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item key="name" label="名称" prop="name">
<el-input v-model.trim="hostForm.name" clearable placeholder="" autocomplete="off" />
<el-input
v-model.trim="hostForm.name"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<div key="instance_info" class="instance_info">
<el-form-item key="host" class="form_item_host" label="主机" prop="host">
<el-input v-model.trim="hostForm.host" clearable placeholder="IP" autocomplete="off" />
<el-form-item
key="host"
class="form_item_host"
label="主机"
prop="host"
>
<el-input
v-model.trim="hostForm.host"
clearable
placeholder="IP"
autocomplete="off"
/>
</el-form-item>
<el-form-item key="port" class="form_item_port" label="端口" prop="port">
<el-input v-model.trim.number="hostForm.port" clearable placeholder="port" autocomplete="off" />
<el-form-item
key="port"
class="form_item_port"
label="端口"
prop="port"
>
<el-input
v-model.trim.number="hostForm.port"
clearable
placeholder="port"
autocomplete="off"
/>
</el-form-item>
</div>
<el-form-item key="username" label="用户名" prop="username">
<el-autocomplete v-model.trim="hostForm.username" :fetch-suggestions="userSearch" style="width: 100%;"
clearable>
<el-autocomplete
v-model.trim="hostForm.username"
:fetch-suggestions="userSearch"
style="width: 100%;"
clearable
>
<template #default="{ item }">
<div class="value">{{ item.value }}</div>
</template>
@ -33,23 +83,56 @@
<el-radio v-model.trim="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model.trim="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item>
<el-form-item v-if="hostForm.authType === 'privateKey'" key="privateKey" prop="privateKey" label="密钥">
<el-form-item
v-if="hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
>
<el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥...
</el-button>
<!-- <el-button type="primary" size="small" @click="handleClickUploadBtn">
从凭据导入...
</el-button> -->
<input ref="privateKeyRef" type="file" name="privateKey" style="display: none;"
@change="handleSelectPrivateKeyFile">
<el-input v-model.trim="hostForm.privateKey" type="textarea" :rows="5" clearable autocomplete="off"
style="margin-top: 5px;" placeholder="-----BEGIN RSA PRIVATE KEY-----" />
<input
ref="privateKeyRef"
type="file"
name="privateKey"
style="display: none;"
@change="handleSelectPrivateKeyFile"
>
<el-input
v-model.trim="hostForm.privateKey"
type="textarea"
:rows="5"
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item>
<el-form-item v-if="hostForm.authType === 'password'" key="password" prop="password" label="密码">
<el-input v-model.trim="hostForm.password" type="password" placeholder="" autocomplete="off" clearable
show-password />
<el-form-item
v-if="hostForm.authType === 'password'"
key="password"
prop="password"
label="密码"
>
<el-input
v-model.trim="hostForm.password"
type="password"
placeholder=""
autocomplete="off"
clearable
show-password
/>
</el-form-item>
<el-form-item v-if="hostForm.authType === 'credential'" key="credential" prop="credential" label="凭据">
<el-form-item
v-if="hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
>
<el-select v-model="hostForm.credential" class="credential_select" placeholder="">
<template #empty>
<div class="empty_credential">
@ -59,7 +142,12 @@
</el-button>
</div>
</template>
<el-option v-for="item in sshList" :key="item.id" :label="item.name" :value="item.id">
<el-option
v-for="item in sshList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="auth_type_wrap">
<span>{{ item.name }}</span>
<span class="auth_type_text">
@ -70,28 +158,61 @@
</el-select>
</el-form-item>
<el-form-item key="command" prop="command" label="执行指令">
<el-input v-model="hostForm.command" type="textarea" :rows="5" clearable autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)" />
<el-input
v-model="hostForm.command"
type="textarea"
:rows="5"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
/>
</el-form-item>
<el-form-item key="expired" label="到期时间" prop="expired">
<el-date-picker v-model="hostForm.expired" type="date" style="width: 100%;" value-format="x"
placeholder="实例到期时间" />
<el-date-picker
v-model="hostForm.expired"
type="date"
style="width: 100%;"
value-format="x"
placeholder="实例到期时间"
/>
</el-form-item>
<el-form-item v-if="hostForm.expired" key="expiredNotify" label="到期提醒" prop="expiredNotify">
<el-form-item
v-if="hostForm.expired"
key="expiredNotify"
label="到期提醒"
prop="expiredNotify"
>
<el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
<el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" />
</el-tooltip>
</el-form-item>
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
<el-input v-model.trim="hostForm.consoleUrl" clearable placeholder="用于直达云服务商控制台" autocomplete="off"
@keyup.enter="handleSave" />
<el-input
v-model.trim="hostForm.consoleUrl"
clearable
placeholder="用于直达云服务商控制台"
autocomplete="off"
@keyup.enter="handleSave"
/>
</el-form-item>
<el-form-item key="index" label="序号" prop="index">
<el-input v-model.trim.number="hostForm.index" clearable placeholder="用于实例列表中排序(填写数字)" autocomplete="off" />
<el-input
v-model.trim.number="hostForm.index"
clearable
placeholder="用于实例列表中排序(填写数字)"
autocomplete="off"
/>
</el-form-item>
<el-form-item key="remark" label="备注" prop="remark">
<el-input v-model.trim="hostForm.remark" type="textarea" :rows="3" clearable autocomplete="off"
placeholder="简单记录实例用途" />
<el-input
v-model.trim="hostForm.remark"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="简单记录实例用途"
/>
</el-form-item>
</transition-group>
</el-form>
@ -195,7 +316,7 @@ const handleSelectPrivateKeyFile = (event) => {
let reader = new FileReader()
reader.onload = (e) => {
hostForm.privateKey = e.target.result
privateKeyRef.value.value = ''
privateKeyRef.value = ''
}
reader.readAsText(file)
}

View File

@ -0,0 +1,221 @@
<template>
<el-dialog
v-model="visible"
width="600px"
top="225px"
modal-class="import_form_dialog"
append-to-body
title="导入实例配置"
:close-on-click-modal="false"
>
<h2>选择要导入的文件类型</h2>
<ul class="type_list">
<li @click="handleFromCsv">
<svg-icon name="icon-csv" class="icon" />
<span class="from">Xshell</span>
<span class="type">(csv)</span>
<input
ref="csvInputRef"
type="file"
accept=".csv"
multiple
name="csvInput"
style="display: none;"
@change="handleCsvFile"
>
</li>
<li @click="handleFromJson">
<svg-icon name="icon-json" class="icon" />
<span class="from">FinalShell</span>
<span class="type">(json)</span>
<input
ref="jsonInputRef"
type="file"
accept=".json"
multiple
name="jsonInput"
style="display: none;"
@change="handleJsonFile"
>
</li>
</ul>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, getCurrentInstance, nextTick } from 'vue'
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
import { parse } from 'csv-parse/browser/esm/sync'
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
const props = defineProps({
show: {
required: true,
type: Boolean
}
})
const emit = defineEmits(['update:show', 'update-list',])
const jsonInputRef = ref(null)
const csvInputRef = ref(null)
let visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
function handleFromCsv() {
csvInputRef.value.click()
}
function handleFromJson() {
jsonInputRef.value.click()
}
const handleCsvFile = (event) => {
const files = event.target.files
if (!files.length) {
console.warn('No files selected')
return
}
const csvFiles = [...files,].filter(file => file.type === 'text/csv')
if (csvFiles.length === 0) return $message.warning('未选择有效的CSV文件')
let readerPromises = csvFiles.map(file => {
return new Promise((resolve, reject) => {
let reader = new FileReader()
reader.onload = (e) => {
const csvText = e.target.result
try {
const jsonContents = parse(csvText, {
columns: ['name', 'protocol', 'host', 'port', 'username', 'placeholder',],
from_line: 1 // xshell
})
handleImportHost(jsonContents)
} catch (error) {
console.error(`Error parsing CSV file ${ file.name }:`, error)
}
}
reader.onerror = () => {
reject(new Error(`Failed to read file: ${ file.name }`))
}
reader.readAsText(file)
})
})
Promise.all(readerPromises)
.then(jsonContents => {
let formatJson = jsonContents.map(item => {
const { name, host, port, user_name: username } = item
return { name, host, port, username }
})
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
console.error('导入失败: ', error)
})
}
const handleJsonFile = (event) => {
let files = event.target.files
let jsonFiles = Array.from(files).filter(file => file.type === 'application/json')
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
let readerPromises = jsonFiles.map(file => {
return new Promise((resolve, reject) => {
let reader = new FileReader()
reader.onload = (e) => {
try {
let jsonContent = JSON.parse(e.target.result)
resolve(jsonContent)
} catch (error) {
reject(new Error(`Failed to parse JSON file: ${ file.name }`))
}
}
reader.onerror = () => {
reject(new Error(`Failed to read file: ${ file.name }`))
}
reader.readAsText(file)
})
})
Promise.all(readerPromises)
.then(jsonContents => {
let formatJson = jsonContents.map(item => {
const { name, host, port, user_name: username } = item
return { name, host, port, username }
})
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
console.error('导入失败: ', error)
})
}
async function handleImportHost(importHost) {
// console.log(': ', importHost)
try {
let { data: { len } } = await $api.importHost({ importHost })
$message({ type: 'success', center: true, message: `成功导入实例: ${ len }` })
emit('update-list')
visible.value = false
} catch (error) {
$message.error('导入失败:', error.message)
}
}
</script>
<style lang="scss">
.import_form_dialog {
h2 {
font-size: 14px;
font-weight: 600;
text-align: center;
margin: 15px 0 25px 0;
}
.type_list {
display: flex;
align-items: center;
justify-content: center;
user-select: none;
li {
margin: 0 25px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 150px;
height: 150px;
cursor: pointer;
border-radius: 3px;
&:hover {
background-color: #f1f2f5;
color: var(--el-menu-active-color);
}
.icon {
width: 35px;
height: 35px;
}
span {
display: inline-block;
}
.from {
font-size: 14px;
margin: 15px 0;
}
.type {
font-size: 12px;
}
}
}
}
.dialog_footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -5,11 +5,14 @@
<el-button type="primary" @click="handleHiddenIP">
{{ hiddenIp ? '显示IP' : '隐藏IP' }}
</el-button>
<el-button type="primary" @click="importVisible = true">导入实例</el-button>
</div>
<div class="server_group_collapse">
<div v-if="isNoHost">
<el-empty description="暂无实例">
<el-button type="primary" @click="hostFormVisible = true">添加第一台实例配置</el-button>
<el-button type="primary" @click="hostFormVisible = true">添加实例配置</el-button>
<span class="or"></span>
<el-button type="primary" @click="importVisible = true">批量导入实例</el-button>
</el-empty>
</div>
<el-collapse v-else v-model="activeGroup">
@ -38,6 +41,10 @@
@update-list="handleUpdateList"
@closed="updateHostData = null"
/>
<ImportHost
v-model:show="importVisible"
@update-list="handleUpdateList"
/>
</div>
</template>
@ -45,15 +52,18 @@
import { ref, getCurrentInstance, computed, watch } from 'vue'
import HostCard from './components/host-card.vue'
import HostForm from './components/host-form.vue'
import ImportHost from './components/import-host.vue'
const { proxy: { $store, $message } } = getCurrentInstance()
const updateHostData = ref(null)
const hostFormVisible = ref(false)
const hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
const activeGroup = ref([])
let updateHostData = ref(null)
let hostFormVisible = ref(false)
let importVisible = ref(false)
const handleUpdateList = async () => {
let hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
let activeGroup = ref([])
let handleUpdateList = async () => {
try {
await $store.getHostList()
} catch (err) {
@ -62,12 +72,12 @@ const handleUpdateList = async () => {
}
}
const handleUpdateHost = (defaultData) => {
let handleUpdateHost = (defaultData) => {
hostFormVisible.value = true
updateHostData.value = defaultData
}
const handleHiddenIP = () => {
let handleHiddenIP = () => {
hiddenIp.value = hiddenIp.value ? 0 : 1
localStorage.setItem('hiddenIp', String(hiddenIp.value))
}
@ -124,6 +134,11 @@ let isNoHost = computed(() => Object.keys(groupHostList.value).length === 0)
.host_card_container {
padding-top: 25px;
}
.or {
color: var(--el-text-color-secondary);
font-size: var(--el-font-size-base);
margin: 0 25px;
}
}
}
</style>

View File

@ -1909,6 +1909,11 @@ csstype@^3.1.3:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
csv-parse@^5.5.6:
version "5.5.6"
resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a"
integrity sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==
date-fns@^2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"