446 lines
13 KiB
Vue
446 lines
13 KiB
Vue
<script setup>
|
||
/**
|
||
* SSH 公钥管理:普通列表使用服务端分页,搜索先读取完整列表再在本地过滤/分页;requestGeneration 与 disposed 共同阻止旧请求回写。只管理公钥,不接收私钥。
|
||
*/
|
||
import {
|
||
computed,
|
||
onBeforeUnmount,
|
||
onMounted,
|
||
reactive,
|
||
ref,
|
||
useId,
|
||
watch,
|
||
} from "vue";
|
||
import Button from "openvue/button";
|
||
import Column from "openvue/column";
|
||
import DataTable from "openvue/datatable";
|
||
import Dialog from "openvue/dialog";
|
||
import InputText from "openvue/inputtext";
|
||
import Message from "openvue/message";
|
||
import Paginator from "openvue/paginator";
|
||
import Textarea from "openvue/textarea";
|
||
import ListEmpty from "~/components/ListEmpty.vue";
|
||
import { useApi, useAuth } from "~/lib/auth.js";
|
||
import { n as loadCompleteList } from "~/lib/complete-list.js";
|
||
import "~/assets/styles/ssh-keys.WWTOf2zG.css";
|
||
|
||
const api = useApi();
|
||
const session = useAuth().prepareLogin();
|
||
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;
|
||
let editorGeneration = 0;
|
||
const isCurrent = () => !disposed && session.isCurrent();
|
||
watch(
|
||
editorVisible,
|
||
() => {
|
||
editorGeneration++;
|
||
saving.value = false;
|
||
},
|
||
{ flush: "sync" },
|
||
);
|
||
|
||
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 isCurrent() && 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 (!isCurrent()) 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 {
|
||
// 搜索负责收集完整结果,仅由 visibleRows 执行本地分页。
|
||
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 || !isCurrent()) 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;
|
||
const generation = editorGeneration;
|
||
const ownsDraft = () => isCurrent() && generation === editorGeneration;
|
||
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 (!isCurrent()) return;
|
||
if (ownsDraft()) editorVisible.value = false;
|
||
await loadKeys();
|
||
} catch (error) {
|
||
if (ownsDraft())
|
||
actionError.value = error instanceof Error ? error.message : "保存失败";
|
||
} finally {
|
||
if (ownsDraft()) saving.value = false;
|
||
}
|
||
}
|
||
|
||
// 删除后复用加载逻辑;末页恢复由 loadKeys/clampPage 统一处理。
|
||
async function deleteKey(key) {
|
||
if (!isCurrent()) return;
|
||
try {
|
||
await api.del("/ssh_key/" + key.id);
|
||
if (isCurrent()) await loadKeys();
|
||
} catch (error) {
|
||
if (isCurrent())
|
||
actionError.value = error instanceof Error ? error.message : "删除失败";
|
||
}
|
||
}
|
||
|
||
// 剪贴板成功后短暂标记当前行;离页时清除反馈计时器,复制失败仍可手工选择公钥。
|
||
async function copyPublicKey(key) {
|
||
try {
|
||
await navigator.clipboard.writeText(key.public_key);
|
||
if (!isCurrent()) return;
|
||
clearTimeout(copyResetTimer);
|
||
copiedKeyId.value = key.id;
|
||
copyResetTimer = setTimeout(() => (copiedKeyId.value = null), 2000);
|
||
} catch {
|
||
// 剪贴板权限或接口可能不可用,用户仍可手工选择并复制公钥。
|
||
}
|
||
}
|
||
|
||
onMounted(loadKeys);
|
||
onBeforeUnmount(() => {
|
||
disposed = true;
|
||
requestGeneration++;
|
||
clearTimeout(copyResetTimer);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 保留导入样式的页面作用域,包括渲染到页面外部的编辑弹窗。 -->
|
||
<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: 240px">
|
||
<template #body="{ data }">
|
||
<div class="row-actions" data-v-31489433>
|
||
<Button
|
||
:label="copiedKeyId === data.id ? '已复制' : '复制'"
|
||
:icon="copiedKeyId === data.id ? 'pi pi-check' : 'pi pi-copy'"
|
||
size="small"
|
||
severity="secondary"
|
||
text
|
||
@click="copyPublicKey(data)"
|
||
/>
|
||
<Button
|
||
label="编辑"
|
||
icon="pi pi-pencil"
|
||
size="small"
|
||
severity="secondary"
|
||
text
|
||
@click="openEditDialog(data)"
|
||
/>
|
||
<Button
|
||
label="删除"
|
||
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>
|