面板UI改造工程

This commit is contained in:
chaoszhu 2024-07-18 18:16:45 +08:00
parent 7b8014c36b
commit 483f17a591
33 changed files with 3172 additions and 20 deletions

View File

@ -15,4 +15,8 @@
}
.el-tabs__content {
padding: 0 10px;
}
}
// :root {
// --active-color: red;
// }

View File

@ -34,7 +34,7 @@ body {
// background-size: cover;
// background-repeat: no-repeat;
// // background-image: url(../bg.jpg), linear-gradient(to bottom, #010179, #F5C4C1, #151799);
background-color: #f4f6f9;
background-color: #E7EBF4;
background-image: url(https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg);
background-repeat: no-repeat;
background-position: center 110px;
@ -42,7 +42,7 @@ body {
}
html, body {
min-width: 1200px;
height: 100vh;
overflow: hidden;
// min-width: 1200px;
// height: 100vh;
// overflow: hidden;
}

View File

@ -0,0 +1,138 @@
<template>
<div class="aside_container">
<div class="logo_wrap">
<img src="@/assets/logo.png" alt="logo">
<h1>EasyNode</h1>
</div>
<el-menu
:default-active="defaultActiveMenu"
class="menu"
@select="handleSelect"
>
<el-menu-item v-for="(item, index) in menuList" :key="index" :index="item.index">
<template #title>
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.name }}</span>
</template>
</el-menu-item>
</el-menu>
<!-- <div class="logout_wrap">
<el-button type="info" link @click="handleLogout">退出登录</el-button>
</div> -->
</div>
</template>
<script setup>
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
import {
Menu as IconMenu,
Key,
Setting,
ScaleToOriginal,
ArrowRight,
Pointer,
FolderOpened
} from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const { proxy: { $router, $route, $store, $message } } = getCurrentInstance()
let menuList = reactive([
{
name: '实例配置',
icon: markRaw(IconMenu),
index: '/server'
},
{
name: '连接终端',
icon: markRaw(ScaleToOriginal),
index: '/terminal'
},
{
name: '凭据管理',
icon: markRaw(Key),
index: '/credentials'
},
{
name: '分组管理',
icon: markRaw(FolderOpened),
index: '/group'
},
{
name: '一键指令',
icon: markRaw(Pointer),
index: '/onekey'
},
{
name: '脚本库',
icon: markRaw(ArrowRight),
index: '/scripts'
},
{
name: '系统设置',
icon: markRaw(Setting),
index: '/setting'
},
])
// eslint-disable-next-line no-useless-escape
const regex = /^\/([^\/]+)/
let defaultActiveMenu = computed(() => {
const match = route.path.match(regex)
return match[0]
})
watchEffect(() => {
let idx = route.path.match(regex)[0]
let targetRoute = menuList.find(item => item.index === idx)
$store.setTitle(targetRoute?.name || '')
})
const handleSelect = (path) => {
// console.log(path)
$router.push(path)
}
</script>
<style lang="scss" scoped>
.aside_container {
background-color: #fff;
border-right: 1px solid var(--el-menu-border-color);
width: 180px;
display: flex;
flex-direction: column;
:deep(.el-menu) {
border-right: none;
}
.logo_wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 15px 0;
img {
height: 30px;
width: 30px;
}
h1 {
color: #1890ff;
font-size: 16px;
margin: 0 5px;
font-weight: 600;
font-size: 16px;
vertical-align: middle;
}
}
.logout_wrap {
margin-top: auto;
display: flex;
// justify-content: center;
align-items: center;
padding: 15px 0;
margin-left: 20px;
}
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<div class="top_bar_container">
<div class="bar_wrap">
<h2>{{ title }}</h2>
<!-- <el-icon><UserFilled /></el-icon> -->
<el-popover placement="bottom" trigger="hover">
<template #reference>
<div class="right_wrap">
<el-icon><User /></el-icon>
<span>{{ user }}</span>
</div>
</template>
<el-button
type="primary"
class="logout_btn"
link
@click="handleLogout"
>
安全退出
</el-button>
</el-popover>
</div>
</div>
</template>
<script setup>
import { getCurrentInstance, computed } from 'vue'
import { User } from '@element-plus/icons-vue'
const { proxy: { $router, $store, $message } } = getCurrentInstance()
let user = computed(() => {
return $store.user
})
let title = computed(() => {
return $store.title
})
const handleLogout = () => {
$store.clearJwtToken()
$message({ type: 'success', message: '已安全退出', center: true })
$router.push('/login')
}
</script>
<style lang="scss" scoped>
.top_bar_container {
height: 60px;
background-color: #fff;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 999;
.bar_wrap {
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
h2 {
font-size: 18px;
}
.right_wrap {
display: flex;
align-items: center;
// color: red;
color: var(--el-menu-text-color);
span {
margin-left: 3px;
}
.logout_btn {
margin: 0 10px 0 15px;
}
// color: var(--el-button-text-color);
}
}
}
</style>

View File

@ -6,7 +6,7 @@ import tools from './plugins/tools'
import elementPlugins from './plugins/element'
import globalComponents from './plugins/components'
import api from './api'
import App from './App.vue'
import App from './app.vue'
import './assets/scss/reset.scss'
import './assets/scss/global.scss'
import './assets/scss/element-ui.scss'

View File

