first commit
This commit is contained in:
commit
db5762abd4
687 files changed
+171307
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,413 @@
|
||||
<script setup>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user