4ba45abf38
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录 - 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除 - 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
413 lines
16 KiB
Vue
413 lines
16 KiB
Vue
<template>
|
||
<v-container fluid class="pa-6">
|
||
<v-card elevation="0" border rounded="lg">
|
||
<v-card-title class="d-flex align-center">
|
||
凭据管理
|
||
<v-spacer />
|
||
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined" class="mr-3">
|
||
<v-btn size="small" value="passwords">密码预设</v-btn>
|
||
<v-btn size="small" value="sshkeys">SSH 密钥</v-btn>
|
||
<v-btn size="small" value="db">数据库凭据</v-btn>
|
||
</v-btn-toggle>
|
||
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
|
||
</v-card-title>
|
||
|
||
<v-divider />
|
||
|
||
<template v-if="credTab === 'passwords'">
|
||
<v-card-text class="text-body-2 text-medium-emphasis pt-0 pb-2">
|
||
保存常用 SSH 账号+密码模板;快速添加服务器时将按顺序轮询所有预设尝试登录。
|
||
</v-card-text>
|
||
<v-data-table
|
||
:items="passwords"
|
||
:headers="passwordHeaders"
|
||
:loading="loading"
|
||
hover
|
||
density="comfortable"
|
||
:items-per-page="20"
|
||
>
|
||
<template #item.created_at="{ item }">
|
||
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
|
||
</template>
|
||
<template #item.actions="{ item }">
|
||
<v-btn variant="text" size="x-small" density="compact" @click="editPassword(item)">编辑</v-btn>
|
||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('password', item)">删除</v-btn>
|
||
</template>
|
||
<template #no-data>
|
||
<div class="text-center text-medium-emphasis py-6">暂无密码预设</div>
|
||
</template>
|
||
</v-data-table>
|
||
</template>
|
||
|
||
<!-- SSH Keys Tab -->
|
||
<template v-if="credTab === 'sshkeys'">
|
||
<v-data-table-server
|
||
:items="sshKeys"
|
||
:headers="sshKeyHeaders"
|
||
:items-length="totalItems"
|
||
:loading="loading"
|
||
:page="page"
|
||
:items-per-page="itemsPerPage"
|
||
hover
|
||
density="comfortable"
|
||
@update:page="(p: number) => { page = p; loadSshKeys() }"
|
||
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadSshKeys() }"
|
||
>
|
||
<template #item.private_key_set="{ item }">
|
||
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
|
||
{{ item.private_key_set ? '已设置' : '未设置' }}
|
||
</v-chip>
|
||
</template>
|
||
<template #item.actions="{ item }">
|
||
<v-btn variant="text" size="x-small" density="compact" @click="editSshKey(item)">编辑</v-btn>
|
||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
|
||
</template>
|
||
<template #no-data>
|
||
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
|
||
</template>
|
||
</v-data-table-server>
|
||
</template>
|
||
|
||
<!-- DB Credentials Tab -->
|
||
<template v-if="credTab === 'db'">
|
||
<v-data-table-server
|
||
:items="dbCreds"
|
||
:headers="dbCredHeaders"
|
||
:items-length="totalItems"
|
||
:loading="loading"
|
||
:page="page"
|
||
:items-per-page="itemsPerPage"
|
||
hover
|
||
density="comfortable"
|
||
@update:page="(p: number) => { page = p; loadDbCreds() }"
|
||
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadDbCreds() }"
|
||
>
|
||
<template #item.actions="{ item }">
|
||
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
|
||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('db', item)">删除</v-btn>
|
||
</template>
|
||
<template #no-data>
|
||
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
|
||
</template>
|
||
</v-data-table-server>
|
||
</template>
|
||
</v-card>
|
||
|
||
<!-- Password Dialog -->
|
||
<v-dialog v-model="showPasswordDialog" max-width="500">
|
||
<v-card border>
|
||
<v-card-title>{{ editingId ? '编辑密码预设' : '新建密码预设' }}</v-card-title>
|
||
<v-card-text>
|
||
<v-text-field
|
||
v-model="pwForm.name"
|
||
label="预设名称"
|
||
variant="outlined"
|
||
density="compact"
|
||
class="mb-2"
|
||
placeholder="如:生产环境 root 密码"
|
||
:rules="[required('预设名称')]"
|
||
/>
|
||
<v-text-field
|
||
v-model="pwForm.username"
|
||
label="SSH 用户名"
|
||
variant="outlined"
|
||
density="compact"
|
||
class="mb-2"
|
||
placeholder="root"
|
||
:rules="[required('SSH 用户名')]"
|
||
/>
|
||
<v-text-field
|
||
v-model="pwForm.password"
|
||
label="SSH 密码"
|
||
variant="outlined"
|
||
density="compact"
|
||
type="password"
|
||
:placeholder="editingId ? '留空表示不修改' : '必填'"
|
||
:rules="editingId ? [] : [required('SSH 密码')]"
|
||
/>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="showPasswordDialog = false">取消</v-btn>
|
||
<v-btn color="primary" variant="flat" @click="savePassword">保存</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- SSH Key Dialog -->
|
||
<v-dialog v-model="showSshKeyDialog" max-width="500">
|
||
<v-card border>
|
||
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
|
||
<v-card-text>
|
||
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
|
||
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
|
||
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
|
||
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="showSshKeyDialog = false">取消</v-btn>
|
||
<v-btn color="primary" variant="flat" @click="saveSshKey">保存</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- DB Credential Dialog -->
|
||
<v-dialog v-model="showDbCredDialog" max-width="500">
|
||
<v-card border>
|
||
<v-card-title>{{ editingId ? '编辑数据库凭据' : '新建数据库凭据' }}</v-card-title>
|
||
<v-card-text>
|
||
<v-text-field v-model="dbForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
|
||
<v-text-field v-model="dbForm.host" label="主机" variant="outlined" density="compact" class="mb-2" />
|
||
<v-text-field v-model="dbForm.port" label="端口" variant="outlined" density="compact" type="number" class="mb-2" />
|
||
<v-text-field v-model="dbForm.username" label="用户名" variant="outlined" density="compact" class="mb-2" />
|
||
<v-text-field v-model="dbForm.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
|
||
<v-text-field v-model="dbForm.database" label="数据库" variant="outlined" density="compact" />
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="showDbCredDialog = false">取消</v-btn>
|
||
<v-btn color="primary" variant="flat" @click="saveDbCred">保存</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Delete Confirm -->
|
||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||
<v-card border>
|
||
<v-card-title>确认删除</v-card-title>
|
||
<v-card-text>确定要删除此凭据吗?</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
|
||
<v-btn color="error" variant="flat" @click="doDelete">删除</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, onMounted } from 'vue'
|
||
import { http } from '@/api'
|
||
import { useSnackbar } from '@/composables/useSnackbar'
|
||
import { formatApiError } from '@/utils/apiError'
|
||
import { required } from '@/utils/validation'
|
||
|
||
const snackbar = useSnackbar()
|
||
|
||
function formatPresetTime(iso: string | undefined): string {
|
||
if (!iso) return '—'
|
||
const d = new Date(iso)
|
||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
|
||
// ── Credential types ──
|
||
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
|
||
interface SshKeyItem { id: number; name: string; username?: string; public_key?: string; private_key_set?: boolean; description?: string }
|
||
interface DbCredItem { id: number; name: string; db_type?: string; host: string; port: number; username: string; password_set?: boolean; database?: string }
|
||
|
||
// ── State ──
|
||
const loading = ref(false)
|
||
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
|
||
const editingId = ref<number | null>(null)
|
||
|
||
// Pagination
|
||
const page = ref(1)
|
||
const totalItems = ref(0)
|
||
const itemsPerPage = ref(20)
|
||
|
||
// Password presets
|
||
const passwords = ref<PasswordItem[]>([])
|
||
const showPasswordDialog = ref(false)
|
||
const pwForm = ref({ name: '', username: 'root', password: '' })
|
||
const passwordHeaders = [
|
||
{ title: '预设名称', key: 'name' },
|
||
{ title: '用户名', key: 'username', width: 120 },
|
||
{ title: '创建时间', key: 'created_at', width: 180 },
|
||
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
|
||
]
|
||
|
||
// SSH keys
|
||
const sshKeys = ref<SshKeyItem[]>([])
|
||
const showSshKeyDialog = ref(false)
|
||
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
|
||
const sshKeyHeaders = [
|
||
{ title: '名称', key: 'name' },
|
||
{ title: '用户名', key: 'username', width: 120 },
|
||
{ title: '私钥', key: 'private_key_set', width: 120 },
|
||
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
|
||
]
|
||
|
||
// DB credentials
|
||
const dbCreds = ref<DbCredItem[]>([])
|
||
const showDbCredDialog = ref(false)
|
||
const dbForm = ref({ name: '', host: '', port: 3306, username: '', password: '', database: '' })
|
||
const dbCredHeaders = [
|
||
{ title: '名称', key: 'name' },
|
||
{ title: '主机', key: 'host' },
|
||
{ title: '端口', key: 'port', width: 80 },
|
||
{ title: '用户名', key: 'username' },
|
||
{ title: '数据库', key: 'database' },
|
||
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
|
||
]
|
||
|
||
// Delete
|
||
const showDeleteDialog = ref(false)
|
||
const deleteType = ref('')
|
||
const deleteId = ref<number | null>(null)
|
||
|
||
// ── Data loading ──
|
||
async function loadPasswords() {
|
||
loading.value = true
|
||
try {
|
||
const res = await http.getList<PasswordItem>('/presets/')
|
||
passwords.value = res.items
|
||
totalItems.value = res.total
|
||
} catch (e: unknown) {
|
||
passwords.value = []
|
||
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
|
||
}
|
||
finally { loading.value = false }
|
||
}
|
||
async function loadSshKeys() {
|
||
loading.value = true
|
||
try {
|
||
const res = await http.getList<SshKeyItem>('/ssh-key-presets/', {
|
||
page: page.value,
|
||
per_page: itemsPerPage.value,
|
||
})
|
||
sshKeys.value = res.items
|
||
totalItems.value = res.total
|
||
} catch (e: unknown) {
|
||
sshKeys.value = []
|
||
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
|
||
}
|
||
finally { loading.value = false }
|
||
}
|
||
async function loadDbCreds() {
|
||
loading.value = true
|
||
try {
|
||
const res = await http.getList<DbCredItem>('/scripts/credentials', {
|
||
page: page.value,
|
||
per_page: itemsPerPage.value,
|
||
})
|
||
dbCreds.value = res.items
|
||
totalItems.value = res.total
|
||
} catch (e: unknown) {
|
||
dbCreds.value = []
|
||
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
|
||
}
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
function loadCurrentTab() {
|
||
switch (credTab.value) {
|
||
case 'passwords': loadPasswords(); break
|
||
case 'sshkeys': loadSshKeys(); break
|
||
case 'db': loadDbCreds(); break
|
||
}
|
||
}
|
||
|
||
watch(credTab, () => { page.value = 1; loadCurrentTab() })
|
||
|
||
// ── Password CRUD ──
|
||
function editPassword(item: PasswordItem) {
|
||
editingId.value = item.id
|
||
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
|
||
showPasswordDialog.value = true
|
||
}
|
||
|
||
async function savePassword() {
|
||
const name = pwForm.value.name.trim()
|
||
const password = pwForm.value.password
|
||
if (!name) {
|
||
snackbar('请填写预设名称', 'error')
|
||
return
|
||
}
|
||
if (!editingId.value && !password) {
|
||
snackbar('请填写 SSH 密码', 'error')
|
||
return
|
||
}
|
||
try {
|
||
const username = pwForm.value.username.trim() || 'root'
|
||
if (editingId.value) {
|
||
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
|
||
if (password) body.encrypted_pw = password
|
||
await http.put(`/presets/${editingId.value}`, body)
|
||
} else {
|
||
await http.post('/presets/', { name, username, encrypted_pw: password })
|
||
}
|
||
showPasswordDialog.value = false
|
||
loadPasswords()
|
||
snackbar('保存成功')
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||
}
|
||
}
|
||
|
||
// ── SSH Key CRUD ──
|
||
function editSshKey(item: SshKeyItem) {
|
||
editingId.value = item.id
|
||
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
|
||
showSshKeyDialog.value = true
|
||
}
|
||
|
||
async function saveSshKey() {
|
||
try {
|
||
if (editingId.value) await http.put(`/ssh-key-presets/${editingId.value}`, skForm.value)
|
||
else await http.post('/ssh-key-presets/', skForm.value)
|
||
showSshKeyDialog.value = false
|
||
loadSshKeys()
|
||
snackbar('保存成功')
|
||
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
|
||
}
|
||
|
||
// ── DB Credential CRUD ──
|
||
function editDbCred(item: DbCredItem) {
|
||
editingId.value = item.id
|
||
dbForm.value = { name: item.name, host: item.host || '', port: item.port || 3306, username: item.username || '', password: '', database: item.database || '' }
|
||
showDbCredDialog.value = true
|
||
}
|
||
|
||
async function saveDbCred() {
|
||
try {
|
||
if (editingId.value) await http.put(`/scripts/credentials/${editingId.value}`, dbForm.value)
|
||
else await http.post('/scripts/credentials', dbForm.value)
|
||
showDbCredDialog.value = false
|
||
loadDbCreds()
|
||
snackbar('保存成功')
|
||
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
|
||
}
|
||
|
||
// ── Delete ──
|
||
function openAdd() {
|
||
editingId.value = null
|
||
if (credTab.value === 'passwords') { pwForm.value = { name: '', username: 'root', password: '' }; showPasswordDialog.value = true }
|
||
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', username: 'root', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
|
||
else { dbForm.value = { name: '', host: '', port: 3306, username: '', password: '', database: '' }; showDbCredDialog.value = true }
|
||
}
|
||
|
||
function confirmDelete(type: string, item: { id: number }) {
|
||
deleteType.value = type
|
||
deleteId.value = item.id
|
||
showDeleteDialog.value = true
|
||
}
|
||
|
||
async function doDelete() {
|
||
try {
|
||
if (deleteType.value === 'password') await http.delete(`/presets/${deleteId.value}`)
|
||
else if (deleteType.value === 'sshkey') await http.delete(`/ssh-key-presets/${deleteId.value}`)
|
||
else await http.delete(`/scripts/credentials/${deleteId.value}`)
|
||
showDeleteDialog.value = false
|
||
loadCurrentTab()
|
||
snackbar('已删除')
|
||
} catch (e: any) { snackbar(e.message || '删除失败', 'error') }
|
||
}
|
||
|
||
|
||
onMounted(() => {
|
||
loadCurrentTab()
|
||
})
|
||
</script>
|