Files
Nexus/frontend/src/components/credentials/CredentialsManager.vue
T
Nexus Agent 08a0157c95 feat: 调度表单重构、登录门控、批量任务与页面缓存对齐
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航;
服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:17:21 +08:00

436 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>