@ -1,18 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
// import hostList from '@views/list/index.vue'
// import login from '@views/login/index.vue'
// import HostList from '@views/list/index.vue'
// import Login from '@views/login/index.vue'
// import terminal from '@views/terminal/index.vue'
// import test from '@views/test/index.vue'
const hostList = () => import('@views/list/index.vue')
const login = () => import('@views/login/index.vue')
const terminal = () => import('@views/terminal/index.vue')
const HostList = () => import('@views/list/index.vue')
const Login = () => import('@views/login/index.vue')
const Container = () => import('@views/index.vue')
const Server = () => import('@views/server/index.vue')
const Terminal = () => import('@views/terminal/index.vue')
const Credentials = () => import('@views/credentials/index.vue')
const Group = () => import('@views/group/index.vue')
const Onekey = () => import('@views/onekey/index.vue')
const Scripts = () => import('@views/scripts/index.vue')
const Setting = () => import('@views/setting/index.vue')
const routes = [
{ path: '/', component: hostList },
{ path: '/login', component: login },
{ path: '/terminal', component: terminal },
{ path: '/login', component: Login },
{
path: '/',
component: Container,
children: [
{ path: '/server', component: Server },
{ path: '/terminal', component: Terminal },
{ path: '/credentials', component: Credentials },
{ path: '/group', component: Group },
{ path: '/onekey', component: Onekey },
{ path: '/scripts', component: Scripts },
{ path: '/setting', component: Setting },
{ path: '', redirect: 'server' }, // 这里添加重定向
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/server'
},
// { path: '/server', component: Server },
// { path: '/', component: HostList },
// { path: '/terminal', component: Terminal },
// { path: '/test', component: test },
]

View File

@ -6,7 +6,10 @@ const useStore = defineStore({
id: 'global',
state: () => ({
hostList: [],
token: sessionStorage.getItem('token') || localStorage.getItem('token') || null
groupList: [],
user: localStorage.getItem('user') || null,
token: sessionStorage.getItem('token') || localStorage.getItem('token') || null,
title: ''
}),
actions: {
async setJwtToken(token, isSession = true) {
@ -14,16 +17,24 @@ const useStore = defineStore({
else localStorage.setItem('token', token)
this.$patch({ token })
},
async setUser(username) {
localStorage.setItem('user', username)
this.$patch({ user: username })
},
async setTitle(title) {
this.$patch({ title })
},
async clearJwtToken() {
localStorage.clear('token')
sessionStorage.clear('token')
this.$patch({ token: null })
},
async getHostList() {
const { data: groupList } = await $api.getGroupList()
const { data: hostList } = await $api.getHostList()
this.$patch({ hostList })
// console.log('pinia: ', this.hostList)
// this.getHostPing()
// console.log('hostList:', hostList)
// console.log('groupList:', groupList)
this.$patch({ hostList, groupList })
},
getHostPing() {
setTimeout(() => {

View File

@ -0,0 +1,19 @@
<template>
<div class="">
credentials
</div>
</template>
<script>
export default {
name: '',
data() {
return {
}
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,245 @@
<template>
<div class="group_container">
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="rules"
:inline="true"
:hide-required-asterisk="true"
label-suffix=""
>
<el-form-item label="" prop="name" style="width: 200px;">
<el-input
v-model.trim="groupForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="" prop="index" style="width: 200px;">
<el-input
v-model.number="groupForm.index"
clearable
placeholder="序号(数字, 用于分组排序)"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="">
<el-button type="primary" @click="addGroup">
添加
</el-button>
</el-form-item>
</el-form>
<!-- 提示 -->
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;">
Tips: 已添加服务器数量 <u>{{ hostGroupInfo.total }}</u>
<span v-show="hostGroupInfo.notGroupCount">, <u>{{ hostGroupInfo.notGroupCount }}</u> 台服务器尚未分组</span>
</span>
</template>
</el-alert><br>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 删除分组会将分组内所有服务器移至默认分组 </span>
</template>
</el-alert>
<el-table v-loading="loading" :data="list">
<el-table-column prop="index" label="序号" />
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="分组名称" />
<el-table-column label="关联服务器数量">
<template #default="{ row }">
<el-popover
v-if="row.hosts.list.length !== 0"
placement="left"
:width="350"
trigger="hover"
>
<template #reference>
<u class="host_count">{{ row.hosts.count }}</u>
</template>
<ul>
<li v-for="item in row.hosts.list" :key="item.host">
<span>{{ item.host }}</span>
-
<span>{{ item.name }}</span>
</li>
</ul>
</el-popover>
<u v-else class="host_count">0</u>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="visible"
width="400px"
title="修改分组"
:close-on-click-modal="false"
>
<el-form
ref="updateFormRef"
:model="updateForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model.trim="updateForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="分组序号" prop="index">
<el-input
v-model.number="updateForm.index"
clearable
placeholder="分组序号"
autocomplete="off"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="updateGroup">修改</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const loading = ref(false)
const visible = ref(false)
const groupList = ref([])
const groupForm = reactive({
name: '',
index: ''
})
const updateForm = reactive({
name: '',
index: ''
})
const rules = reactive({
name: { required: true, message: '需输入分组名称', trigger: 'change' },
index: { required: true, type: 'number', message: '需输入数字', trigger: 'change' }
})
const groupFormRef = ref(null)
const updateFormRef = ref(null)
const hostGroupInfo = computed(() => {
const total = $store.hostList.length
const notGroupCount = $store.hostList.reduce((prev, next) => {
if (!next.group) prev++
return prev
}, 0)
return { total, notGroupCount }
})
const list = computed(() => {
return groupList.value.map(item => {
const hosts = $store.hostList.reduce((prev, next) => {
if (next.group === item.id) {
prev.count++
prev.list.push(next)
}
return prev
}, { count: 0, list: [] })
return { ...item, hosts }
})
})
const getGroupList = () => {
loading.value = true
$api.getGroupList()
.then(({ data }) => {
groupList.value = data
groupForm.index = data.length
})
.finally(() => loading.value = false)
}
const addGroup = () => {
groupFormRef.value.validate()
.then(() => {
const { name, index } = groupForm
$api.addGroup({ name, index })
.then(() => {
$message.success('success')
groupForm.name = ''
groupForm.index = ''
getGroupList()
})
})
}
const handleChange = ({ id, name, index }) => {
updateForm.id = id
updateForm.name = name
updateForm.index = index
visible.value = true
}
const updateGroup = () => {
updateFormRef.value.validate()
.then(() => {
const { id, name, index } = updateForm
$api.updateGroup(id, { name, index })
.then(() => {
$message.success('success')
visible.value = false
getGroupList()
})
})
}
const deleteGroup = ({ id, name }) => {
$messageBox.confirm(`确认删除分组:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await $api.deleteGroup(id)
await $store.getHostList()
$message.success('success')
getGroupList()
})
}
onMounted(() => {
getGroupList()
})
</script>
<style lang="scss" scoped>
.group_container {
padding: 20px;
}
.host_count {
display: block;
width: 100px;
text-align: center;
font-size: 15px;
color: #87cf63;
cursor: pointer;
}
</style>

35
web/src/views/index.vue Normal file
View File

@ -0,0 +1,35 @@
<template>
<div class="view_container">
<AsideBox />
<div class="main_container">
<TopBar />
<router-view v-slot="{ Component }" class="router_box">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</template>
<script setup>
import AsideBox from '@/components/aside-box.vue'
import TopBar from '@/components/top-bar.vue'
</script>
<style lang="scss" scoped>
.view_container {
display: flex;
height: 100vh;
.main_container {
flex: 1;
height: 100vh;
overflow: auto;
.router_box {
min-height: calc(100vh - 60px - 20px);
background-color: #fff;
margin: 10px;
}
}
}
</style>

View File

@ -56,7 +56,7 @@
import { ref, reactive, getCurrentInstance } from 'vue'
import { RSAEncrypt } from '@utils/index.js'
const { proxy: { $api, $message } } = getCurrentInstance()
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const loading = ref(false)
const formRef = ref(null)
@ -81,6 +81,7 @@ const handleUpdate = () => {
newPwd = RSAEncrypt(newPwd)
let { msg } = await $api.updatePwd({ oldLoginName, oldPwd, newLoginName, newPwd })
$message({ type: 'success', center: true, message: msg })
$store.setUser(newLoginName)
formData.oldLoginName = ''
formData.oldPwd = ''
formData.newLoginName = ''

View File

@ -113,6 +113,7 @@ const handleLogin = () => {
.then(({ data, msg }) => {
const { token } = data
$store.setJwtToken(token, isSession.value)
$store.setUser(loginName)
$message.success({ message: msg || 'success', center: true })
$router.push('/')
})
@ -140,9 +141,10 @@ onMounted(async () => {
justify-content: center;
align-items: center;
background: rgba(171, 181, 196, 0.3); // #f0f2f5;
padding-top: 70px;
padding-top: 1px;
.login_box {
margin-top: -80px;
width: 500px;
min-height: 250px;
padding: 20px;

View File

@ -0,0 +1,19 @@
<template>
<div class="">
开发中...
</div>
</template>
<script>
export default {
name: '',
data() {
return {
}
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,19 @@
<template>
<div class="">
开发中...
</div>
</template>
<script>
export default {
name: '',
data() {
return {
}
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,390 @@
<template>
<el-card shadow="always" class="host-card">
<div class="host-state">
<span v-if="isError" class="offline">未连接</span>
<span v-else class="online">已连接 {{ ping }}</span>
</div>
<div class="info">
<div class="weizhi field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-fuwuqi" class="svg-icon" />
</template>
<div class="field-detail">
<h2>系统</h2>
<h3><span>名称:</span> {{ osInfo.hostname }}</h3>
<h3><span>类型:</span> {{ osInfo.type }}</h3>
<h3><span>架构:</span> {{ osInfo.arch }}</h3>
<h3><span>平台:</span> {{ osInfo.platform }}</h3>
<h3><span>版本:</span> {{ osInfo.release }}</h3>
<h3><span>开机时长:</span> {{ $tools.formatTime(osInfo.uptime) }}</h3>
<h3><span>到期时间:</span> {{ expiredTime }}</h3>
<h3><span>本地IP:</span> {{ osInfo.ip }}</h3>
<h3><span>连接数:</span> {{ openedCount || 0 }}</h3>
</div>
</el-popover>
<div class="fields">
<span class="name" @click="handleUpdate">
{{ name || '--' }}
<svg-icon name="icon-xiugai" class="svg-icon" />
</span>
<span>{{ osInfo?.type || '--' }}</span>
<!-- <span>{{ osInfo?.hostname || '--' }}</span> -->
</div>
</div>
<div class="weizhi field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-position" class="svg-icon" />
</template>
<div class="field-detail">
<h2>位置信息</h2>
<h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</h3>
<!-- <h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</h3> -->
<!-- <h3><span>IP:</span> {{ hostIp }}</h3> -->
<h3><span>提供商:</span> {{ ipInfo.isp || '--' }}</h3>
<h3><span>线路:</span> {{ ipInfo.as || '--' }}</h3>
</div>
</el-popover>
<div class="fields">
<span>{{ `${ipInfo?.country || '--'} ${ipInfo?.regionName || '--'}` }}</span>
<!-- <span>{{ `${ipInfo?.country || '--' } ${ipInfo?.regionName || '--'} ${ipInfo?.city || '--'}` }}</span> -->
<span>{{ hostIp }}</span>
</div>
</div>
<div class="cpu field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-xingzhuang" class="svg-icon" />
</template>
<div class="field-detail">
<h2>CPU</h2>
<h3><span>利用率:</span> {{ cpuInfo.cpuUsage }}%</h3>
<h3><span>物理核心:</span> {{ cpuInfo.cpuCount }}</h3>
<h3><span>型号:</span> {{ cpuInfo.cpuModel }}</h3>
</div>
</el-popover>
<div class="fields">
<span :style="{ color: setColor(cpuInfo.cpuUsage) }">{{ cpuInfo.cpuUsage || '0' || '--' }}%</span>
<span>{{ cpuInfo.cpuCount || '--' }} 核心</span>
</div>
</div>
<div class="ram field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-neicun1" class="svg-icon" />
</template>
<div class="field-detail">
<h2>内存</h2>
<h3><span>总大小:</span> {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</h3>
<h3><span>已使用:</span> {{ $tools.toFixed(memInfo.usedMemMb / 1024) }} GB</h3>
<h3><span>占比:</span> {{ $tools.toFixed(memInfo.usedMemPercentage) }}%</h3>
<h3><span>空闲:</span> {{ $tools.toFixed(memInfo.freeMemMb / 1024) }} GB</h3>
</div>
</el-popover>
<div class="fields">
<span :style="{ color: setColor(memInfo.usedMemPercentage) }">{{ $tools.toFixed(memInfo.usedMemPercentage)
}}%</span>
<span>{{ $tools.toFixed(memInfo.usedMemMb / 1024) }} | {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</span>
</div>
</div>
<div class="yingpan field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-xingzhuang1" class="svg-icon" />
</template>
<div class="field-detail">
<h2>存储</h2>
<h3><span>总空间:</span> {{ driveInfo.totalGb || '--' }} GB</h3>
<h3><span>已使用:</span> {{ driveInfo.usedGb || '--' }} GB</h3>
<h3><span>剩余:</span> {{ driveInfo.freeGb || '--' }} GB</h3>
<h3><span>占比:</span> {{ driveInfo.usedPercentage || '--' }}%</h3>
</div>
</el-popover>
<div class="fields">
<span :style="{ color: setColor(driveInfo.usedPercentage) }">{{ driveInfo.usedPercentage || '--' }}%</span>
<span>{{ driveInfo.usedGb || '--' }} | {{ driveInfo.totalGb || '--' }} GB</span>
</div>
</div>
<div class="wangluo field">
<el-popover placement="bottom-start" :width="200" trigger="hover">
<template #reference>
<svg-icon name="icon-wangluo1" class="svg-icon" />
</template>
<div class="field-detail">
<h2>网卡</h2>
<!-- <h3>
<span>实时流量</span>
<div> {{ $tools.toFixed(netstatInfo.netTotal?.outputMb) || 0 }}MB / s</div>
<div> {{ $tools.toFixed(netstatInfo.netTotal?.inputMb) || 0 }}MB / s</div>
</h3> -->
<div v-for="(value, key) in netstatInfo.netCards" :key="key" style="display: flex; flex-direction: column;">
<h3>
<span>{{ key }}</span>
<div> {{ $tools.formatNetSpeed(value?.outputMb) || 0 }}</div>
<div> {{ $tools.formatNetSpeed(value?.inputMb) || 0 }}</div>
</h3>
</div>
</div>
</el-popover>
<div class="fields">
<span> {{ $tools.formatNetSpeed(netstatInfo.netTotal?.outputMb) || 0 }}</span>
<span> {{ $tools.formatNetSpeed(netstatInfo.netTotal?.inputMb) || 0 }}</span>
</div>
</div>
<div class="fields terminal">
<el-dropdown class="web-ssh" type="primary" trigger="click">
<!-- <el-button type="primary" @click="handleSSH">Web SSH</el-button> -->
<el-button type="primary">功能</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleSSH">连接终端</el-dropdown-item>
<el-dropdown-item v-if="consoleUrl" @click="handleToConsole">控制台</el-dropdown-item>
<el-dropdown-item @click="handleUpdate">修改服务器</el-dropdown-item>
<el-dropdown-item @click="handleRemoveHost"><span style="color: #727272;">移除主机</span></el-dropdown-item>
<el-dropdown-item @click="handleRemoveSSH"><span style="color: #727272;">移除凭证</span></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<SSHForm v-model:show="sshFormVisible" :temp-host="tempHost" :name="name" />
</el-card>
</template>
<script setup>
import { ref, computed, getCurrentInstance } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import SSHForm from './ssh-form.vue'
const { proxy: { $api, $tools } } = getCurrentInstance()
const props = defineProps({
hostInfo: {
required: true,
type: Object
},
hiddenIp: {
required: true,
type: [Number, Boolean,]
}
})
const emit = defineEmits(['update-list', 'update-host',])
const sshFormVisible = ref(false)
const tempHost = ref('')
const hostIp = computed(() => {
let ip = props.hostInfo?.ipInfo?.query || props.hostInfo?.host || '--'
try {
let formatIp = ip.replace(/\d/g, '*').split('.').map((item) => item.padStart(3, '*')).join('.')
return props.hiddenIp ? formatIp : ip
} catch (error) {
return ip
}
})
const host = computed(() => props.hostInfo?.host)
const name = computed(() => props.hostInfo?.name)
const ping = computed(() => props.hostInfo?.ping || '')
const expiredTime = computed(() => $tools.formatTimestamp(props.hostInfo?.expired, 'date'))
const consoleUrl = computed(() => props.hostInfo?.consoleUrl)
const ipInfo = computed(() => props.hostInfo?.ipInfo || {})
const isError = computed(() => !Boolean(props.hostInfo?.osInfo))
const cpuInfo = computed(() => props.hostInfo?.cpuInfo || {})
const memInfo = computed(() => props.hostInfo?.memInfo || {})
const osInfo = computed(() => props.hostInfo?.osInfo || {})
const driveInfo = computed(() => props.hostInfo?.driveInfo || {})
const netstatInfo = computed(() => {
let { total: netTotal, ...netCards } = props.hostInfo?.netstatInfo || {}
return { netTotal, netCards: netCards || {} }
})
const openedCount = computed(() => props.hostInfo?.openedCount || 0)
const setColor = (num) => {
num = Number(num)
return num ? (num < 80 ? '#595959' : (num >= 80 && num < 90 ? '#FF6600' : '#FF0000')) : '#595959'
}
const handleUpdate = () => {
let { expired, expiredNotify, group, consoleUrl, remark } = props.hostInfo
emit('update-host', { name: name.value, host: host.value, expired, expiredNotify, group, consoleUrl, remark })
}
const handleToConsole = () => {
window.open(consoleUrl.value)
}
const handleSSH = async () => {
let { data } = await $api.existSSH(host.value)
if (data) return window.open(`/terminal?host=${ host.value }&name=${ name.value }`)
if (!host.value) {
return ElMessage({
message: '请等待获取服务器ip或刷新页面重试',
type: 'warning',
center: true
})
}
tempHost.value = host.value
sshFormVisible.value = true
}
const handleRemoveSSH = async () => {
ElMessageBox.confirm('确认删除SSH凭证', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
let { data } = await $api.removeSSH(host.value)
ElMessage({
message: data,
type: 'success',
center: true
})
})
}
const handleRemoveHost = async () => {
ElMessageBox.confirm('确认删除主机', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
let { data } = await $api.removeHost({ host: host.value })
ElMessage({
message: data,
type: 'success',
center: true
})
emit('update-list')
})
}
</script>
<style lang="scss" scoped>
.host-card {
margin: 0px 30px 20px;
transition: all 0.5s;
position: relative;
&:hover {
box-shadow: 0px 0px 15px rgba(6, 30, 37, 0.5);
}
.host-state {
position: absolute;
top: 0px;
left: 0px;
span {
font-size: 8px;
// transform: rotate(-45deg);
transform: scale(0.9);
display: inline-block;
padding: 3px 5px;
}
.online {
color: #009933;
background-color: #e8fff3;
}
.offline {
color: #FF0033;
background-color: #fff5f8;
}
}
.info {
display: flex;
align-items: center;
height: 50px;
&>div {
flex: 1
}
.field {
height: 100%;
display: flex;
align-items: center;
.svg-icon {
width: 25px;
height: 25px;
color: #1989fa;
cursor: pointer;
}
.fields {
display: flex;
flex-direction: column;
// justify-content: center;
span {
padding: 3px 0;
margin-left: 5px;
font-weight: 600;
font-size: 13px;
color: #595959;
}
.name {
display: inline-block;
height: 19px;
cursor: pointer;
&:hover {
text-decoration-line: underline;
text-decoration-color: #1989fa;
.svg-icon {
display: inline-block;
}
}
.svg-icon {
display: none;
width: 13px;
height: 13px;
}
}
}
}
.web-ssh {
// ::v-deep has been deprecated. Use :deep(<inner-selector>) instead.
:deep(.el-dropdown__caret-button) {
margin-left: -5px;
}
}
}
}
</style>
<style lang="scss">
.field-detail {
display: flex;
flex-direction: column;
h2 {
font-weight: 600;
font-size: 16px;
margin: 0px 0 8px 0;
}
h3 {
span {
font-weight: 600;
color: #797979;
}
}
span {
display: inline-block;
margin: 4px 0;
}
}
</style>

View File

@ -0,0 +1,208 @@
<template>
<el-dialog
v-model="visible"
width="400px"
: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"
>
<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-select>
</el-form-item>
<el-form-item key="name" label="主机别名" prop="name">
<el-input
v-model.trim="hostForm.name"
clearable
placeholder="主机别名"
autocomplete="off"
/>
</el-form-item>
<el-form-item key="host" label="IP/域名" prop="host">
<el-input
v-model.trim="hostForm.host"
clearable
placeholder="IP/域名"
autocomplete="off"
@keyup.enter="handleSave"
/>
</el-form-item>
<el-form-item key="expired" label="到期时间" prop="expired">
<el-date-picker
v-model="hostForm.expired"
type="date"
value-format="x"
placeholder="服务器到期时间"
/>
</el-form-item>
<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-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-form-item>
</transition-group>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="handleSave">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, getCurrentInstance, nextTick } from 'vue'
const { proxy: { $api, $message } } = getCurrentInstance()
const props = defineProps({
show: {
required: true,
type: Boolean
},
defaultData: {
required: false,
type: Object,
default: null
}
})
const emit = defineEmits(['update:show', 'update-list', 'closed',])
const resetForm = () => ({
group: 'default',
name: '',
host: '',
expired: null,
expiredNotify: false,
consoleUrl: '',
remark: ''
})
const hostForm = reactive(resetForm())
const oldHost = ref('')
const groupList = ref([])
const rules = reactive({
group: { required: true, message: '选择一个分组' },
name: { required: true, message: '输入主机别名', trigger: 'change' },
host: { required: true, message: '输入IP/域名', trigger: 'change' },
expired: { required: false },
expiredNotify: { required: false },
consoleUrl: { required: false },
remark: { required: false }
})
const formRef = ref(null)
const visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
const title = computed(() => props.defaultData ? '修改服务器' : '新增服务器')
watch(() => props.show, (newVal) => {
if (!newVal) return
getGroupList()
})
const getGroupList = () => {
$api.getGroupList()
.then(({ data }) => {
groupList.value = data
})
}
const handleClosed = () => {
// console.log('handleClosed')
Object.assign(hostForm, resetForm())
emit('closed')
nextTick(() => formRef.value.resetFields())
}
const setDefaultData = () => {
if (!props.defaultData) return
let { name, host, expired, expiredNotify, consoleUrl, group, remark } = props.defaultData
oldHost.value = host
Object.assign(hostForm, { name, host, expired, expiredNotify, consoleUrl, group, remark })
}
const handleSave = () => {
formRef.value.validate()
.then(async () => {
if (!hostForm.expired || !hostForm.expiredNotify) {
hostForm.expired = null
hostForm.expiredNotify = false
}
if (props.defaultData) {
let { msg } = await $api.updateHost(Object.assign({}, hostForm, { oldHost: oldHost.value }))
$message({ type: 'success', center: true, message: msg })
} else {
let { msg } = await $api.saveHost(hostForm)
$message({ type: 'success', center: true, message: msg })
}
visible.value = false
emit('update-list')
Object.assign(hostForm, resetForm())
})
}
</script>
<style lang="scss" scoped>
.dialog-footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,185 @@
<template>
<div v-loading="loading">
<el-form
ref="emailFormRef"
:model="emailForm"
:rules="rules"
:inline="true"
:hide-required-asterisk="true"
label-suffix=""
>
<el-form-item label="" prop="target" style="width: 200px;">
<el-select
v-model="emailForm.target"
placeholder="邮件服务商"
>
<el-option
v-for="item in supportEmailList"
:key="item.target"
:label="item.name"
:value="item.target"
/>
</el-select>
</el-form-item>
<el-form-item label="" prop="auth.user" style="width: 200px;">
<el-input
v-model.trim="emailForm.auth.user"
clearable
placeholder="邮箱"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="" prop="auth.pass" style="width: 200px;">
<el-input
v-model.trim="emailForm.auth.pass"
clearable
placeholder="SMTP授权码"
autocomplete="off"
@keyup.enter="addEmail"
/>
</el-form-item>
<el-form-item label="">
<el-tooltip
effect="dark"
content="重复添加的邮箱将会被覆盖"
placement="right"
>
<el-button type="primary" @click="addEmail">
添加
</el-button>
</el-tooltip>
</el-form-item>
</el-form>
<!-- 提示 -->
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 系统所有通知邮件将会下发到所有已经配置成功的邮箱中 </span>
</template>
</el-alert>
<!-- 表格 -->
<el-table :data="userEmailList" class="table">
<el-table-column prop="email" label="Email" />
<el-table-column prop="name" label="服务商" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button
type="primary"
:loading="row.loading"
@click="pushTestEmail(row)"
>
测试
</el-button>
<el-button
type="danger"
@click="deleteUserEmail(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $message, $messageBox, $notification } } = getCurrentInstance()
const loading = ref(false)
const userEmailList = ref([])
const supportEmailList = ref([])
const emailFormRef = ref(null)
const emailForm = reactive({
target: 'qq',
auth: {
user: '',
pass: ''
}
})
const rules = reactive({
'auth.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
'auth.pass': { required: true, message: '需输入SMTP授权码', trigger: 'change' }
})
const getUserEmailList = () => {
loading.value = true
$api.getUserEmailList()
.then(({ data }) => {
userEmailList.value = data.map(item => {
item.loading = false
return item
})
})
.finally(() => loading.value = false)
}
const getSupportEmailList = () => {
$api.getSupportEmailList()
.then(({ data }) => {
supportEmailList.value = data
})
}
const addEmail = () => {
emailFormRef.value.validate()
.then(() => {
$api.updateUserEmailList({ ...emailForm })
.then(() => {
$message.success('添加成功, 点击[测试]按钮发送测试邮件')
let { target } = emailForm
emailForm.target = target
emailForm.auth.user = ''
emailForm.auth.pass = ''
getUserEmailList()
})
})
}
const pushTestEmail = (row) => {
row.loading = true
const { email: toEmail } = row
$api.pushTestEmail({ isTest: true, toEmail })
.then(() => {
$message.success(`发送成功, 请检查邮箱: ${ toEmail }`)
})
.catch((error) => {
$notification({
title: '发送测试邮件失败, 请检查邮箱SMTP配置',
message: error.response?.data.msg,
type: 'error'
})
})
.finally(() => {
row.loading = false
})
}
const deleteUserEmail = ({ email }) => {
$messageBox.confirm(
`确认删除邮箱:${ email }`,
'Warning',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
await $api.deleteUserEmail(email)
$message.success('success')
getUserEmailList()
})
}
onMounted(() => {
getUserEmailList()
getSupportEmailList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,239 @@
<template>
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="rules"
:inline="true"
:hide-required-asterisk="true"
label-suffix=""
>
<el-form-item label="" prop="name" style="width: 200px;">
<el-input
v-model.trim="groupForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="" prop="index" style="width: 200px;">
<el-input
v-model.number="groupForm.index"
clearable
placeholder="序号(数字, 用于分组排序)"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="">
<el-button type="primary" @click="addGroup">
添加
</el-button>
</el-form-item>
</el-form>
<!-- 提示 -->
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;">
Tips: 已添加服务器数量 <u>{{ hostGroupInfo.total }}</u>
<span v-show="hostGroupInfo.notGroupCount">, <u>{{ hostGroupInfo.notGroupCount }}</u> 台服务器尚未分组</span>
</span>
</template>
</el-alert><br>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 删除分组会将分组内所有服务器移至默认分组 </span>
</template>
</el-alert>
<el-table v-loading="loading" :data="list">
<el-table-column prop="index" label="序号" />
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="分组名称" />
<el-table-column label="关联服务器数量">
<template #default="{ row }">
<el-popover
v-if="row.hosts.list.length !== 0"
placement="right"
:width="350"
trigger="hover"
>
<template #reference>
<u class="host-count">{{ row.hosts.count }}</u>
</template>
<ul>
<li v-for="item in row.hosts.list" :key="item.host">
<span>{{ item.host }}</span>
-
<span>{{ item.name }}</span>
</li>
</ul>
</el-popover>
<u v-else class="host-count">0</u>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="visible"
width="400px"
title="修改分组"
:close-on-click-modal="false"
>
<el-form
ref="updateFormRef"
:model="updateForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model.trim="updateForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="分组序号" prop="index">
<el-input
v-model.number="updateForm.index"
clearable
placeholder="分组序号"
autocomplete="off"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="updateGroup">修改</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const loading = ref(false)
const visible = ref(false)
const groupList = ref([])
const groupForm = reactive({
name: '',
index: ''
})
const updateForm = reactive({
name: '',
index: ''
})
const rules = reactive({
name: { required: true, message: '需输入分组名称', trigger: 'change' },
index: { required: true, type: 'number', message: '需输入数字', trigger: 'change' }
})
const groupFormRef = ref(null)
const updateFormRef = ref(null)
const hostGroupInfo = computed(() => {
const total = $store.hostList.length
const notGroupCount = $store.hostList.reduce((prev, next) => {
if (!next.group) prev++
return prev
}, 0)
return { total, notGroupCount }
})
const list = computed(() => {
return groupList.value.map(item => {
const hosts = $store.hostList.reduce((prev, next) => {
if (next.group === item.id) {
prev.count++
prev.list.push(next)
}
return prev
}, { count: 0, list: [] })
return { ...item, hosts }
})
})
const getGroupList = () => {
loading.value = true
$api.getGroupList()
.then(({ data }) => {
groupList.value = data
groupForm.index = data.length
})
.finally(() => loading.value = false)
}
const addGroup = () => {
groupFormRef.value.validate()
.then(() => {
const { name, index } = groupForm
$api.addGroup({ name, index })
.then(() => {
$message.success('success')
groupForm.name = ''
groupForm.index = ''
getGroupList()
})
})
}
const handleChange = ({ id, name, index }) => {
updateForm.id = id
updateForm.name = name
updateForm.index = index
visible.value = true
}
const updateGroup = () => {
updateFormRef.value.validate()
.then(() => {
const { id, name, index } = updateForm
$api.updateGroup(id, { name, index })
.then(() => {
$message.success('success')
visible.value = false
getGroupList()
})
})
}
const deleteGroup = ({ id, name }) => {
$messageBox.confirm(`确认删除分组:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await $api.deleteGroup(id)
await $store.getHostList()
$message.success('success')
getGroupList()
})
}
onMounted(() => {
getGroupList()
})
</script>
<style lang="scss" scoped>
.host-count {
display: block;
width: 100px;
text-align: center;
font-size: 15px;
color: #87cf63;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 请添加邮箱并确保测试邮件通过 </span>
</template>
</el-alert>
<el-table v-loading="notifyListLoading" :data="notifyList">
<el-table-column prop="desc" label="通知类型" />
<el-table-column prop="sw" label="开关">
<template #default="{row}">
<el-switch
v-model="row.sw"
:active-value="true"
:inactive-value="false"
:loading="row.loading"
@change="handleChangeSw(row, $event)"
/>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCurrentInstance } from 'vue'
const { proxy: { $api } } = getCurrentInstance()
const notifyListLoading = ref(false)
const notifyList = ref([])
const getNotifyList = (flag = true) => {
if (flag) notifyListLoading.value = true
$api.getNotifyList()
.then(({ data }) => {
notifyList.value = data.map((item) => {
item.loading = false
return item
})
})
.finally(() => notifyListLoading.value = false)
}
const handleChangeSw = async (row) => {
row.loading = true
const { type, sw } = row
try {
await $api.updateNotifyList({ type, sw })
// if (this.userEmailList.length === 0) $message.warning(', ')
} finally {
row.loading = false
}
getNotifyList(false)
}
onMounted(() => {
getNotifyList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,98 @@
<template>
<el-form
ref="formRef"
class="password-form"
:model="formData"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="90px"
:show-message="false"
>
<el-form-item label="原用户名" prop="oldLoginName">
<el-input
v-model.trim="formData.oldLoginName"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="原密码" prop="oldPwd">
<el-input
v-model.trim="formData.oldPwd"
type="password"
clearable
show-password
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="新用户名" prop="oldPwd">
<el-input
v-model.trim="formData.newLoginName"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input
v-model.trim="formData.newPwd"
type="password"
show-password
clearable
placeholder=""
autocomplete="off"
@keyup.enter="handleUpdate"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleUpdate">确认</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive, getCurrentInstance } from 'vue'
import { RSAEncrypt } from '@utils/index.js'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const loading = ref(false)
const formRef = ref(null)
const formData = reactive({
oldLoginName: '',
oldPwd: '',
newLoginName: '',
newPwd: ''
})
const rules = reactive({
oldLoginName: { required: true, message: '输入原用户名', trigger: 'change' },
oldPwd: { required: true, message: '输入原密码', trigger: 'change' },
newLoginName: { required: true, message: '输入新用户名', trigger: 'change' },
newPwd: { required: true, message: '输入新密码', trigger: 'change' }
})
const handleUpdate = () => {
formRef.value.validate()
.then(async () => {
let { oldLoginName, oldPwd, newLoginName, newPwd } = formData
oldPwd = RSAEncrypt(oldPwd)
newPwd = RSAEncrypt(newPwd)
let { msg } = await $api.updatePwd({ oldLoginName, oldPwd, newLoginName, newPwd })
$message({ type: 'success', center: true, message: msg })
$store.setUser(newLoginName)
formData.oldLoginName = ''
formData.oldPwd = ''
formData.newLoginName = ''
formData.newPwd = ''
formRef.value.resetFields()
})
}
</script>
<style lang="scss" scoped>
.password-form {
width: 500px;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 系统只保存最近10条登录记录, 检测到更换IP后需重新登录 </span>
</template>
</el-alert>
<el-table v-loading="loading" :data="loginRecordList">
<el-table-column prop="ip" label="IP" />
<el-table-column prop="address" label="地点" show-overflow-tooltip>
<template #default="scope">
<span style="letter-spacing: 2px;"> {{ scope.row.country }} {{ scope.row.city }} </span>
</template>
</el-table-column>
<el-table-column prop="date" label="时间" />
</el-table>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $tools } } = getCurrentInstance()
const loginRecordList = ref([])
const loading = ref(false)
const handleLookupLoginRecord = () => {
loading.value = true
$api.getLoginRecord()
.then(({ data }) => {
loginRecordList.value = data.map((item) => {
item.date = $tools.formatTimestamp(item.date)
return item
})
})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
handleLookupLoginRecord()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,98 @@
<template>
<transition-group
name="list"
mode="out-in"
tag="ul"
class="host-list"
>
<li
v-for="(item, index) in list"
:key="item.host"
:draggable="true"
class="host-item"
@dragenter="dragenter($event, index)"
@dragover="dragover($event)"
@dragstart="dragstart(index)"
>
<span>{{ item.host }}</span>
---
<span>{{ item.name }}</span>
</li>
</transition-group>
<div style="display: flex; justify-content: center; margin-top: 25px">
<el-button type="primary" @click="handleUpdateSort">
保存
</el-button>
</div>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
const emit = defineEmits(['update-list',])
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const targetIndex = ref(0)
const list = ref([])
const dragstart = (index) => {
targetIndex.value = index
}
const dragenter = (e, curIndex) => {
e.preventDefault()
if (targetIndex.value !== curIndex) {
let target = list.value.splice(targetIndex.value, 1)[0]
list.value.splice(curIndex, 0, target)
targetIndex.value = curIndex
}
}
const dragover = (e) => {
e.preventDefault()
}
const handleUpdateSort = () => {
$api.updateHostSort({ list: list.value })
.then(({ msg }) => {
$message({ type: 'success', center: true, message: msg })
$store.sortHostList(list.value)
emit('update-list', list.value) //
})
}
onMounted(() => {
list.value = $store.hostList.map(({ name, host }) => ({ name, host }))
})
</script>
<style lang="scss" scoped>
.drag-move {
transition: transform .3s;
}
.host-list {
padding-top: 10px;
padding-right: 50px;
}
.host-item {
transition: all .3s;
box-shadow: var(--el-box-shadow-lighter);
cursor: move;
font-size: 12px;
color: #595959;
padding: 0 20px;
margin: 0 auto;
border-radius: 4px;
color: #000;
margin-bottom: 6px;
height: 35px;
line-height: 35px;
&:hover {
box-shadow: var(--el-box-shadow);
}
}
.dialog-footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<el-dialog
v-model="visible"
width="1100px"
:title="'功能设置'"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-tabs style="height: 500px;" tab-position="left">
<el-tab-pane label="分组管理">
<Group />
</el-tab-pane>
<el-tab-pane label="登录记录">
<Record />
</el-tab-pane>
<el-tab-pane label="主机排序" lazy>
<Sort @update-list="emitUpdateList" />
</el-tab-pane>
<el-tab-pane label="全局通知" lazy>
<NotifyList />
</el-tab-pane>
<el-tab-pane label="邮箱配置" lazy>
<EmailList />
</el-tab-pane>
<el-tab-pane label="修改密码" lazy>
<Password />
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
import NotifyList from './setting-tab/notify-list.vue'
import EmailList from './setting-tab/email-list.vue'
import Sort from './setting-tab/sort.vue'
import Record from './setting-tab/record.vue'
import Group from './setting-tab/group.vue'
import Password from './setting-tab/password.vue'
const props = defineProps({
show: {
required: true,
type: Boolean
}
})
const emit = defineEmits(['update:show', 'update-list',])
const visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
const emitUpdateList = () => {
emit('update-list')
}
</script>
<style lang="scss" scoped>
.table {
max-height: 400px;
overflow: auto;
}
.dialog-footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,205 @@
<template>
<el-dialog
v-model="visible"
title="SSH连接"
:close-on-click-modal="false"
@closed="clearFormInfo"
>
<el-form
ref="formRef"
:model="sshForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="90px"
>
<el-form-item label="主机" prop="host">
<el-input
v-model.trim="sshForm.host"
disabled
clearable
autocomplete="off"
/>
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input v-model.trim="sshForm.port" clearable autocomplete="off" />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-autocomplete
v-model.trim="sshForm.username"
:fetch-suggestions="userSearch"
style="width: 100%;"
clearable
>
<template #default="{item}">
<div class="value">{{ item.value }}</div>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item label="认证方式" prop="type">
<el-radio v-model.trim="sshForm.type" value="privateKey">密钥</el-radio>
<el-radio v-model.trim="sshForm.type" value="password">密码</el-radio>
</el-form-item>
<el-form-item v-if="sshForm.type === 'password'" prop="password" label="密码">
<el-input
v-model.trim="sshForm.password"
type="password"
placeholder="Please input password"
autocomplete="off"
clearable
show-password
/>
</el-form-item>
<el-form-item v-if="sshForm.type === 'privateKey'" prop="privateKey" label="密钥">
<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="sshForm.privateKey"
type="textarea"
:rows="5"
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item>
<el-form-item prop="command" label="执行指令">
<el-input
v-model="sshForm.command"
type="textarea"
:rows="5"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSaveSSH">保存</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, getCurrentInstance, nextTick } from 'vue'
import { ElNotification } from 'element-plus'
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
const props = defineProps({
show: {
required: true,
type: Boolean
},
tempHost: {
required: true,
type: String
},
name: {
required: true,
type: String
}
})
const emit = defineEmits(['update:show',])
const formRef = ref(null)
const privateKeyRef = ref(null)
const sshForm = reactive({
host: '',
port: 22,
username: '',
type: 'privateKey',
password: '',
privateKey: '',
command: ''
})
const defaultUsers = [
{ value: 'root' },
{ value: 'ubuntu' },
]
const rules = reactive({
host: { required: true, message: '需输入主机', trigger: 'change' },
port: { required: true, message: '需输入端口', trigger: 'change' },
username: { required: true, message: '需输入用户名', trigger: 'change' },
type: { required: true },
password: { required: true, message: '需输入密码', trigger: 'change' },
privateKey: { required: true, message: '需输入密钥', trigger: 'change' },
command: { required: false }
})
const { proxy: { $api } } = getCurrentInstance()
const visible = computed({
get() {
return props.show
},
set(newVal) {
emit('update:show', newVal)
}
})
watch(() => props.tempHost, (newVal) => {
sshForm.host = newVal
})
const handleClickUploadBtn = () => {
privateKeyRef.value.click()
}
const handleSelectPrivateKeyFile = (event) => {
let file = event.target.files[0]
let reader = new FileReader()
reader.onload = (e) => {
sshForm.privateKey = e.target.result
privateKeyRef.value.value = ''
}
reader.readAsText(file)
}
const handleSaveSSH = () => {
formRef.value.validate()
.then(async () => {
let randomKey = randomStr(16)
let formData = JSON.parse(JSON.stringify(sshForm))
//
if (formData.password) formData.password = AESEncrypt(formData.password, randomKey)
if (formData.privateKey) formData.privateKey = AESEncrypt(formData.privateKey, randomKey)
formData.randomKey = RSAEncrypt(randomKey)
await $api.updateSSH(formData)
ElNotification({
title: '保存成功',
message: '下次点击 [Web SSH] 可直接登录终端\n如无法登录请 [移除凭证] 后重新添加',
type: 'success'
})
visible.value = false
})
}
const userSearch = (keyword, cb) => {
let res = keyword
? defaultUsers.filter((item) => item.value.includes(keyword))
: defaultUsers
cb(res)
}
const clearFormInfo = () => {
nextTick(() => formRef.value.resetFields())
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="server_group_collapse">
<el-collapse v-model="activeGroup">
<el-collapse-item v-for="(servers, groupName) in resList" :key="groupName" :name="groupName">
<template #title>
<div class="group_title">
{{ groupName }}
</div>
</template>
<div class="host_card_container">
<HostCard
v-for="(item, index) in servers"
:key="index"
:host-info="item"
:hidden-ip="hiddenIp"
@update-list="handleUpdateList"
@update-host="handleUpdateHost"
/>
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, getCurrentInstance, reactive, computed, watch } from 'vue'
import { io } from 'socket.io-client'
import HostForm from './components/host-form.vue'
import Setting from './components/setting.vue'
import HostCard from './components/host-card.vue'
const { proxy: { $api, $store, $message, $notification, $router, $serviceURI } } = getCurrentInstance()
const socket = ref(null)
const loading = ref(true)
const hostListStatus = ref([])
const updateHostData = ref(null)
const hostFormVisible = ref(false)
const settingVisible = ref(false)
const hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
const activeGroup = ref([])
const handleLogout = () => {
$store.clearJwtToken()
$message({ type: 'success', message: '已安全退出', center: true })
$router.push('/login')
}
const getHostList = async () => {
try {
loading.value = true
await $store.getHostList()
connectIo()
} catch (err) {
loading.value = false
}
}
const connectIo = () => {
let socketInstance = io($serviceURI, {
path: '/clients',
forceNew: true,
reconnectionDelay: 5000,
reconnectionAttempts: 2
})
socket.value = socketInstance
socketInstance.on('connect', () => {
let flag = 5
loading.value = false
console.log('clients websocket 已连接: ', socketInstance.id)
let token = $store.token
socketInstance.emit('init_clients_data', { token })
socketInstance.on('clients_data', (data) => {
if ((flag++ % 5) === 0) $store.getHostPing()
hostListStatus.value = $store.hostList.map(item => {
const { host } = item
if (data[host] === null) return { ...item }
return Object.assign({}, item, data[host])
})
})
socketInstance.on('token_verify_fail', (message) => {
$notification({
title: '鉴权失败',
message,
type: 'error'
})
$router.push('/login')
})
})
socketInstance.on('disconnect', () => {
console.error('clients websocket 连接断开')
})
socketInstance.on('connect_error', (message) => {
loading.value = false
console.error('clients websocket 连接出错: ', message)
})
}
const handleUpdateList = () => {
if (socket.value) socket.value.close()
getHostList()
}
const handleUpdateHost = (defaultData) => {
hostFormVisible.value = true
updateHostData.value = defaultData
}
const handleHiddenIP = () => {
hiddenIp.value = hiddenIp.value ? 0 : 1
localStorage.setItem('hiddenIp', String(hiddenIp.value))
}
let resList = computed(() => {
let res = {}
let hostList = $store.hostList
let groupList = $store.groupList
groupList.forEach(group => {
res[group.name] = []
})
hostList.forEach(item => {
const group = groupList.find(group => group.id === item.group)
if (group) {
res[group.name].push(item)
} else {
res['默认分组'].push(item)
}
})
Object.keys(res).map(groupName => {
if (res[groupName].length === 0) delete res[groupName]
})
return res
})
watch(resList, () => {
activeGroup.value = [...Object.keys(resList.value),]
}, {
immediate: true,
deep: false
})
onMounted(async () => {
await getHostList()
})
onBeforeUnmount(() => {
if (socket.value) socket.value.close()
})
</script>
<style lang="scss" scoped>
.server_group_collapse {
.group_title {
margin: 0 15px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.host_card_container {
padding-top: 25px;
}
}
</style>

View File

@ -0,0 +1,185 @@
<template>
<div v-loading="loading">
<el-form
ref="emailFormRef"
:model="emailForm"
:rules="rules"
:inline="true"
:hide-required-asterisk="true"
label-suffix=""
>
<el-form-item label="" prop="target" style="width: 200px;">
<el-select
v-model="emailForm.target"
placeholder="邮件服务商"
>
<el-option
v-for="item in supportEmailList"
:key="item.target"
:label="item.name"
:value="item.target"
/>
</el-select>
</el-form-item>
<el-form-item label="" prop="auth.user" style="width: 200px;">
<el-input
v-model.trim="emailForm.auth.user"
clearable
placeholder="邮箱"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="" prop="auth.pass" style="width: 200px;">
<el-input
v-model.trim="emailForm.auth.pass"
clearable
placeholder="SMTP授权码"
autocomplete="off"
@keyup.enter="addEmail"
/>
</el-form-item>
<el-form-item label="">
<el-tooltip
effect="dark"
content="重复添加的邮箱将会被覆盖"
placement="right"
>
<el-button type="primary" @click="addEmail">
添加
</el-button>
</el-tooltip>
</el-form-item>
</el-form>
<!-- 提示 -->
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 系统所有通知邮件将会下发到所有已经配置成功的邮箱中 </span>
</template>
</el-alert>
<!-- 表格 -->
<el-table :data="userEmailList" class="table">
<el-table-column prop="email" label="Email" />
<el-table-column prop="name" label="服务商" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button
type="primary"
:loading="row.loading"
@click="pushTestEmail(row)"
>
测试
</el-button>
<el-button
type="danger"
@click="deleteUserEmail(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $message, $messageBox, $notification } } = getCurrentInstance()
const loading = ref(false)
const userEmailList = ref([])
const supportEmailList = ref([])
const emailFormRef = ref(null)
const emailForm = reactive({
target: 'qq',
auth: {
user: '',
pass: ''
}
})
const rules = reactive({
'auth.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
'auth.pass': { required: true, message: '需输入SMTP授权码', trigger: 'change' }
})
const getUserEmailList = () => {
loading.value = true
$api.getUserEmailList()
.then(({ data }) => {
userEmailList.value = data.map(item => {
item.loading = false
return item
})
})
.finally(() => loading.value = false)
}
const getSupportEmailList = () => {
$api.getSupportEmailList()
.then(({ data }) => {
supportEmailList.value = data
})
}
const addEmail = () => {
emailFormRef.value.validate()
.then(() => {
$api.updateUserEmailList({ ...emailForm })
.then(() => {
$message.success('添加成功, 点击[测试]按钮发送测试邮件')
let { target } = emailForm
emailForm.target = target
emailForm.auth.user = ''
emailForm.auth.pass = ''
getUserEmailList()
})
})
}
const pushTestEmail = (row) => {
row.loading = true
const { email: toEmail } = row
$api.pushTestEmail({ isTest: true, toEmail })
.then(() => {
$message.success(`发送成功, 请检查邮箱: ${ toEmail }`)
})
.catch((error) => {
$notification({
title: '发送测试邮件失败, 请检查邮箱SMTP配置',
message: error.response?.data.msg,
type: 'error'
})
})
.finally(() => {
row.loading = false
})
}
const deleteUserEmail = ({ email }) => {
$messageBox.confirm(
`确认删除邮箱:${ email }`,
'Warning',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
await $api.deleteUserEmail(email)
$message.success('success')
getUserEmailList()
})
}
onMounted(() => {
getUserEmailList()
getSupportEmailList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,239 @@
<template>
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="rules"
:inline="true"
:hide-required-asterisk="true"
label-suffix=""
>
<el-form-item label="" prop="name" style="width: 200px;">
<el-input
v-model.trim="groupForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="" prop="index" style="width: 200px;">
<el-input
v-model.number="groupForm.index"
clearable
placeholder="序号(数字, 用于分组排序)"
autocomplete="off"
@keyup.enter="addGroup"
/>
</el-form-item>
<el-form-item label="">
<el-button type="primary" @click="addGroup">
添加
</el-button>
</el-form-item>
</el-form>
<!-- 提示 -->
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;">
Tips: 已添加服务器数量 <u>{{ hostGroupInfo.total }}</u>
<span v-show="hostGroupInfo.notGroupCount">, <u>{{ hostGroupInfo.notGroupCount }}</u> 台服务器尚未分组</span>
</span>
</template>
</el-alert><br>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 删除分组会将分组内所有服务器移至默认分组 </span>
</template>
</el-alert>
<el-table v-loading="loading" :data="list">
<el-table-column prop="index" label="序号" />
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="分组名称" />
<el-table-column label="关联服务器数量">
<template #default="{ row }">
<el-popover
v-if="row.hosts.list.length !== 0"
placement="right"
:width="350"
trigger="hover"
>
<template #reference>
<u class="host-count">{{ row.hosts.count }}</u>
</template>
<ul>
<li v-for="item in row.hosts.list" :key="item.host">
<span>{{ item.host }}</span>
-
<span>{{ item.name }}</span>
</li>
</ul>
</el-popover>
<u v-else class="host-count">0</u>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="visible"
width="400px"
title="修改分组"
:close-on-click-modal="false"
>
<el-form
ref="updateFormRef"
:model="updateForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="100px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model.trim="updateForm.name"
clearable
placeholder="分组名称"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="分组序号" prop="index">
<el-input
v-model.number="updateForm.index"
clearable
placeholder="分组序号"
autocomplete="off"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button type="primary" @click="updateGroup">修改</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const loading = ref(false)
const visible = ref(false)
const groupList = ref([])
const groupForm = reactive({
name: '',
index: ''
})
const updateForm = reactive({
name: '',
index: ''
})
const rules = reactive({
name: { required: true, message: '需输入分组名称', trigger: 'change' },
index: { required: true, type: 'number', message: '需输入数字', trigger: 'change' }
})
const groupFormRef = ref(null)
const updateFormRef = ref(null)
const hostGroupInfo = computed(() => {
const total = $store.hostList.length
const notGroupCount = $store.hostList.reduce((prev, next) => {
if (!next.group) prev++
return prev
}, 0)
return { total, notGroupCount }
})
const list = computed(() => {
return groupList.value.map(item => {
const hosts = $store.hostList.reduce((prev, next) => {
if (next.group === item.id) {
prev.count++
prev.list.push(next)
}
return prev
}, { count: 0, list: [] })
return { ...item, hosts }
})
})
const getGroupList = () => {
loading.value = true
$api.getGroupList()
.then(({ data }) => {
groupList.value = data
groupForm.index = data.length
})
.finally(() => loading.value = false)
}
const addGroup = () => {
groupFormRef.value.validate()
.then(() => {
const { name, index } = groupForm
$api.addGroup({ name, index })
.then(() => {
$message.success('success')
groupForm.name = ''
groupForm.index = ''
getGroupList()
})
})
}
const handleChange = ({ id, name, index }) => {
updateForm.id = id
updateForm.name = name
updateForm.index = index
visible.value = true
}
const updateGroup = () => {
updateFormRef.value.validate()
.then(() => {
const { id, name, index } = updateForm
$api.updateGroup(id, { name, index })
.then(() => {
$message.success('success')
visible.value = false
getGroupList()
})
})
}
const deleteGroup = ({ id, name }) => {
$messageBox.confirm(`确认删除分组:${ name }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await $api.deleteGroup(id)
await $store.getHostList()
$message.success('success')
getGroupList()
})
}
onMounted(() => {
getGroupList()
})
</script>
<style lang="scss" scoped>
.host-count {
display: block;
width: 100px;
text-align: center;
font-size: 15px;
color: #87cf63;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 请添加邮箱并确保测试邮件通过 </span>
</template>
</el-alert>
<el-table v-loading="notifyListLoading" :data="notifyList">
<el-table-column prop="desc" label="通知类型" />
<el-table-column prop="sw" label="开关">
<template #default="{row}">
<el-switch
v-model="row.sw"
:active-value="true"
:inactive-value="false"
:loading="row.loading"
@change="handleChangeSw(row, $event)"
/>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCurrentInstance } from 'vue'
const { proxy: { $api } } = getCurrentInstance()
const notifyListLoading = ref(false)
const notifyList = ref([])
const getNotifyList = (flag = true) => {
if (flag) notifyListLoading.value = true
$api.getNotifyList()
.then(({ data }) => {
notifyList.value = data.map((item) => {
item.loading = false
return item
})
})
.finally(() => notifyListLoading.value = false)
}
const handleChangeSw = async (row) => {
row.loading = true
const { type, sw } = row
try {
await $api.updateNotifyList({ type, sw })
// if (this.userEmailList.length === 0) $message.warning(', ')
} finally {
row.loading = false
}
getNotifyList(false)
}
onMounted(() => {
getNotifyList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,98 @@
<template>
<el-form
ref="formRef"
class="password-form"
:model="formData"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="90px"
:show-message="false"
>
<el-form-item label="原用户名" prop="oldLoginName">
<el-input
v-model.trim="formData.oldLoginName"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="原密码" prop="oldPwd">
<el-input
v-model.trim="formData.oldPwd"
type="password"
clearable
show-password
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="新用户名" prop="oldPwd">
<el-input
v-model.trim="formData.newLoginName"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input
v-model.trim="formData.newPwd"
type="password"
show-password
clearable
placeholder=""
autocomplete="off"
@keyup.enter="handleUpdate"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleUpdate">确认</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, reactive, getCurrentInstance } from 'vue'
import { RSAEncrypt } from '@utils/index.js'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const loading = ref(false)
const formRef = ref(null)
const formData = reactive({
oldLoginName: '',
oldPwd: '',
newLoginName: '',
newPwd: ''
})
const rules = reactive({
oldLoginName: { required: true, message: '输入原用户名', trigger: 'change' },
oldPwd: { required: true, message: '输入原密码', trigger: 'change' },
newLoginName: { required: true, message: '输入新用户名', trigger: 'change' },
newPwd: { required: true, message: '输入新密码', trigger: 'change' }
})
const handleUpdate = () => {
formRef.value.validate()
.then(async () => {
let { oldLoginName, oldPwd, newLoginName, newPwd } = formData
oldPwd = RSAEncrypt(oldPwd)
newPwd = RSAEncrypt(newPwd)
let { msg } = await $api.updatePwd({ oldLoginName, oldPwd, newLoginName, newPwd })
$message({ type: 'success', center: true, message: msg })
$store.setUser(newLoginName)
formData.oldLoginName = ''
formData.oldPwd = ''
formData.newLoginName = ''
formData.newPwd = ''
formRef.value.resetFields()
})
}
</script>
<style lang="scss" scoped>
.password-form {
width: 500px;
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<el-alert type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> Tips: 系统只保存最近10条登录记录, 检测到更换IP后需重新登录 </span>
</template>
</el-alert>
<el-table v-loading="loading" :data="loginRecordList">
<el-table-column prop="ip" label="IP" />
<el-table-column prop="address" label="地点" show-overflow-tooltip>
<template #default="scope">
<span style="letter-spacing: 2px;"> {{ scope.row.country }} {{ scope.row.city }} </span>
</template>
</el-table-column>
<el-table-column prop="date" label="时间" />
</el-table>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
const { proxy: { $api, $tools } } = getCurrentInstance()
const loginRecordList = ref([])
const loading = ref(false)
const handleLookupLoginRecord = () => {
loading.value = true
$api.getLoginRecord()
.then(({ data }) => {
loginRecordList.value = data.map((item) => {
item.date = $tools.formatTimestamp(item.date)
return item
})
})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
handleLookupLoginRecord()
})
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,98 @@
<template>
<transition-group
name="list"
mode="out-in"
tag="ul"
class="host-list"
>
<li
v-for="(item, index) in list"
:key="item.host"
:draggable="true"
class="host-item"
@dragenter="dragenter($event, index)"
@dragover="dragover($event)"
@dragstart="dragstart(index)"
>
<span>{{ item.host }}</span>
---
<span>{{ item.name }}</span>
</li>
</transition-group>
<div style="display: flex; justify-content: center; margin-top: 25px">
<el-button type="primary" @click="handleUpdateSort">
保存
</el-button>
</div>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
const emit = defineEmits(['update-list',])
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const targetIndex = ref(0)
const list = ref([])
const dragstart = (index) => {
targetIndex.value = index
}
const dragenter = (e, curIndex) => {
e.preventDefault()
if (targetIndex.value !== curIndex) {
let target = list.value.splice(targetIndex.value, 1)[0]
list.value.splice(curIndex, 0, target)
targetIndex.value = curIndex
}
}
const dragover = (e) => {
e.preventDefault()
}
const handleUpdateSort = () => {
$api.updateHostSort({ list: list.value })
.then(({ msg }) => {
$message({ type: 'success', center: true, message: msg })
$store.sortHostList(list.value)
emit('update-list', list.value) //
})
}
onMounted(() => {
list.value = $store.hostList.map(({ name, host }) => ({ name, host }))
})
</script>
<style lang="scss" scoped>
.drag-move {
transition: transform .3s;
}
.host-list {
padding-top: 10px;
padding-right: 50px;
}
.host-item {
transition: all .3s;
box-shadow: var(--el-box-shadow-lighter);
cursor: move;
font-size: 12px;
color: #595959;
padding: 0 20px;
margin: 0 auto;
border-radius: 4px;
color: #000;
margin-bottom: 6px;
height: 35px;
line-height: 35px;
&:hover {
box-shadow: var(--el-box-shadow);
}
}
.dialog-footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<div class="setting_container">
<el-tabs tab-position="top">
<el-tab-pane label="修改密码" lazy>
<Password />
</el-tab-pane>
<!-- <el-tab-pane label="分组管理">
<Group />
</el-tab-pane> -->
<el-tab-pane label="登录日志">
<Record />
</el-tab-pane>
<el-tab-pane label="主机排序" lazy>
<Sort @update-list="emitUpdateList" />
</el-tab-pane>
<el-tab-pane label="全局通知" lazy>
<NotifyList />
</el-tab-pane>
<el-tab-pane label="邮箱配置" lazy>
<EmailList />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { computed } from 'vue'
import NotifyList from './components/notify-list.vue'
import EmailList from './components/email-list.vue'
import Sort from './components/sort.vue'
import Record from './components/record.vue'
// import Group from './components/group.vue'
import Password from './components/password.vue'
// const props = defineProps({
// show: {
// required: true,
// type: Boolean
// }
// })
const emit = defineEmits(['update:show', 'update-list',])
// const visible = computed({
// get: () => props.show,
// set: (newVal) => emit('update:show', newVal)
// })
const emitUpdateList = () => {
emit('update-list')
}
</script>
<style lang="scss" scoped>
.setting_container {
padding: 20px;
}
</style>