feat: 实例列表支持搜索&列表设置持久化

This commit is contained in:
chaos-zhu
2025-11-01 20:08:09 +08:00
parent ec9f6ecda8
commit 61846606b8
11 changed files with 515 additions and 94 deletions
+1
View File
@@ -24,6 +24,7 @@ module.exports = {
proxyDBPath: path.join(process.cwd(),'app/db/proxy.db'),
fileTransferDBPath: path.join(process.cwd(),'app/db/file-transfer.db'),
terminalConfigDBPath: path.join(process.cwd(),'app/db/terminal-config.db'),
serverListDBPath: path.join(process.cwd(),'app/db/server-list-config.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/db/logs'),
@@ -0,0 +1,70 @@
const { ServerListDB } = require('../utils/db-class')
const serverListDB = new ServerListDB().getInstance()
// 获取服务器列表配置
async function getServerListConfig({ res }) {
try {
const config = await serverListDB.findOneAsync({})
if (!config) {
// 返回默认配置
const defaultConfig = {
columnSettings: {
selection: true,
index: true,
name: true,
username: true,
host: true,
port: true,
authType: true,
proxyType: false,
expired: false,
consoleUrl: false,
tag: true
},
displayMode: 'group'
}
return res.success({ data: defaultConfig })
}
// eslint-disable-next-line no-unused-vars
const { _id, ...configData } = config
res.success({ data: configData })
} catch (error) {
console.error('getServerListConfig error: ', error)
res.fail({ msg: '获取配置失败' })
}
}
// 保存服务器列表配置
async function saveServerListConfig({ res, request }) {
try {
const { body } = request
if (!body || typeof body !== 'object') {
return res.fail({ msg: '参数错误' })
}
const existingConfig = await serverListDB.findOneAsync({})
if (existingConfig) {
// 更新现有配置
await serverListDB.updateAsync(
{ _id: existingConfig._id },
{ $set: body },
{}
)
} else {
// 插入新配置
await serverListDB.insertAsync(body)
}
res.success({ msg: '保存成功' })
} catch (error) {
console.error('saveServerListConfig error: ', error)
res.fail({ msg: '保存配置失败' })
}
}
module.exports = {
getServerListConfig,
saveServerListConfig
}
+16 -1
View File
@@ -10,6 +10,7 @@ const { getLog, saveIpWhiteList, removeSomeLoginRecords } = require('../controll
const { getAIConfig, saveAIConfig, getAIModels, getChatHistory, saveChatHistory, removeChatHistory } = require('../controller/chat')
const { getProxyList, addProxy, updateProxy, removeProxy } = require('../controller/proxy')
const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config')
const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config')
const ssh = [
{
@@ -349,6 +350,19 @@ const terminalConfig = [
}
]
const serverListConfig = [
{
method: 'get',
path: '/server-list-config',
controller: getServerListConfig
},
{
method: 'post',
path: '/server-list-config',
controller: saveServerListConfig
}
]
module.exports = [].concat(
ssh,
host,
@@ -361,5 +375,6 @@ module.exports = [].concat(
log,
aiConfig,
proxy,
terminalConfig
terminalConfig,
serverListConfig
)
+13 -1
View File
@@ -16,7 +16,8 @@ const {
favoriteSftpDBPath,
proxyDBPath,
fileTransferDBPath,
terminalConfigDBPath
terminalConfigDBPath,
serverListDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
@@ -213,4 +214,15 @@ module.exports.TerminalConfigDB = class TerminalConfigDB {
getInstance() {
return TerminalConfigDB.instance
}
}
module.exports.ServerListDB = class ServerListDB {
constructor() {
if (!ServerListDB.instance) {
ServerListDB.instance = new Datastore({ filename: serverListDBPath, autoload: true })
}
}
getInstance() {
return ServerListDB.instance
}
}
+7
View File
@@ -190,5 +190,12 @@ export default {
},
saveTerminalConfig(data) {
return axios({ url: '/terminal-config', method: 'post', data })
},
// 服务器列表配置相关API
getServerListConfig(params = {}) {
return axios({ url: '/server-list-config', method: 'get', params })
},
saveServerListConfig(data) {
return axios({ url: '/server-list-config', method: 'post', data })
}
}
+31
View File
@@ -51,6 +51,23 @@ const useStore = defineStore('global', {
],
// 终端配置占位
terminalConfig: {},
// 服务器列表配置
serverListConfig: {
columnSettings: {
selection: true,
index: true,
name: true,
username: true,
host: true,
port: true,
authType: true,
proxyType: false,
expired: false,
consoleUrl: false,
tag: false
},
displayMode: localStorage.getItem('host_list_display_mode') || 'group'
},
menuSetting: {
...{
scriptLibrary: true,
@@ -95,6 +112,7 @@ const useStore = defineStore('global', {
await this.getPlusInfo()
await this.getProxyList()
await this.getTerminalConfig() // 添加终端配置获取
await this.getServerListConfig() // 添加服务器列表配置获取
this.getAIConfig()
this.getChatHistory()
},
@@ -162,6 +180,19 @@ const useStore = defineStore('global', {
await $api.saveTerminalConfig(newConfig)
this.$patch({ terminalConfig: newConfig })
},
async getServerListConfig() {
const { data: serverListConfig } = await $api.getServerListConfig()
if (serverListConfig.displayMode) {
// 将displayMode写入localStorage,避免刷新时闪烁
localStorage.setItem('host_list_display_mode', serverListConfig.displayMode)
}
this.$patch({ serverListConfig })
},
async setServerListConfig(setTarget = {}) {
const newConfig = { ...this.serverListConfig, ...setTarget }
await $api.saveServerListConfig(newConfig)
await this.getServerListConfig()
},
setMenuSetting(setTarget = {}) {
let newConfig = { ...this.menuSetting, ...setTarget }
localStorage.setItem('menuSetting', JSON.stringify(newConfig))
+6 -1
View File
@@ -68,7 +68,12 @@
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="command" label="指令内容" show-overflow-tooltip />
<el-table-column prop="useBase64" label="编码方式" width="140px" :formatter="formatExecutionMode" />
<el-table-column
prop="useBase64"
label="编码方式"
width="140px"
:formatter="formatExecutionMode"
/>
<el-table-column label="操作" fixed="right" width="160px">
<template #default="{ row }">
<template v-if="row.index !== '--'">
@@ -15,7 +15,7 @@
v-for="item in batchHosts"
:key="item.id"
class="host_name_tag"
type="warning"
type="success"
>
{{ item.name }}
</el-tag>
+11 -1
View File
@@ -213,10 +213,20 @@ const selectAll = () => {
nextTick(() => tableRef.value.toggleAllSelection())
}
// 反选:已选的变成不选,未选的变成已选
const toggleSelection = () => {
nextTick(() => {
hosts.value.forEach(row => {
tableRef.value.toggleRowSelection(row)
})
})
}
defineExpose({
getSelectHosts,
clearSelection,
selectAll
selectAll,
toggleSelection
})
const handleRemoveHost = async ({ id }) => {
@@ -0,0 +1,240 @@
<template>
<el-dialog
v-model="visible"
title="列表设置"
width="500px"
append-to-body
@close="handleClose"
>
<el-tabs v-model="activeTab">
<!-- 表头设置 Tab -->
<el-tab-pane label="表头设置" name="columns">
<div class="column_settings">
<div v-for="(item, key) in columnConfig" :key="key" class="column_item">
<el-checkbox
v-model="localColumnSettings[key]"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox>
</div>
</div>
<div class="tab_footer" />
</el-tab-pane>
<!-- 展现形式 Tab -->
<el-tab-pane label="展现形式" name="display">
<div class="display_settings">
<div class="display_mode_cards">
<div
class="display_mode_card"
:class="{ active: localDisplayMode === 'group' }"
@click="localDisplayMode = 'group'"
>
<div class="card_content">
<div class="card_title">分组展示</div>
<div class="card_description">按照分组折叠展示主机列表</div>
</div>
<div class="card_check">
<el-icon v-if="localDisplayMode === 'group'" class="check_icon">
<CircleCheckFilled />
</el-icon>
</div>
</div>
<div
class="display_mode_card"
:class="{ active: localDisplayMode === 'list' }"
@click="localDisplayMode = 'list'"
>
<div class="card_content">
<div class="card_title">列表展示</div>
<div class="card_description">在一个列表中展示所有主机</div>
</div>
<div class="card_check">
<el-icon v-if="localDisplayMode === 'list'" class="check_icon">
<CircleCheckFilled />
</el-icon>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="dialog_footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { CircleCheckFilled } from '@element-plus/icons-vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
columnConfig: {
type: Object,
required: true
},
columnSettings: {
type: Object,
required: true
},
displayMode: {
type: String,
default: 'group'
}
})
const emit = defineEmits(['update:show', 'confirm',])
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const activeTab = ref('columns')
const localColumnSettings = ref({ ...props.columnSettings })
const localDisplayMode = ref(props.displayMode)
// 监听props变化,更新本地状态
watch(() => props.columnSettings, (newVal) => {
localColumnSettings.value = { ...newVal }
}, { deep: true })
watch(() => props.displayMode, (newVal) => {
localDisplayMode.value = newVal
})
// 重置弹窗状态
watch(() => props.show, (newVal) => {
if (newVal) {
localColumnSettings.value = { ...props.columnSettings }
localDisplayMode.value = props.displayMode
activeTab.value = 'columns'
}
})
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
// 确认设置
const handleConfirm = () => {
emit('confirm', {
columnSettings: localColumnSettings.value,
displayMode: localDisplayMode.value
})
visible.value = false
}
</script>
<style lang="scss" scoped>
.column_settings {
padding: 10px 0;
max-height: 400px;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
.column_item {
flex: 0 0 auto;
min-width: fit-content;
}
}
.tab_footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
.display_settings {
padding: 20px 0;
.display_mode_cards {
display: flex;
flex-direction: column;
gap: 12px;
.display_mode_card {
display: flex;
align-items: center;
padding: 16px 20px;
border: 2px solid var(--el-border-color);
border-radius: 6px;
background-color: var(--el-fill-color-blank);
cursor: pointer;
&:hover {
border-color: var(--el-color-primary-light-5);
}
&.active {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
.card_icon {
color: var(--el-color-primary);
}
.card_title {
color: var(--el-color-primary);
}
}
.card_icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
color: var(--el-text-color-secondary);
}
.card_content {
flex: 1;
.card_title {
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.card_description {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.card_check {
flex-shrink: 0;
margin-left: 12px;
.check_icon {
font-size: 22px;
color: var(--el-color-primary);
}
}
}
}
}
.dialog_footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
+119 -89
View File
@@ -1,8 +1,25 @@
<template>
<div class="server_group_container">
<div class="server_group_header">
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
<el-input
v-if="hostList.length > 2"
v-model="searchKeyword"
placeholder="搜索实例"
class="search_input"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
class="add_host_btn"
:class="{ 'first_btn': hostList.length <= 2 }"
@click="hostFormVisible = true"
>
添加实例
</el-button>
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
批量操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
@@ -22,9 +39,9 @@
<el-button
type="primary"
class="table_header_setting_btn"
@click="columnSettingsVisible = true"
@click="listSettingsVisible = true"
>
表头设置
列表设置
</el-button>
</div>
<div class="server_group_collapse">
@@ -35,7 +52,8 @@
<el-button type="primary" @click="importVisible = true">批量导入实例</el-button>
</el-empty>
</div>
<el-collapse v-else v-model="activeGroup">
<!-- 分组展示模式 -->
<el-collapse v-else-if="displayMode === 'group'" v-model="activeGroup">
<el-collapse-item v-for="(hosts, groupName) in groupHostList" :key="groupName" :name="groupName">
<template #title>
<div class="group_title">
@@ -45,13 +63,24 @@
<HostTable
ref="hostTableRefs"
:hosts="hosts"
:column-settings="rawColumnSettings"
:column-settings="columnSettings"
@update-host="handleUpdateHost"
@update-list="handleUpdateList"
/>
</el-collapse-item>
</el-collapse>
<!-- 列表展示模式 -->
<div v-else class="list_mode_container">
<HostTable
ref="hostTableRefs"
:hosts="filteredHostList"
:column-settings="columnSettings"
@update-host="handleUpdateHost"
@update-list="handleUpdateList"
/>
</div>
<!-- 滚动到顶部按钮 -->
<Transition name="scroll-to-top">
<div
@@ -77,30 +106,14 @@
/>
<GroupDialog v-model:show="groupDialogVisible" />
<!-- 表头设置弹窗 -->
<el-dialog
v-model="columnSettingsVisible"
title="表头设置"
width="400px"
append-to-body
>
<div class="column-settings">
<div v-for="(item, key) in columnConfig" :key="key" class="column-item">
<el-checkbox
v-model="columnSettings[key]"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="resetColumnSettings">重置默认</el-button>
<el-button type="primary" @click="saveColumnSettings">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 列表设置弹窗 -->
<ListSettings
v-model:show="listSettingsVisible"
:column-config="columnConfig"
:column-settings="columnSettings"
:display-mode="displayMode"
@confirm="handleSettingsConfirm"
/>
</div>
</template>
@@ -111,7 +124,8 @@ import HostTable from './components/host-table.vue'
import HostForm from './components/host-form.vue'
import ImportHost from './components/import-host.vue'
import GroupDialog from './components/group.vue'
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import ListSettings from './components/list-settings.vue'
import { ArrowDown, ArrowUp, Search } from '@element-plus/icons-vue'
import { exportFile } from '@/utils'
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
@@ -126,7 +140,10 @@ const activeGroup = ref([])
const groupDialogVisible = ref(false)
// 列设置相关
const columnSettingsVisible = ref(false)
const listSettingsVisible = ref(false)
// 展示模式(从store获取)
const displayMode = computed(() => $store.serverListConfig.displayMode)
// 滚动到顶部相关
const showScrollToTop = ref(false)
@@ -147,41 +164,19 @@ const columnConfig = {
tag: { label: 'Tag', disabled: false }
}
// 默认列设置
const defaultColumnSettings = {
selection: true,
index: true,
name: true,
username: true,
host: true,
port: true,
authType: true,
proxyType: false,
expired: false,
consoleUrl: false,
tag: false
}
// 列设置状态(从store获取)
const columnSettings = computed(() => $store.serverListConfig.columnSettings)
// 从localStorage获取列设置
const getColumnSettings = () => {
const saved = localStorage.getItem('host_table_column_settings')
return saved ? { ...defaultColumnSettings, ...JSON.parse(saved) } : { ...defaultColumnSettings }
}
// 列设置状态
const columnSettings = ref(getColumnSettings())
const rawColumnSettings = ref({ ...columnSettings.value })
// 保存列设置到localStorage
const saveColumnSettings = () => {
localStorage.setItem('host_table_column_settings', JSON.stringify(columnSettings.value))
rawColumnSettings.value = { ...columnSettings.value }
columnSettingsVisible.value = false
}
// 重置列设置
const resetColumnSettings = () => {
columnSettings.value = { ...defaultColumnSettings }
// 处理设置确认
const handleSettingsConfirm = async (settings) => {
try {
// 保存到数据库
await $store.setServerListConfig(settings)
$message.success('设置已保存')
} catch (error) {
$message.error('保存设置失败')
console.error('保存设置失败:', error)
}
}
const handleUpdateList = async () => {
@@ -193,10 +188,17 @@ const handleUpdateList = async () => {
}
}
// 获取所有 HostTable 组件的引用(兼容分组和列表模式)
const getHostTableRefs = () => {
if (!hostTableRefs.value) return []
// 列表模式下是单个组件,分组模式下是数组
return Array.isArray(hostTableRefs.value) ? hostTableRefs.value : [hostTableRefs.value,]
}
// 收集选中的实例
const collectSelectHost = () => {
let allSelectHosts = []
hostTableRefs.value.map(item => {
getHostTableRefs().forEach(item => {
if (item) allSelectHosts = allSelectHosts.concat(item.getSelectHosts())
})
selectHosts.value = allSelectHosts
@@ -207,7 +209,6 @@ const handleBatchConnect = () => {
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
let ids = selectHosts.value.filter(item => item.isConfig).map(item => item.id)
if (!ids.length) return $message.warning('所选实例未配置ssh连接信息')
if (ids.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
// $router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
if (selectHosts.value.every(item => item.connectType === 'rdp')) {
$router.push({ path: '/rdp', query: { hostIds: ids.join(',') } })
@@ -215,7 +216,9 @@ const handleBatchConnect = () => {
$router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
} else {
$message.warning('所选实例包含rdp和ssh连接信息,请选择同一终端类型进行批量连接')
return
}
if (ids.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
}
const handleBatchModify = async () => {
@@ -226,7 +229,11 @@ const handleBatchModify = async () => {
}
const handleSelectAll = () => {
hostTableRefs.value.forEach(item => item.selectAll())
getHostTableRefs().forEach(item => {
if (item && item.toggleSelection) {
item.toggleSelection()
}
})
}
const handleBatchRemove = async () => {
@@ -244,7 +251,7 @@ const handleBatchRemove = async () => {
$message({ message: data, type: 'success', center: true })
selectHosts.value = []
await handleUpdateList()
hostTableRefs.value.forEach(item => item.clearSelection())
getHostTableRefs().forEach(item => item.clearSelection())
})
}
@@ -263,18 +270,39 @@ const handleBatchExport = () => {
})
const fileName = `easynode-${ $tools.formatTimestamp(Date.now(), 'time', '.') }.json`
exportFile(exportData, fileName, 'application/json')
hostTableRefs.value.forEach(item => item.clearSelection())
getHostTableRefs().forEach(item => item.clearSelection())
}
const hostList = computed(() => $store.hostList)
const searchKeyword = ref('')
const filteredHostList = computed(() => {
if (!searchKeyword.value) return hostList.value
const keyword = searchKeyword.value.toLowerCase()
return hostList.value.filter(item => {
// 搜索实例名称
if (item.name && item.name.toLowerCase().includes(keyword)) return true
// 搜索用户名
if (item.username && item.username.toLowerCase().includes(keyword)) return true
// 搜索IP
if (item.host && item.host.toLowerCase().includes(keyword)) return true
// 搜索标签
if (item.tag && Array.isArray(item.tag)) {
return item.tag.some(tag => tag.toLowerCase().includes(keyword))
}
return false
})
})
const groupHostList = computed(() => {
let res = {}
let groupList = $store.groupList
groupList.forEach(group => {
res[group.name] = []
})
hostList.value.forEach(item => {
filteredHostList.value.forEach(item => {
const group = groupList.find(group => group.id === item.group)
if (group?.name) {
res[group.name].push(item)
@@ -302,7 +330,7 @@ const hostFormClosed = () => {
updateHostData.value = null
isBatchModify.value = false
selectHosts.value = []
hostTableRefs.value.forEach(item => item.clearSelection())
getHostTableRefs().forEach(item => item.clearSelection())
}
// 滚动监听处理
@@ -353,7 +381,6 @@ onUnmounted(() => {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
justify-content: flex-end;
@media screen and (max-width: 768px) {
justify-content: flex-start;
@@ -363,12 +390,26 @@ onUnmounted(() => {
display: none;
}
.search_input {
width: 300px;
margin-right: auto;
flex-shrink: 0;
@media screen and (max-width: 768px) {
width: 200px;
}
}
.add_host_btn,
.group_action_btn,
.el-button {
flex-shrink: 0;
min-width: fit-content;
}
.first_btn {
margin-left: auto;
}
.table_header_setting_btn {
margin-left: 0px;
}
@@ -401,6 +442,10 @@ onUnmounted(() => {
margin: 0 25px;
}
.list-mode-container {
padding: 10px;
}
.scroll-to-top-btn {
position: fixed;
right: 15px;
@@ -431,21 +476,6 @@ onUnmounted(() => {
}
}
.column-settings {
.column-item {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
}
// 滚动到顶部按钮的过渡动画
.scroll-to-top-enter-active,
.scroll-to-top-leave-active {