Files
console-dev/app/pages/security/ssh-keys/index.vue
T

429 lines
13 KiB
Vue

<script setup>
/**
* SSH 公钥管理:普通列表使用服务端分页,搜索先读取完整列表再在本地过滤/分页;requestGeneration 与 disposed 共同阻止旧请求回写。只管理公钥,不接收私钥。
*/
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
useId,
} from "vue";
import Button from "primevue/button";
import Column from "primevue/column";
import DataTable from "primevue/datatable";
import Dialog from "primevue/dialog";
import InputText from "primevue/inputtext";
import Message from "primevue/message";
import Paginator from "primevue/paginator";
import Textarea from "primevue/textarea";
import ListEmpty from "~/components/ListEmpty.vue";
import { useApi } from "~/lib/auth.js";
import { n as loadCompleteList } from "~/lib/complete-list.js";
import "~/assets/styles/ssh-keys.WWTOf2zG.css";
const api = useApi();
const fieldId = useId();
const draftSearch = ref("");
const appliedSearch = ref("");
const rows = ref([]);
const totalRows = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const listLoading = ref(true);
const searchLoading = ref(false);
const listError = ref("");
const actionError = ref("");
const editorVisible = ref(false);
const saving = ref(false);
const editingKey = ref(null);
const keyForm = reactive({ name: "", public_key: "" });
const copiedKeyId = ref(null);
let copyResetTimer;
let requestGeneration = 0;
let disposed = false;
const firstRecord = computed(() => (currentPage.value - 1) * pageSize.value);
const visibleRows = computed(() =>
appliedSearch.value
? rows.value.slice(firstRecord.value, firstRecord.value + pageSize.value)
: rows.value,
);
// 仅允许当前加载代次且组件尚未卸载时修改列表反馈。
function isCurrentLoad(generation) {
return !disposed && generation === requestGeneration;
}
// 删除或搜索后夹紧页码;返回是否变化,供服务端分页决定是否重读修正后的页。
function clampPage() {
const lastPage = Math.max(1, Math.ceil(totalRows.value / pageSize.value));
const nextPage = Math.max(1, Math.min(currentPage.value, lastPage));
if (nextPage === currentPage.value) return false;
currentPage.value = nextPage;
return true;
}
// 非搜索模式请求当前服务端页,删除末页最后一条时先修正页码再重读。
async function loadKeys() {
if (disposed) return;
if (appliedSearch.value) return loadSearchResults();
const generation = ++requestGeneration;
searchLoading.value = false;
listLoading.value = true;
listError.value = "";
try {
const result = await api.get("/ssh_key", {
page: currentPage.value,
limit: pageSize.value,
});
if (!isCurrentLoad(generation)) return;
totalRows.value = result.count;
if (clampPage()) {
await loadKeys();
return;
}
rows.value = result.list;
} catch (error) {
if (!isCurrentLoad(generation)) return;
listError.value =
error instanceof Error ? error.message : "加载密钥列表失败";
rows.value = [];
totalRows.value = 0;
} finally {
if (isCurrentLoad(generation)) listLoading.value = false;
}
}
// 搜索拥有全量过滤结果,visibleRows 才做本地分页;不能只在服务端某一页上搜索全部密钥。
async function loadSearchResults() {
const query = appliedSearch.value;
const generation = ++requestGeneration;
const isCurrent = () => isCurrentLoad(generation);
listLoading.value = true;
searchLoading.value = true;
listError.value = "";
try {
// Search owns the complete result set; only visibleRows applies local pagination.
const completeRows = await loadCompleteList(
(page, limit) => api.get("/ssh_key", { page, limit }),
isCurrent,
);
if (!isCurrent()) return;
rows.value = completeRows.filter((row) =>
(row.id + " " + row.name + " " + row.public_key)
.toLowerCase()
.includes(query),
);
totalRows.value = rows.value.length;
clampPage();
} catch (error) {
if (!isCurrent()) return;
listError.value = error instanceof Error ? error.message : "搜索密钥失败";
rows.value = [];
totalRows.value = 0;
} finally {
if (isCurrent()) {
searchLoading.value = false;
listLoading.value = false;
}
}
}
// 关键词确认后才应用,并从第一页重新查询。
async function submitSearch() {
appliedSearch.value = draftSearch.value.trim().toLowerCase();
currentPage.value = 1;
await loadKeys();
}
// 清除草稿与已应用关键词,恢复服务端分页。
async function clearSearch() {
draftSearch.value = "";
appliedSearch.value = "";
currentPage.value = 1;
await loadKeys();
}
// 每页条数变化回到第一页;搜索只调整本地页,普通模式重新读接口。
function changePage(event) {
currentPage.value = event.rows === pageSize.value ? event.page + 1 : 1;
pageSize.value = event.rows;
if (appliedSearch.value) clampPage();
else void loadKeys();
}
// 新建前清空上次编辑对象和表单,避免把旧 ID 带入创建。
function openCreateDialog() {
editingKey.value = null;
keyForm.name = "";
keyForm.public_key = "";
actionError.value = "";
editorVisible.value = true;
}
// 复制行中的名称与公钥到草稿,不直接改表格对象。
function openEditDialog(key) {
editingKey.value = key;
keyForm.name = key.name;
keyForm.public_key = key.public_key;
actionError.value = "";
editorVisible.value = true;
}
// 校验名称非空、字符数上限和公钥非空;按编辑对象选择创建/更新,成功后刷新当前模式列表。
async function saveKey() {
if (saving.value || disposed) return;
actionError.value = "";
if (!keyForm.name.trim()) {
actionError.value = "请输入密钥名称";
return;
}
if ([...keyForm.name.trim()].length > 10) {
actionError.value = "密钥名称最多 10 个字符";
return;
}
if (!keyForm.public_key.trim()) {
actionError.value = "请输入公钥内容";
return;
}
saving.value = true;
try {
if (editingKey.value) {
await api.put("/ssh_key/" + editingKey.value.id, {
id: editingKey.value.id,
...keyForm,
});
} else {
await api.post("/ssh_key", { ...keyForm });
}
if (disposed) return;
editorVisible.value = false;
await loadKeys();
} catch (error) {
if (!disposed)
actionError.value = error instanceof Error ? error.message : "保存失败";
} finally {
if (!disposed) saving.value = false;
}
}
// 删除后复用加载逻辑;末页恢复由 loadKeys/clampPage 统一处理。
async function deleteKey(key) {
if (disposed) return;
try {
await api.del("/ssh_key/" + key.id);
await loadKeys();
} catch (error) {
if (!disposed)
actionError.value = error instanceof Error ? error.message : "删除失败";
}
}
// 剪贴板成功后短暂标记当前行;离页时清除反馈计时器,复制失败仍可手工选择公钥。
async function copyPublicKey(key) {
try {
await navigator.clipboard.writeText(key.public_key);
if (disposed) return;
clearTimeout(copyResetTimer);
copiedKeyId.value = key.id;
copyResetTimer = setTimeout(() => (copiedKeyId.value = null), 2000);
} catch {
// Clipboard access can be unavailable; the public key remains selectable.
}
}
onMounted(loadKeys);
onBeforeUnmount(() => {
disposed = true;
requestGeneration++;
clearTimeout(copyResetTimer);
});
</script>
<template>
<!-- Keep the imported page styles scoped, including the teleported editor. -->
<div class="list-page" data-v-31489433>
<div class="list-head" data-v-31489433>
<h2 data-v-31489433>SSH 密钥</h2>
<div class="list-head-actions" data-v-31489433>
<Button label="添加密钥" icon="pi pi-plus" @click="openCreateDialog" />
</div>
</div>
<Message v-if="listError" severity="error" :closable="false">
{{ listError }}
<Button label="重试" text @click="loadKeys" />
</Message>
<Message
v-if="actionError && !editorVisible"
severity="error"
:closable="false"
>
{{ actionError }}
</Message>
<div class="list-toolbar" data-v-31489433>
<InputText
v-model="draftSearch"
class="list-search"
placeholder="输入关键字"
aria-label="搜索 SSH 密钥"
data-v-31489433
@keyup.enter="submitSearch"
/>
<Button
label="搜索"
icon="pi pi-search"
severity="secondary"
outlined
:loading="searchLoading"
@click="submitSearch"
/>
<Button
label="清除筛选"
icon="pi pi-filter-slash"
severity="secondary"
outlined
@click="clearSearch"
/>
</div>
<div class="list-table" data-v-31489433>
<DataTable
:value="visibleRows"
:loading="listLoading"
lazy
:rows="pageSize"
:total-records="totalRows"
:first="firstRecord"
data-key="id"
>
<template #empty>
<ListEmpty
icon="pi pi-key"
title="还没有 SSH 密钥"
description="添加公钥后可在开通云服务器时选用"
/>
</template>
<Column field="id" header="ID" style="width: 120px">
<template #body="{ data }">#{{ data.id }}</template>
</Column>
<Column header="名称" style="width: 180px; min-width: 140px">
<template #body="{ data }">{{ data.name }}</template>
</Column>
<Column header="公钥">
<template #body="{ data }">
<span class="key-text" data-v-31489433>{{ data.public_key }}</span>
</template>
</Column>
<Column header="指纹" style="width: 220px">
<template #body="{ data }">
<span class="fingerprint" data-v-31489433>{{
data.finger_print || "--"
}}</span>
</template>
</Column>
<Column header="操作" style="width: 200px">
<template #body="{ data }">
<div class="row-actions" data-v-31489433>
<Button
:label="copiedKeyId === data.id ? '已复制' : '复制'"
size="small"
severity="secondary"
outlined
@click="copyPublicKey(data)"
/>
<Button
label="编辑"
size="small"
severity="secondary"
text
@click="openEditDialog(data)"
/>
<Button
icon="pi pi-trash"
aria-label="删除密钥"
size="small"
severity="danger"
text
@click="deleteKey(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<div v-if="totalRows > 0" class="list-foot" data-v-31489433>
<div class="list-bulk" data-v-31489433 />
<Paginator
:rows="pageSize"
:total-records="totalRows"
:first="firstRecord"
:rows-per-page-options="[10, 20, 50]"
@page="changePage"
/>
</div>
<Dialog
v-model:visible="editorVisible"
:header="editingKey ? '编辑密钥' : '添加密钥'"
modal
:draggable="false"
style="width: 448px"
>
<div class="form" data-v-31489433>
<div class="field" data-v-31489433>
<label :for="fieldId + '-name'" data-v-31489433>
名称 <span class="req" data-v-31489433>*</span>
</label>
<InputText
:id="fieldId + '-name'"
v-model="keyForm.name"
:aria-describedby="fieldId + '-name-hint'"
aria-required="true"
placeholder="便于识别的名称"
maxlength="10"
fluid
/>
<span :id="fieldId + '-name-hint'" class="hint" data-v-31489433>
最多 10 个字符
</span>
</div>
<div class="field" data-v-31489433>
<label :for="fieldId + '-public-key'" data-v-31489433>
公钥内容 <span class="req" data-v-31489433>*</span>
</label>
<Textarea
:id="fieldId + '-public-key'"
v-model="keyForm.public_key"
:aria-describedby="fieldId + '-key-hint'"
aria-required="true"
rows="8"
placeholder="输入公钥"
auto-resize
fluid
/>
<span :id="fieldId + '-key-hint'" class="hint" data-v-31489433>
粘贴 ~/.ssh/id_rsa.pub 等公钥文件内容
</span>
</div>
<Message v-if="actionError" severity="error" :closable="false">
{{ actionError }}
</Message>
</div>
<template #footer>
<Button
label="取消"
severity="secondary"
text
@click="editorVisible = false"
/>
<Button label="保存" :loading="saving" @click="saveKey" />
</template>
</Dialog>
</div>
</template>