Files

446 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>