Files
Nexus/frontend/src/components/credentials/CredentialsManager.vue
T

436 lines
16 KiB
Vue
Raw Normal View History

<template>
<div class="credentials-manager">
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined">
<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-spacer />
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
</div>
<template v-if="credTab === 'passwords'">
<p class="text-body-2 text-medium-emphasis mb-2">
保存常用 SSH 账号+密码模板快速添加服务器时将按顺序轮询所有预设尝试登录
</p>
<v-data-table
:items="passwords"
:headers="passwordHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<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>
<template v-else-if="credTab === 'sshkeys'">
<v-data-table-server
:items="sshKeys"
:headers="sshKeyHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadSshKeys() }"
@update:items-per-page="onItemsPerPageChange"
>
<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>
<template v-else>
<v-data-table-server
:items="dbCreds"
:headers="dbCredHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="onItemsPerPageChange"
>
<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-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>
<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>
<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>
<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>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
defineOptions({ name: 'CredentialsManager' })
const props = defineProps<{
/** 对话框打开时为 true,触发数据加载 */
active?: boolean
}>()
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
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 }
interface DbCredItem { id: number; name: string; host: string; port: number; username: string; database?: string }
const loading = ref(false)
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
const editingId = ref<number | null>(null)
const page = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(20)
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = headersWithoutSort([
{ title: '预设名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const sshKeys = ref<SshKeyItem[]>([])
const showSshKeyDialog = ref(false)
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
const sshKeyHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '私钥', key: 'private_key_set', width: 120 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const dbCreds = ref<DbCredItem[]>([])
const showDbCredDialog = ref(false)
const dbForm = ref({ name: '', host: '', port: 3306, username: '', password: '', database: '' })
const dbCredHeaders = headersWithoutSort([
{ 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 },
])
const showDeleteDialog = ref(false)
const deleteType = ref('')
const deleteId = ref<number | null>(null)
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
if (credTab.value === 'sshkeys') loadSshKeys()
else if (credTab.value === 'db') loadDbCreds()
}
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 })
}
async function loadPasswords(silent = false) {
if (!silent) 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 {
if (!silent) loading.value = false
}
}
async function loadSshKeys(silent = false) {
if (!silent) loading.value = true
try {
const res = await fetchPagePerPage<SshKeyItem>('/ssh-key-presets/', {}, page.value, itemsPerPage.value)
sshKeys.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
async function loadDbCreds(silent = false) {
if (!silent) loading.value = true
try {
const res = await fetchPagePerPage<DbCredItem>('/scripts/credentials', {}, page.value, itemsPerPage.value)
dbCreds.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
function loadCurrentTab(silent = false) {
switch (credTab.value) {
case 'passwords': void loadPasswords(silent); break
case 'sshkeys': void loadSshKeys(silent); break
case 'db': void loadDbCreds(silent); break
}
}
watch(credTab, () => {
page.value = 1
loadCurrentTab()
})
watch(
() => props.active,
(active) => {
if (active) loadCurrentTab()
},
{ immediate: true },
)
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(formatApiError(e, '保存失败'), 'error')
}
}
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: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
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: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
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: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
}
}
</script>