08a0157c95
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航; 服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
436 lines
16 KiB
Vue
436 lines
16 KiB
Vue
<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>
|