429 lines
13 KiB
Vue
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>
|