add group manager

This commit is contained in:
dalaolala committed 2026-02-11 09:40:03 +08:00
1 parent a2df934abd
commit d6976f8ee4
9 files changed
+1177 -34

No files matched your search

+87 -4
View File
@@ -67,14 +67,58 @@
### 数据库设计
```sql
-- 用户表
users (id, email, phone, password_hash, created_at, updated_at)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 分组表
CREATE TABLE IF NOT EXISTS `groups` (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_user_group (user_id, name)
);
-- 服务器表
servers (id, user_id, name, host, port, username, password_encrypted,
private_key_encrypted, auth_type, group_name, created_at, updated_at)
CREATE TABLE IF NOT EXISTS servers (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
port INT DEFAULT 22,
username VARCHAR(255) NOT NULL,
password_encrypted TEXT,
private_key_encrypted TEXT,
auth_type ENUM('password', 'key') DEFAULT 'password',
group_id INT,
group_name VARCHAR(255) DEFAULT 'Default',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE SET NULL
);
-- 连接日志表
connection_logs (id, user_id, server_id, host, start_time, end_time, duration)
CREATE TABLE IF NOT EXISTS connection_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
server_id INT,
host VARCHAR(255) NOT NULL,
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP NULL,
duration INT DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
);
```
## 📁 项目结构
@@ -131,6 +175,14 @@ cd webssh
```
### 2. 数据库设置
#### 方式一:使用安装脚本(推荐)
```bash
# 使用安装脚本自动创建数据库和表结构
mysql -u root -p < install.sql
```
#### 方式二:手动创建数据库
```sql
-- 创建数据库
CREATE DATABASE webssh CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -141,6 +193,17 @@ GRANT ALL PRIVILEGES ON webssh.* TO 'webssh'@'localhost';
FLUSH PRIVILEGES;
```
#### 方式三:系统初始化
```sql
-- 如果需要重新初始化系统,使用初始化脚本
mysql -u root -p < init.sql
```
**重要说明**:
- 第一次安装推荐使用 `install.sql` 脚本
- 如果系统已运行,使用 `init.sql` 会保留现有数据
- 安装脚本会自动处理数据迁移和分组关联
### 3. 安装依赖
```bash
# 安装后端依赖
@@ -187,6 +250,26 @@ ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456
- 生产环境请使用强密码生成器创建安全的密钥
- 不要将真实的密钥提交到版本控制系统
### 4.5 数据库初始化验证
数据库初始化完成后,后端服务会自动创建必要的表结构。如果遇到表结构错误,可以手动执行:
```bash
# 检查数据库表结构
mysql -u root -p -D webssh -e "SHOW TABLES;"
# 验证表结构
mysql -u root -p -D webssh -e "DESCRIBE servers; DESCRIBE \`groups\`;"
# 如果表结构不完整,执行更新脚本
mysql -u root -p < update_tables.sql
```
**常见问题解决**:
- 如果遇到 `Unknown column 'group_id'` 错误,运行 `update_tables.sql`
- 如果遇到 `groups` 表语法错误,确保表名使用反引号
- 如果分组数据不显示,运行 `generate_groups_data.sql`
### 5. 启动应用
#### 开发模式(推荐)
+17 -1
View File
@@ -50,6 +50,20 @@ const initializeTables = async () => {
)
`);
// Groups table
await pool.execute(`
CREATE TABLE IF NOT EXISTS \`groups\` (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_user_group (user_id, name)
)
`);
// Servers table (with encrypted credentials)
await pool.execute(`
CREATE TABLE IF NOT EXISTS servers (
@@ -62,10 +76,12 @@ const initializeTables = async () => {
password_encrypted TEXT,
private_key_encrypted TEXT,
auth_type ENUM('password', 'key') DEFAULT 'password',
group_id INT,
group_name VARCHAR(255) DEFAULT 'Default',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES \`groups\`(id) ON DELETE SET NULL
)
`);
+226 -12
View File
@@ -29,15 +29,23 @@ router.get('/', authenticateToken, async (req, res) => {
try {
const [servers] = await db.pool.execute(
`SELECT
id, name, host, port, username, auth_type, group_name,
created_at, updated_at
FROM servers
WHERE user_id = ?
ORDER BY group_name, name`,
s.id, s.name, s.host, s.port, s.username, s.auth_type,
s.group_id, s.group_name, g.name as group_name_actual,
s.created_at, s.updated_at
FROM servers s
LEFT JOIN \`groups\` g ON s.group_id = g.id
WHERE s.user_id = ?
ORDER BY COALESCE(g.name, s.group_name), s.name`,
[req.user.userId]
);
res.json({ servers });
// 统一分组名称显示
const processedServers = servers.map(server => ({
...server,
group_name: server.group_name_actual || server.group_name
}));
res.json({ servers: processedServers });
} catch (error) {
console.error('获取服务器列表错误:', error);
res.status(500).json({ error: '服务器内部错误' });
@@ -75,11 +83,34 @@ router.post('/', authenticateToken, async (req, res) => {
privateKeyEncrypted = encryptionService.encryptPrivateKey(privateKey, req.user.userId);
}
// 处理分组信息
let groupId = null;
let finalGroupName = groupName || 'Default';
// 查找对应的分组ID
if (groupName) {
const [groups] = await db.pool.execute(
'SELECT id FROM \`groups\` WHERE user_id = ? AND name = ?',
[req.user.userId, groupName]
);
if (groups.length > 0) {
groupId = groups[0].id;
} else {
// 如果分组不存在,创建新分组
const [groupResult] = await db.pool.execute(
'INSERT INTO \`groups\` (user_id, name, description) VALUES (?, ?, ?)',
[req.user.userId, groupName, `${groupName} 分组`]
);
groupId = groupResult.insertId;
}
}
// 插入服务器记录
const [result] = await db.pool.execute(
`INSERT INTO servers
(user_id, name, host, port, username, password_encrypted, private_key_encrypted, auth_type, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(user_id, name, host, port, username, password_encrypted, private_key_encrypted, auth_type, group_id, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
req.user.userId,
name,
@@ -89,7 +120,8 @@ router.post('/', authenticateToken, async (req, res) => {
passwordEncrypted,
privateKeyEncrypted,
authType || 'password',
groupName || 'Default'
groupId,
finalGroupName
]
);
@@ -152,6 +184,28 @@ router.put('/:id', authenticateToken, async (req, res) => {
}
if (groupName) {
// 处理分组信息
let groupId = null;
// 查找对应的分组ID
const [groups] = await db.pool.execute(
'SELECT id FROM `groups` WHERE user_id = ? AND name = ?',
[req.user.userId, groupName]
);
if (groups.length > 0) {
groupId = groups[0].id;
} else {
// 如果分组不存在,创建新分组
const [groupResult] = await db.pool.execute(
'INSERT INTO `groups` (user_id, name, description) VALUES (?, ?, ?)',
[req.user.userId, groupName, `${groupName} 分组`]
);
groupId = groupResult.insertId;
}
updateFields.push('group_id = ?');
updateValues.push(groupId);
updateFields.push('group_name = ?');
updateValues.push(groupName);
}
@@ -218,18 +272,178 @@ router.delete('/:id', authenticateToken, async (req, res) => {
router.get('/groups', authenticateToken, async (req, res) => {
try {
const [groups] = await db.pool.execute(
'SELECT DISTINCT group_name FROM servers WHERE user_id = ? ORDER BY group_name',
'SELECT id, name, description, created_at, updated_at FROM `groups` WHERE user_id = ? ORDER BY name',
[req.user.userId]
);
const groupNames = groups.map(group => group.group_name);
res.json({ groups: groupNames });
// 获取每个分组下的服务器数量
for (let group of groups) {
const [serverCount] = await db.pool.execute(
'SELECT COUNT(*) as count FROM servers WHERE group_id = ? AND user_id = ?',
[group.id, req.user.userId]
);
group.serverCount = serverCount[0].count;
}
res.json({ groups });
} catch (error) {
console.error('获取服务器分组错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 添加服务器分组
router.post('/groups', authenticateToken, async (req, res) => {
try {
const { name, description } = req.body;
// 验证必填字段
if (!name) {
return res.status(400).json({ error: '分组名称是必填项' });
}
// 检查分组名称是否已存在
const [existingGroups] = await db.pool.execute(
'SELECT id FROM `groups` WHERE user_id = ? AND name = ?',
[req.user.userId, name]
);
if (existingGroups.length > 0) {
return res.status(400).json({ error: '分组名称已存在' });
}
// 插入新的分组记录
const [result] = await db.pool.execute(
'INSERT INTO `groups` (user_id, name, description) VALUES (?, ?, ?)',
[req.user.userId, name, description || `${name} 分组`]
);
// 获取新创建的分组详情
const [newGroup] = await db.pool.execute(
'SELECT id, name, description, created_at, updated_at FROM `groups` WHERE id = ?',
[result.insertId]
);
res.status(201).json({
message: '分组添加成功',
group: {
...newGroup[0],
serverCount: 0
}
});
} catch (error) {
console.error('添加分组错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 更新服务器分组
router.put('/groups/:id', authenticateToken, async (req, res) => {
try {
const groupId = req.params.id;
const { name, description } = req.body;
// 验证必填字段
if (!name) {
return res.status(400).json({ error: '分组名称是必填项' });
}
// 检查分组是否存在且属于当前用户
const [existingGroup] = await db.pool.execute(
'SELECT id FROM `groups` WHERE id = ? AND user_id = ?',
[groupId, req.user.userId]
);
if (existingGroup.length === 0) {
return res.status(404).json({ error: '分组不存在' });
}
// 检查新名称是否已存在(排除当前分组)
const [duplicateGroups] = await db.pool.execute(
'SELECT id FROM `groups` WHERE user_id = ? AND name = ? AND id != ?',
[req.user.userId, name, groupId]
);
if (duplicateGroups.length > 0) {
return res.status(400).json({ error: '分组名称已存在' });
}
// 更新分组信息
await db.pool.execute(
'UPDATE `groups` SET name = ?, description = ? WHERE id = ? AND user_id = ?',
[name, description || `${name} 分组`, groupId, req.user.userId]
);
// 获取更新后的分组详情
const [updatedGroup] = await db.pool.execute(
'SELECT id, name, description, created_at, updated_at FROM `groups` WHERE id = ?',
[groupId]
);
// 获取服务器数量
const [serverCount] = await db.pool.execute(
'SELECT COUNT(*) as count FROM servers WHERE group_id = ? AND user_id = ?',
[groupId, req.user.userId]
);
res.json({
message: '分组更新成功',
group: {
...updatedGroup[0],
serverCount: serverCount[0].count
}
});
} catch (error) {
console.error('更新分组错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 删除服务器分组
router.delete('/groups/:id', authenticateToken, async (req, res) => {
try {
const groupId = req.params.id;
// 检查分组是否存在且属于当前用户
const [existingGroup] = await db.pool.execute(
'SELECT id FROM `groups` WHERE id = ? AND user_id = ?',
[groupId, req.user.userId]
);
if (existingGroup.length === 0) {
return res.status(404).json({ error: '分组不存在' });
}
// 获取该分组下的服务器数量
const [serverCount] = await db.pool.execute(
'SELECT COUNT(*) as count FROM servers WHERE group_id = ? AND user_id = ?',
[groupId, req.user.userId]
);
const count = serverCount[0].count;
// 如果分组中有服务器,不允许删除
if (count > 0) {
return res.status(400).json({
error: `分组中包含 ${count} 个服务器,请先删除或移出所有服务器后再删除分组`
});
}
// 删除分组
await db.pool.execute(
'DELETE FROM `groups` WHERE id = ? AND user_id = ?',
[groupId, req.user.userId]
);
res.json({
message: '分组删除成功'
});
} catch (error) {
console.error('删除分组错误:', error);
res.status(500).json({ error: '服务器内部错误' });
}
});
// 获取服务器详情(包含解密后的认证信息)
router.get('/:id/credentials', authenticateToken, async (req, res) => {
try {
+73 -15
View File
@@ -79,6 +79,10 @@ const props = defineProps({
serverId: {
type: Number,
default: null
},
groupName: {
type: String,
default: ''
}
})
@@ -99,20 +103,12 @@ const form = reactive({
authType: 'password',
password: '',
privateKey: '',
groupName: 'Default'
groupName: props.groupName || 'Default'
})
// 获取已存在的分组列表
// 获取分组列表
const existingGroups = computed(() => {
const groups = new Set()
serversStore.servers.forEach(server => {
// 兼容 groupName 和 group_name 字段
const groupName = server.groupName || server.group_name
if (groupName) {
groups.add(groupName)
}
})
return Array.from(groups)
return serversStore.groups.map(group => group.name)
})
const rules = {
@@ -198,6 +194,11 @@ const loadServerData = async () => {
loading.value = true
try {
// 确保服务器列表已加载
if (serversStore.servers.length === 0) {
await serversStore.fetchServers()
}
const result = await serversStore.getServerCredentials(props.serverId)
if (result.success) {
const { server, credentials } = result.data
@@ -207,7 +208,7 @@ const loadServerData = async () => {
form.port = server.port
form.username = server.username
form.authType = server.authType
form.groupName = server.groupName
form.groupName = server.group_name || server.groupName || 'Default'
if (server.authType === 'password') {
form.password = credentials.password || ''
@@ -224,10 +225,67 @@ const loadServerData = async () => {
}
}
// 监听分组数据变化,确保分组选择器有正确的数据
watch(() => serversStore.groups, (newGroups) => {
if (newGroups.length > 0 && !props.serverId) {
// 添加模式下,确保分组数据正确设置
if (props.groupName) {
form.groupName = props.groupName
} else {
form.groupName = newGroups[0].name
}
}
}, { immediate: true })
// 监听serverId变化,确保每次打开编辑对话框都重新加载数据
watch(() => props.serverId, (newServerId) => {
if (newServerId) {
// 重置表单数据,确保重新加载
Object.keys(form).forEach(key => {
if (key === 'port') {
form[key] = 22
} else if (key === 'authType') {
form[key] = 'password'
} else if (key === 'groupName') {
// 编辑模式下重置为默认值
form[key] = 'Default'
} else {
form[key] = ''
}
})
newGroupName.value = ''
// 延迟加载数据,确保DOM已更新
setTimeout(() => {
loadServerData()
}, 0)
} else {
// 添加模式下,确保分组数据正确设置
if (props.groupName) {
form.groupName = props.groupName
} else if (serversStore.groups.length > 0) {
form.groupName = serversStore.groups[0].name
}
}
}, { immediate: true })
// 组件挂载时加载数据
onMounted(() => {
if (props.serverId) {
loadServerData()
onMounted(async () => {
// 确保分组数据已加载
if (serversStore.groups.length === 0) {
await serversStore.fetchGroups()
}
// 添加模式下,确保分组数据正确设置
if (!props.serverId) {
// 如果传入了分组名称,使用传入的分组
if (props.groupName) {
form.groupName = props.groupName
}
// 如果没有分组数据,确保至少有一个默认分组
else if (serversStore.groups.length > 0) {
form.groupName = serversStore.groups[0].name
}
}
})
+6
View File
@@ -37,6 +37,12 @@ const routes = [
name: 'TerminalNew',
component: () => import('@/views/TerminalNew.vue'),
meta: { requiresAuth: true }
},
{
path: '/group-management',
name: 'GroupManagement',
component: () => import('@/views/GroupManagement.vue'),
meta: { requiresAuth: true }
}
]
+68 -1
View File
@@ -142,6 +142,69 @@ export const useServersStore = defineStore('servers', () => {
return servers.value.filter(server => server.group_name === groupName)
}
// 添加服务器分组
const addGroup = async (groupData) => {
try {
const response = await axios.post('/api/servers/groups', groupData, {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
await fetchGroups()
return { success: true, data: response.data }
} catch (error) {
return {
success: false,
error: error.response?.data?.error || '添加分组失败'
}
}
}
// 更新服务器分组
const updateGroup = async (groupId, groupData) => {
try {
await axios.put(`/api/servers/groups/${groupId}`, groupData, {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
await fetchGroups()
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.error || '更新分组失败'
}
}
}
// 删除服务器分组
const deleteGroup = async (groupId, deleteServers = false) => {
try {
await axios.delete(`/api/servers/groups/${groupId}?deleteServers=${deleteServers}`, {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
await fetchGroups()
await fetchServers()
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.error || '删除分组失败'
}
}
}
// 获取所有分组名称(用于表单选择)
const getGroupNames = () => {
return groups.value.map(group => group.name)
}
return {
servers,
groups,
@@ -152,6 +215,10 @@ export const useServersStore = defineStore('servers', () => {
deleteServer,
getServerCredentials,
testConnection,
getServersByGroup
getServersByGroup,
addGroup,
updateGroup,
deleteGroup,
getGroupNames
}
})
+1 -1
View File
@@ -112,7 +112,7 @@
</div>
</el-col>
<el-col :span="6">
<div class="feature-card">
<div class="feature-card" @click="$router.push('/group-management')" style="cursor: pointer;">
<el-icon size="48" color="#E6A23C"><Folder /></el-icon>
<h3>分组管理</h3>
<p>支持服务器分组,方便管理和快速访问</p>
+610
View File
@@ -0,0 +1,610 @@
<template>
<div class="group-management">
<!-- 顶部导航栏 -->
<el-header class="header">
<div class="header-content">
<div class="header-left">
<el-button @click="$router.back()" size="small">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h1>分组管理</h1>
</div>
<div class="header-right">
<el-button type="primary" @click="showAddGroupDialog = true">
<el-icon><Plus /></el-icon>
添加分组
</el-button>
</div>
</div>
</el-header>
<el-main class="main-content">
<!-- 分组列表 -->
<div class="groups-container">
<el-row :gutter="20">
<el-col :span="8" v-for="group in groups" :key="group.id">
<el-card class="group-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="group-info">
<el-icon class="group-icon"><Folder /></el-icon>
<span class="group-name">{{ group.name }}</span>
</div>
<div class="card-actions">
<el-button
size="small"
text
@click="editGroup(group)"
title="编辑分组"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
size="small"
text
@click="deleteGroup(group)"
title="删除分组"
style="color: #f56c6c;"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
<!-- 分组服务器列表 -->
<div class="servers-list">
<div
v-for="server in getServersByGroup(group.name)"
:key="server.id"
class="server-item"
>
<div class="server-info">
<el-icon><Monitor /></el-icon>
<div class="server-details">
<div class="server-name">{{ server.name }}</div>
<div class="server-address">{{ server.host }}:{{ server.port }}</div>
</div>
</div>
<div class="server-actions">
<el-button
size="small"
text
@click="editServer(server)"
title="编辑服务器"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
size="small"
text
@click="deleteServer(server)"
title="删除服务器"
style="color: #f56c6c;"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<!-- 添加服务器按钮 -->
<div class="add-server-btn">
<el-button
type="primary"
size="small"
text
@click="addServerToGroup(group)"
style="width: 100%;"
>
<el-icon><Plus /></el-icon>
添加服务器
</el-button>
</div>
<div v-if="getServersByGroup(group.name).length === 0" class="empty-servers">
<el-icon><InfoFilled /></el-icon>
<span>该分组暂无服务器</span>
</div>
</div>
<!-- 分组统计 -->
<div class="group-stats">
<span class="server-count">
服务器数量: {{ getServersByGroup(group.name).length }}
</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 空状态提示 -->
<div v-if="groups.length === 0" class="empty-state">
<el-icon size="64" color="#909399"><FolderOpened /></el-icon>
<h3>暂无分组</h3>
<p>点击"添加分组"按钮创建您的第一个服务器分组</p>
<el-button type="primary" @click="showAddGroupDialog = true">
<el-icon><Plus /></el-icon>
添加分组
</el-button>
</div>
</div>
</el-main>
<!-- 添加分组对话框 -->
<el-dialog v-model="showAddGroupDialog" title="添加分组" width="400px">
<el-form :model="newGroup" :rules="groupRules" ref="addGroupForm">
<el-form-item label="分组名称" prop="name">
<el-input v-model="newGroup.name" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input
v-model="newGroup.description"
type="textarea"
:rows="3"
placeholder="请输入分组描述(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddGroupDialog = false">取消</el-button>
<el-button type="primary" @click="handleAddGroup" :loading="addingGroup">
确认添加
</el-button>
</template>
</el-dialog>
<!-- 编辑分组对话框 -->
<el-dialog v-model="showEditGroupDialog" :title="'编辑分组 - ' + editingGroup.name" width="400px">
<el-form :model="editingGroup" :rules="groupRules" ref="editGroupForm">
<el-form-item label="分组名称" prop="name">
<el-input v-model="editingGroup.name" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input
v-model="editingGroup.description"
type="textarea"
:rows="3"
placeholder="请输入分组描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditGroupDialog = false">取消</el-button>
<el-button type="primary" @click="handleUpdateGroup" :loading="updatingGroup">
保存修改
</el-button>
</template>
</el-dialog>
<!-- 编辑服务器对话框 -->
<el-dialog v-model="showEditServerDialog" :title="'编辑服务器 - ' + editingServer.name" width="500px">
<ServerForm
v-if="editingServer.id"
:server-id="editingServer.id"
@success="handleServerUpdated"
@cancel="showEditServerDialog = false"
/>
</el-dialog>
<!-- 添加服务器对话框 -->
<el-dialog v-model="showAddServerDialog" :title="'添加服务器到 ' + addingServerToGroup.groupName + ' 分组'" width="500px">
<ServerForm
:group-name="addingServerToGroup.groupName"
@success="handleServerAdded"
@cancel="showAddServerDialog = false"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
Plus,
Folder,
Edit,
Delete,
Monitor,
FolderOpened,
InfoFilled
} from '@element-plus/icons-vue'
import { useServersStore } from '@/stores/servers'
import ServerForm from '@/components/ServerForm.vue'
const serversStore = useServersStore()
// 响应式数据
const showAddGroupDialog = ref(false)
const showEditGroupDialog = ref(false)
const showEditServerDialog = ref(false)
const showAddServerDialog = ref(false)
const addingGroup = ref(false)
const updatingGroup = ref(false)
const newGroup = ref({
name: '',
description: ''
})
const editingGroup = ref({
id: null,
name: '',
description: ''
})
const editingServer = ref({
id: null,
name: ''
})
const addingServerToGroup = ref({
groupName: ''
})
const groupRules = {
name: [
{ required: true, message: '请输入分组名称', trigger: 'blur' },
{ min: 1, max: 50, message: '分组名称长度在 1 到 50 个字符', trigger: 'blur' }
]
}
// 计算属性
const groups = computed(() => serversStore.groups)
// 方法:根据分组名称获取服务器列表
const getServersByGroup = (groupName) => {
return serversStore.servers.filter(server => server.group_name === groupName)
}
// 方法:添加分组
const handleAddGroup = async () => {
try {
addingGroup.value = true
const result = await serversStore.addGroup(newGroup.value)
if (result.success) {
ElMessage.success('分组添加成功')
showAddGroupDialog.value = false
newGroup.value = { name: '', description: '' }
} else {
ElMessage.error(result.error)
}
} catch (error) {
ElMessage.error('添加分组失败')
} finally {
addingGroup.value = false
}
}
// 方法:编辑分组
const editGroup = (group) => {
editingGroup.value = { ...group }
showEditGroupDialog.value = true
}
const handleUpdateGroup = async () => {
try {
updatingGroup.value = true
const result = await serversStore.updateGroup(editingGroup.value.id, editingGroup.value)
if (result.success) {
ElMessage.success('分组更新成功')
showEditGroupDialog.value = false
editingGroup.value = { id: null, name: '', description: '' }
} else {
ElMessage.error(result.error)
}
} catch (error) {
ElMessage.error('更新分组失败')
} finally {
updatingGroup.value = false
}
}
// 方法:删除分组
const deleteGroup = async (group) => {
try {
const serversInGroup = getServersByGroup(group.name)
// 如果分组中有服务器,不允许删除
if (serversInGroup.length > 0) {
ElMessage.error(`分组 "${group.name}" 中包含 ${serversInGroup.length} 个服务器,请先删除或移出所有服务器后再删除分组`)
return
}
// 确认删除空分组
await ElMessageBox.confirm(
`确定要删除分组 "${group.name}" 吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const result = await serversStore.deleteGroup(group.id)
if (result.success) {
ElMessage.success('分组删除成功')
} else {
ElMessage.error(result.error)
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败,请重试')
}
}
}
// 方法:编辑服务器
const editServer = (server) => {
// 重置表单数据,确保每次编辑都从后台获取最新数据
editingServer.value = {
id: server.id,
name: server.name // 保留名称用于对话框标题
}
showEditServerDialog.value = true
}
// 方法:删除服务器
const deleteServer = async (server) => {
try {
await ElMessageBox.confirm(
`确定要删除服务器 "${server.name}" 吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const result = await serversStore.deleteServer(server.id)
if (result.success) {
ElMessage.success('服务器删除成功')
} else {
ElMessage.error(result.error)
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败,请重试')
}
}
}
// 方法:添加服务器到分组
const addServerToGroup = (group) => {
addingServerToGroup.value = { groupName: group.name }
showAddServerDialog.value = true
}
// 方法:服务器添加成功处理
const handleServerAdded = () => {
showAddServerDialog.value = false
addingServerToGroup.value = { groupName: '' }
ElMessage.success('服务器添加成功')
}
// 方法:服务器更新成功处理
const handleServerUpdated = () => {
showEditServerDialog.value = false
editingServer.value = { id: null, name: '' }
ElMessage.success('服务器更新成功')
}
// 生命周期
onMounted(async () => {
await serversStore.fetchServers()
})
</script>
<style scoped>
.group-management {
height: 100vh;
background-color: #f5f5f5;
}
.header {
background-color: white;
border-bottom: 1px solid #e4e7ed;
padding: 0 20px;
height: 60px;
display: flex;
align-items: center;
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-left h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
.main-content {
padding: 20px;
height: calc(100vh - 60px);
overflow-y: auto;
}
.groups-container {
max-width: 1200px;
margin: 0 auto;
}
.group-card {
margin-bottom: 20px;
transition: all 0.3s ease;
}
.group-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.group-info {
display: flex;
align-items: center;
gap: 8px;
}
.group-icon {
color: #409eff;
font-size: 18px;
}
.group-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.card-actions {
display: flex;
gap: 4px;
}
.servers-list {
margin-bottom: 16px;
}
.server-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.server-item:last-child {
border-bottom: none;
}
.server-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.server-details {
min-width: 0;
}
.server-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.server-address {
font-size: 12px;
color: #999;
}
.server-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
.server-item:hover .server-actions {
opacity: 1;
}
.empty-servers {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #999;
font-size: 14px;
gap: 8px;
}
.group-stats {
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.server-count {
font-size: 12px;
color: #999;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: #909399;
}
.empty-state h3 {
margin: 16px 0 8px;
font-size: 18px;
font-weight: 500;
}
.empty-state p {
margin-bottom: 20px;
font-size: 14px;
}
/* 添加服务器按钮样式 */
.add-server-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
margin-top: 8px;
border-top: 1px dashed #e0e0e0;
}
.add-server-btn .el-button {
color: #409eff;
border-color: #409eff;
}
.add-server-btn .el-button:hover {
background-color: #ecf5ff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.el-col {
width: 100%;
}
.server-actions {
opacity: 1;
}
}
</style>
+89
View File
@@ -0,0 +1,89 @@
/*
Navicat Premium Data Transfer
Source Server : webssh
Source Server Type : MySQL
Source Server Version : 80012
Source Host : localhost:3306
Source Schema : webssh
Target Server Type : MySQL
Target Server Version : 80012
File Encoding : 65001
Date: 11/02/2026 08:53:32
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for connection_logs
-- ----------------------------
DROP TABLE IF EXISTS `connection_logs`;
CREATE TABLE `connection_logs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`server_id` int(11) NULL DEFAULT NULL,
`host` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`start_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`end_time` timestamp NULL DEFAULT NULL,
`duration` int(11) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `user_id`(`user_id`) USING BTREE,
INDEX `server_id`(`server_id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for groups
-- ----------------------------
DROP TABLE IF EXISTS `groups`;
CREATE TABLE `groups` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`description` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `unique_user_group`(`user_id`, `name`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for servers
-- ----------------------------
DROP TABLE IF EXISTS `servers`;
CREATE TABLE `servers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`host` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`port` int(11) NULL DEFAULT 22,
`username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password_encrypted` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`private_key_encrypted` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`auth_type` enum('password','key') CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT 'password',
`group_id` int(11) NULL DEFAULT NULL,
`group_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT 'Default',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
INDEX `user_id`(`user_id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 6 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`phone` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`password_hash` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `email`(`email`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;