补充证书历史版本前端恢复入口

保存证书历史版本列表、恢复确认、幂等提交和任务详情跳转的前端实现,作为后续样式与测试优化基线。
This commit is contained in:
xuans 2026-10-03 02:24:48 +08:00
parent 3aa1d50251
commit bd5f31946c
2 changed files with 134 additions and 0 deletions

View File

@ -65,6 +65,12 @@ export function useDashboard() {
const importSaving = ref(false); const importSaving = ref(false);
const importError = ref(""); const importError = ref("");
const importResult = ref(null); const importResult = ref(null);
const showCertificateVersions = ref(false);
const versionDomain = ref(null);
const certificateVersions = ref([]);
const versionsLoading = ref(false);
const versionsError = ref("");
const restoreSubmitting = ref("");
let importIdempotencyKey = ""; let importIdempotencyKey = "";
const importFiles = ref({ const importFiles = ref({
certificate: null, certificate: null,
@ -92,6 +98,7 @@ export function useDashboard() {
showServerForm.value || showServerForm.value ||
showSettings.value || showSettings.value ||
showCertificateImport.value || showCertificateImport.value ||
showCertificateVersions.value ||
deploymentResult.value !== null || deploymentResult.value !== null ||
showTaskDetails.value, showTaskDetails.value,
); );
@ -847,6 +854,64 @@ export function useDashboard() {
if (!importSaving.value) showCertificateImport.value = false; if (!importSaving.value) showCertificateImport.value = false;
} }
async function openCertificateVersions(item) {
versionDomain.value = item;
certificateVersions.value = [];
versionsError.value = "";
versionsLoading.value = true;
showCertificateVersions.value = true;
try {
const response = await fetchAdmin(
`/api/v1/domains/${item.id}/certificate/versions`,
);
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "读取证书历史版本失败");
certificateVersions.value = Array.isArray(result.items) ? result.items : [];
} catch (err) {
versionsError.value = err.message || "读取证书历史版本失败";
} finally {
versionsLoading.value = false;
}
}
function closeCertificateVersions() {
if (!restoreSubmitting.value) showCertificateVersions.value = false;
}
async function restoreCertificateVersion(version) {
if (!version?.history_id || version.current || restoreSubmitting.value) return;
if (!window.confirm("确定恢复这个证书历史版本吗?恢复只更新 Scert 本地托管证书,不代表所有远端服务器已经部署。")) {
return;
}
const domain = versionDomain.value;
if (!domain) return;
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
restoreSubmitting.value = version.history_id;
versionsError.value = "";
try {
const response = await fetchAdmin(
`/api/v1/domains/${domain.id}/certificate/versions/${encodeURIComponent(version.history_id)}/restore`,
{
method: "POST",
headers: { "Idempotency-Key": `certificate-restore-${domain.id}-${suffix}` },
},
);
const result = await response.json().catch(() => ({}));
const replayed = response.status === 409 && result.replayed;
if (!(response.ok || replayed) || !result.task?.id) {
throw new Error(result.error || "提交证书历史版本恢复失败");
}
notice.value = `证书历史版本恢复任务已提交:${result.task.id},恢复完成后请另行确认远端部署状态`;
showCertificateVersions.value = false;
await loadData();
await openTaskDetails(result.task);
} catch (err) {
versionsError.value = err.message || "提交证书历史版本恢复失败";
} finally {
restoreSubmitting.value = "";
}
}
function selectCertificateImportFile(kind, event) { function selectCertificateImportFile(kind, event) {
importFiles.value[kind] = event.target.files?.[0] || null; importFiles.value[kind] = event.target.files?.[0] || null;
importError.value = ""; importError.value = "";
@ -1127,6 +1192,15 @@ export function useDashboard() {
closeCertificateImport, closeCertificateImport,
selectCertificateImportFile, selectCertificateImportFile,
importCertificate, importCertificate,
showCertificateVersions,
versionDomain,
certificateVersions,
versionsLoading,
versionsError,
restoreSubmitting,
openCertificateVersions,
closeCertificateVersions,
restoreCertificateVersion,
saveServer, saveServer,
deleteServer, deleteServer,
saveDomain, saveDomain,

View File

@ -74,6 +74,15 @@ const {
closeCertificateImport, closeCertificateImport,
selectCertificateImportFile, selectCertificateImportFile,
importCertificate, importCertificate,
showCertificateVersions,
versionDomain,
certificateVersions,
versionsLoading,
versionsError,
restoreSubmitting,
openCertificateVersions,
closeCertificateVersions,
restoreCertificateVersion,
settingsSaving, settingsSaving,
settingsError, settingsError,
dnsProviderCatalog, dnsProviderCatalog,
@ -384,6 +393,9 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<button class="text-button" type="button" @click="openCertificateImport(item)"> <button class="text-button" type="button" @click="openCertificateImport(item)">
导入证书 导入证书
</button> </button>
<button class="text-button" type="button" @click="openCertificateVersions(item)">
历史版本
</button>
<button class="text-button" type="button" @click="openEdit(item)"> <button class="text-button" type="button" @click="openEdit(item)">
编辑</button 编辑</button
><button ><button
@ -495,6 +507,54 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
</section> </section>
</div> </div>
<div v-if="showCertificateVersions" class="modal-backdrop" @click.self="closeCertificateVersions">
<section class="modal-card certificate-history-modal" role="dialog" aria-modal="true" aria-label="证书历史版本">
<div class="modal-heading">
<div>
<p class="eyebrow">证书管理</p>
<h2>证书历史版本</h2>
<p class="muted">托管域名:{{ versionDomain?.name }}</p>
</div>
<button class="close-button" type="button" aria-label="关闭" @click="closeCertificateVersions">×</button>
</div>
<p class="security-hint">
这里仅恢复 Scert 本地托管证书。恢复完成不等于远端服务器已部署,请在任务完成后单独确认部署状态。
</p>
<div v-if="versionsLoading" class="empty-state">正在读取证书历史版本……</div>
<p v-else-if="versionsError" class="form-error" role="alert">{{ versionsError }}</p>
<div v-else-if="certificateVersions.length === 0" class="empty-state compact-empty">
<strong>暂无可显示的历史版本</strong>
<span>证书成功保存后,历史版本会出现在这里。</span>
</div>
<div v-else class="certificate-history-list">
<article v-for="version in certificateVersions" :key="version.history_id || version.version" class="certificate-history-row">
<div>
<div class="domain-title-line">
<strong>{{ version.current ? "当前版本" : "历史版本" }}</strong>
<span v-if="version.current" class="tag success">正在使用</span>
</div>
<div class="domain-meta">
<span>版本:{{ version.version || "未记录" }}</span>
<span>保存时间:{{ formatDate(version.saved_at) }}</span>
<span>有效期至:{{ formatDate(version.not_after) }}</span>
<span v-if="version.fingerprint_sha256">指纹:{{ version.fingerprint_sha256 }}</span>
</div>
</div>
<button
v-if="!version.current"
class="secondary-button"
type="button"
:disabled="!!restoreSubmitting"
@click="restoreCertificateVersion(version)"
>
{{ restoreSubmitting === version.history_id ? "提交中……" : "恢复此版本" }}
</button>
<span v-else class="muted">当前版本不可重复恢复</span>
</article>
</div>
</section>
</div>
<DomainModal v-if="showForm" :state="dashboard" /> <DomainModal v-if="showForm" :state="dashboard" />
<ServerModal v-if="showServerForm" :state="dashboard" /> <ServerModal v-if="showServerForm" :state="dashboard" />