Scert/frontend/src/views/Dashboard/Dashboard.js
xuans bd5f31946c 补充证书历史版本前端恢复入口
保存证书历史版本列表、恢复确认、幂等提交和任务详情跳转的前端实现,作为后续样式与测试优化基线。
2026-10-03 02:24:48 +08:00

1235 lines
41 KiB
JavaScript

import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
import { useDomainForm } from "./DomainForm.js";
import { useTaskMonitor } from "./TaskMonitor.js";
import { useRenewalAction } from "./RenewalAction.js";
const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 6;
function dnsVerificationTimeoutFor(providerID) {
return String(providerID || "").trim().toLowerCase() === "aliyun"
? aliyunDNSVerificationTimeoutSeconds
: defaultDNSVerificationTimeoutSeconds;
}
export function useDashboard() {
const status = ref(null);
const currentUser = ref(null);
const canManageSystemSettings = computed(() => currentUser.value?.role === "super_admin");
const canRunRenewal = computed(() => currentUser.value?.role === "super_admin");
const domains = ref([]);
const servers = ref([]);
const certificates = ref([]);
const tasks = ref([]);
const auditLogs = ref([]);
const settings = ref(null);
const dnsProviderCatalog = ref([]);
const domainServers = ref({});
const serverDomains = computed(() => Object.fromEntries(
servers.value.map((server) => [
server.id,
domains.value.filter((domain) =>
(domainServers.value[domain.id] || []).some((item) => item.id === server.id),
),
]),
));
const domainPage = ref(1);
const domainPageSize = ref(5);
const domainPageSizeOptions = [5, 10, 15, 30];
const loading = ref(true);
const error = ref("");
const notice = ref("");
const domainForm = useDomainForm({ fetchAdmin, domains, servers, domainServers, notice, loadData });
const { saving, formError, showForm, editingId, isEditing, formTitle, form,
openCreate, openEdit, closeForm, saveDomain } = domainForm;
const showServerForm = ref(false);
const serverEditingId = ref("");
const serverSaving = ref(false);
const serverFormError = ref("");
const showSettings = ref(false);
const settingsSaving = ref(false);
const settingsError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
const dnsVerificationElapsedSeconds = ref(0);
const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds);
const verificationClock = ref(Date.now());
let verificationTimer;
let dnsVerificationTimer;
let dnsRequestVersion = 0;
const settingsForm = ref(createEmptySettingsForm());
const showCertificateImport = ref(false);
const importDomain = ref(null);
const importSaving = ref(false);
const importError = ref("");
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 = "";
const importFiles = ref({
certificate: null,
private_key: null,
issuer_bundle: null,
});
const showTaskDetails = ref(false);
const taskDetails = ref(null);
const taskAttempts = ref([]);
const taskDetailsLoading = ref(false);
const taskDetailsError = ref("");
const deploymentResult = ref(null);
const domainActionRunning = ref(false);
const taskMonitor = useTaskMonitor({
fetchAdmin, tasks, deploymentResult, showTaskDetails, taskDetails,
taskAttempts, taskDetailsLoading, taskDetailsError,
refreshResources: refreshTaskResources,
});
const { runRenewalNow, renewalSubmitting } = useRenewalAction({
canRunRenewal, error, notice, fetchAdmin, taskMonitor,
});
const hasOpenModal = computed(
() =>
showForm.value ||
showServerForm.value ||
showSettings.value ||
showCertificateImport.value ||
showCertificateVersions.value ||
deploymentResult.value !== null ||
showTaskDetails.value,
);
let previousBodyOverflow = "";
const domainPageCount = computed(() =>
Math.max(1, Math.ceil(domains.value.length / domainPageSize.value)),
);
const paginatedDomains = computed(() => {
const start = (domainPage.value - 1) * domainPageSize.value;
return domains.value.slice(start, start + domainPageSize.value);
});
const domainPageStart = computed(() =>
domains.value.length === 0 ? 0 : (domainPage.value - 1) * domainPageSize.value + 1,
);
const domainPageEnd = computed(() =>
Math.min(domainPage.value * domainPageSize.value, domains.value.length),
);
watch(domainPageSize, () => {
domainPage.value = 1;
});
watch(domains, () => {
if (domainPage.value > domainPageCount.value) {
domainPage.value = domainPageCount.value;
}
});
watch(hasOpenModal, (isOpen) => {
if (isOpen) {
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return;
}
document.body.style.overflow = previousBodyOverflow;
});
const certificateTypeLabels = {
single: "单域名",
wildcard: "泛域名",
san: "SAN 多域名",
};
const taskStatusLabels = {
queued: "排队中",
running: "执行中",
success: "已完成",
failed: "失败",
cancelled: "已取消",
cancelling: "\u53d6\u6d88\u4e2d",
retry_wait: "\u7b49\u5f85\u91cd\u8bd5",
partial_success: "\u90e8\u5206\u6210\u529f",
};
taskStatusLabels.error = taskStatusLabels.failed;
const taskTypeLabels = {
renewal_check: "全局续期检查",
management_renewal: "管理证书续期",
certificate_issue: "申请证书",
certificate_import: "导入证书",
certificate_deploy: "部署证书",
certificate_validate: "校验证书",
certificate_restore: "恢复证书版本",
renewal_run: "自动续期",
dns_test: "DNS 在线测试",
};
const auditActionLabels = {
renewal_check: "全局续期检查",
management_renewal: "管理证书续期",
certificate_issue: "申请证书",
certificate_import: "导入证书",
certificate_deploy: "部署证书",
certificate_validate: "校验证书",
certificate_version_restore: "恢复证书版本",
task_cancel: "取消任务",
settings_update: "更新系统设置",
domain_create: "创建域名",
domain_update: "更新域名",
domain_delete: "删除域名",
server_create: "创建服务器",
server_update: "更新服务器",
server_delete: "删除服务器",
renewal_run: "执行自动续期",
};
const taskStageLabels = {
renewal_check: "检查管理证书和业务域名续期",
management_renewal: "管理证书续期",
preflight: "续期前置检查",
issue: "申请管理证书",
activate: "激活管理证书",
record: "保存任务记录",
retry_wait: "等待续期重试",
service_restart: "服务重启恢复",
queued: "排队中",
certificate_issue: "申请证书",
certificate_validate: "校验证书",
certificate_deploy: "部署证书",
deployment: "远程部署",
verification: "部署后校验",
rollback: "回滚处理",
completed: "已完成",
};
const attemptStatusLabels = {
running: "执行中",
success: "已完成",
failed: "失败",
cancelled: "已取消",
cancelling: "\u53d6\u6d88\u4e2d",
retry_wait: "\u7b49\u5f85\u91cd\u8bd5",
partial_success: "\u90e8\u5206\u6210\u529f",
};
const domainStatusLabels = {
pending: "等待申请",
active: "正常",
valid: "正常",
error: "错误",
};
const httpsModeLabels = { direct: "核心服务 HTTPS", proxy: "反向代理 HTTPS" };
const environmentLabels = { staging: "测试环境", production: "生产环境" };
const webServerLabels = {
nginx: "Nginx",
caddy: "Caddy",
apache: "Apache",
custom: "自定义网站服务",
};
const dnsProviders = [
{ value: "cloudflare", label: "Cloudflare" },
{ value: "aliyun", label: "阿里云 DNS" },
{ value: "dnspod", label: "DNSPod" },
{ value: "cname", label: "CNAME 委派" },
{ value: "huaweicloud", label: "华为云" },
{ value: "tencentcloud", label: "腾讯云" },
{ value: "route53", label: "AWS Route 53" },
{ value: "googlecloud", label: "Google Cloud DNS" },
{ value: "other", label: "其他服务商" },
];
const selectedDNSProvider = computed(() =>
dnsProviderCatalog.value.find(
(provider) => provider.id === settingsForm.value.dns.provider,
) || null,
);
const dnsVerificationRequired = computed(() => {
const current = settingsForm.value.dns;
const saved = settings.value?.dns;
if (!current.provider) return false;
return (
current.provider !== saved?.provider ||
!saved?.credential_configured ||
Boolean(current.test_domain.trim()) ||
Object.values(current.credentials || {}).some((value) => String(value || "").trim()) ||
Boolean(current.verification_token) ||
current.verification_state === "pending"
);
});
const dnsReadyToSave = computed(() => {
verificationClock.value;
if (dnsTesting.value) return false;
if (!dnsVerificationRequired.value) return true;
const dns = settingsForm.value.dns;
return Boolean(dns.credential_ref && dns.verification_token) &&
new Date(dns.expires_at).getTime() > Date.now();
});
function updateDNSVerificationTimeoutSeconds(value) {
const timeout = Number(value);
if (Number.isFinite(timeout) && timeout > 0) {
dnsVerificationTimeoutSeconds.value = Math.floor(timeout);
}
}
function stopDNSVerificationTimer(requestVersion) {
if (requestVersion !== undefined && requestVersion !== dnsRequestVersion) return;
if (dnsVerificationTimer) {
window.clearInterval(dnsVerificationTimer);
dnsVerificationTimer = undefined;
}
dnsVerificationElapsedSeconds.value = 0;
}
function startDNSVerificationTimer(requestVersion, providerID) {
stopDNSVerificationTimer();
dnsVerificationTimeoutSeconds.value = dnsVerificationTimeoutFor(providerID);
const startedAt = Date.now();
const updateElapsedSeconds = () => {
if (requestVersion !== dnsRequestVersion) return;
dnsVerificationElapsedSeconds.value = Math.max(
0,
Math.floor((Date.now() - startedAt) / 1000),
);
};
updateElapsedSeconds();
dnsVerificationTimer = window.setInterval(updateElapsedSeconds, 1000);
}
watch(
() => [settingsForm.value.dns.provider, settingsForm.value.dns.test_domain, settingsForm.value.dns.credentials],
handleDNSProviderChange,
{ deep: true, flush: "sync" },
);
const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem";
const defaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem";
const defaultPrivateKeyPath = "/ssl/{域名}/{证书类型}/key.pem";
const serverForm = ref(createEmptyServerForm());
async function fetchAdmin(path, options = {}) {
const response = await fetch(path, {
...options,
credentials: "include",
});
if (response.status === 401) {
window.location.assign("/login");
throw new Error("登录会话已过期,请重新登录");
}
return response;
}
async function responseError(response, fallback) {
const result = await response.json().catch(() => ({}));
if (result.error) return result.error;
if (response.status === 403) return "当前账号没有管理权限";
if (response.status === 409) return "当前服务状态不允许执行此操作";
if (response.status >= 500) return "核心服务暂时不可用,请稍后重试";
return fallback;
}
function taskTypeLabel(value) {
return displayLabel(taskTypeLabels, value);
}
function auditActionLabel(value) {
if (typeof value === "string" && /\p{Script=Han}/u.test(value)) {
return value.trim();
}
return displayLabel(auditActionLabels, value);
}
function taskStageLabel(value) {
return displayLabel(taskStageLabels, value);
}
function attemptStatusLabel(value) {
return displayLabel(attemptStatusLabels, value);
}
function createEmptyServerForm() {
return {
name: "",
address: "",
port: 22,
username: "",
auth_method: "password",
password: "",
passwordConfigured: false,
host_key_fingerprint: "",
certificate_path: defaultCertificatePath,
private_key_path: defaultPrivateKeyPath,
certificate_chain_path: defaultCertificateChainPath,
web_server: "nginx",
validation_command: "",
reload_command: "",
server_ids: [],
};
}
function createRandomSuffix() {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(
"",
);
}
function createEmptySettingsForm() {
return {
server: { host: "", port: 5200, https_mode: "direct", proxy_https_port: 0 },
web: { enabled: true, hostname: "", public_ip: "" },
paths: { certificate: "" },
acme: {
ca: "letsencrypt",
environment: "staging",
account_email: "",
renewal_check_interval_days: 3,
renewal_near_expiry_days: 10,
renewal_threshold_days: 6,
renewal_urgent_threshold_days: 3,
renewal_max_retries: 3,
renewal_retry_interval_mins: 30,
propagation_timeout_secs: 300,
propagation_polling_interval_secs: 5,
},
dns: {
provider: "",
credential_configured: false,
verification_state: "not_configured",
test_domain: "",
credentials: {},
credential_ref: "",
verification_token: "",
expires_at: "",
test_report: null,
},
notification: {
http_enabled: false,
smtp_enabled: false,
provider: "",
endpoint: "",
http_path: "/api/email/send",
http_login_path: "/api/login",
http_auth_mode: "none",
event_mode: "detailed",
include_deployment_details: true,
include_certificate_serial: false,
include_management_url: false,
sender_name: "Scert",
sender_email: "",
recipients: [],
timeout_secs: 10,
retry_count: 3,
retry_intervals_secs: [30, 120, 600],
smtp_host: "",
smtp_port: 587,
smtp_username: "",
smtp_password: "",
smtp_mode: "starttls",
smtp_allow_plaintext_auth: false,
smtp_credential_configured: false,
},
};
}
function normalizeSettings(value) {
const defaults = createEmptySettingsForm();
const source = value || {};
return {
...defaults,
...source,
server: { ...defaults.server, ...(source.server || {}) },
web: { ...defaults.web, ...(source.web || {}) },
paths: { ...defaults.paths, ...(source.paths || {}) },
acme: { ...defaults.acme, ...(source.acme || {}) },
dns: {
...defaults.dns,
...(source.dns || {}),
credentials: {},
credential_ref: "",
verification_token: "",
expires_at: "",
test_domain: "",
test_report: null,
},
notification: {
...defaults.notification,
...(source.notification || {}),
smtp_password: "",
recipients: Array.isArray(source.notification?.recipients)
? source.notification.recipients
: [],
},
};
}
function displayLabel(labels, value) {
return labels[value] || (value ? "未知" : "—");
}
function formatDate(value) {
if (!value) return "尚未签发";
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeZone: "Asia/Shanghai",
}).format(new Date(value));
}
function daysRemaining(value) {
if (!value) return "待申请";
const days = Math.ceil((new Date(value).getTime() - Date.now()) / 86400000);
return days >= 0 ? `剩余 ${days} 天` : `已过期 ${Math.abs(days)} 天`;
}
function statusClass(value) {
return value === "active" || value === "valid" || value === "success"
? "success"
: value === "error" || value === "failed"
? "danger"
: "warning";
}
function previousDomainPage() {
domainPage.value = Math.max(1, domainPage.value - 1);
}
function nextDomainPage() {
domainPage.value = Math.min(domainPageCount.value, domainPage.value + 1);
}
function openServerCreate() {
serverEditingId.value = "";
serverForm.value = createEmptyServerForm();
serverFormError.value = "";
showServerForm.value = true;
}
function openServerEdit(item) {
serverEditingId.value = item.id;
serverForm.value = {
...item,
auth_method: "password",
passwordConfigured: item.password_configured === true,
password: "",
validation_command: (item.validation_command || []).join("\n"),
reload_command: (item.reload_command || []).join("\n"),
server_ids: [],
};
serverFormError.value = "";
showServerForm.value = true;
}
function closeServerForm() {
if (!serverSaving.value) {
showServerForm.value = false;
}
}
function parseCommand(value) {
return value
.split(/\n/)
.map((item) => item.trim())
.filter(Boolean);
}
async function saveServer() {
serverFormError.value = "";
serverSaving.value = true;
try {
const payload = {
name: serverForm.value.name,
address: serverForm.value.address,
port: Number(serverForm.value.port),
username: serverForm.value.username,
host_key_fingerprint: serverForm.value.host_key_fingerprint,
certificate_path: serverForm.value.certificate_path,
private_key_path: serverForm.value.private_key_path,
certificate_chain_path: serverForm.value.certificate_chain_path,
web_server: serverForm.value.web_server,
validation_command: parseCommand(serverForm.value.validation_command),
reload_command: parseCommand(serverForm.value.reload_command),
};
payload.password = serverForm.value.password;
const path = serverEditingId.value
? `/api/v1/servers/${serverEditingId.value}`
: "/api/v1/servers";
const response = await fetchAdmin(path, {
method: serverEditingId.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存服务器失败");
showServerForm.value = false;
notice.value = serverEditingId.value
? "服务器配置已更新"
: "服务器已添加";
await loadData();
} catch (err) {
serverFormError.value = err.message || "保存服务器失败";
} finally {
serverSaving.value = false;
}
}
async function refreshTaskResources(signal) {
// 任务结束后只刷新运行数据,不覆盖用户正在编辑的系统设置或表单。
const responses = await Promise.all([
fetchAdmin("/api/v1/status", { signal }),
fetchAdmin("/api/v1/domains", { signal }),
fetchAdmin("/api/v1/certificates", { signal }),
fetchAdmin("/api/v1/audit-logs?limit=20", { signal }),
]);
if (responses.some((response) => !response.ok)) throw new Error("刷新任务关联数据失败");
const [statusResult, domainResult, certificateResult, auditResult] = await Promise.all(
responses.map((response) => response.json()),
);
if (signal.aborted) return;
if (![domainResult, certificateResult, auditResult].every((result) => Array.isArray(result.items))) {
throw new Error("任务关联数据响应不完整");
}
status.value = statusResult;
domains.value = domainResult.items;
certificates.value = certificateResult.items;
auditLogs.value = auditResult.items;
}
async function runDomainAction(item, action, successMessage) {
if (domainActionRunning.value) return;
domainActionRunning.value = true;
if (action === "deploy") {
deploymentResult.value = { domain: item.name, running: true, report: null, error: "" };
}
error.value = "";
notice.value = "";
try {
const options = { method: "POST" };
if (action === "issue" || action === "deploy" || action === "certificate/validate") {
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
const prefix = action === "certificate/validate" ? "certificate-validate" : action;
options.headers = { "Idempotency-Key": `${prefix}-${item.id}-${suffix}` };
}
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
const result = await response.json().catch(() => ({}));
if (response.status === 202 && !result.task?.id) {
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
}
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
(response.status === 202 || result.accepted || result.replayed)) {
notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`;
if (action === "deploy") {
deploymentResult.value = {
domain: item.name, running: false, pending: true,
taskID: result.task.id, taskStatus: result.task.status,
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
};
}
if (action === "certificate/validate") await taskMonitor.openTaskDetails(result.task);
return;
}
if (action === "deploy") {
deploymentResult.value = {
domain: item.name,
running: false,
report: result.deployment || null,
error: response.ok && result.deployment ? "" : result.error || deploymentSummary(null),
};
}
if (!response.ok) throw new Error(result.error || "域名操作失败");
notice.value = successMessage;
if (action === "deploy") notice.value = deploymentSummary(result.deployment);
} catch (err) {
error.value = err.message || "域名操作失败";
} finally {
const actionError = error.value;
if (action === "deploy" && deploymentResult.value) {
deploymentResult.value.running = false;
if (actionError) deploymentResult.value.error = actionError;
}
domainActionRunning.value = false;
await loadData();
if (actionError) error.value = actionError;
}
}
function issueDomain(item) {
return runDomainAction(item, "issue", `已提交 ${item.name} 的证书申请`);
}
function validateDomain(item) {
return runDomainAction(item, "certificate/validate", `已提交 ${item.name} 的证书校验`);
}
function deployDomain(item) {
return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`);
}
async function cancelTask(item) {
if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return;
error.value = "";
notice.value = "";
try {
const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "取消任务失败");
notice.value = response.status === 202 || result.task?.status === "cancelling"
? "正在取消,等待任务停止和部署收尾;此时不能重试"
: "证书任务已取消";
await loadData();
await taskMonitor.refreshTaskStatus();
} catch (err) {
error.value = err.message || "取消任务失败";
}
}
async function retryTask(item) {
if (!window.confirm("确定重新执行这项失败的证书任务吗?")) return;
error.value = "";
notice.value = "";
try {
const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "重试任务失败");
notice.value = "重试已提交,请在任务详情查看本次执行结果";
await loadData();
await taskMonitor.refreshTaskStatus();
} catch (err) {
error.value = err.message || "重试任务失败";
}
}
function handleDNSProviderChange() {
dnsRequestVersion += 1;
stopDNSVerificationTimer();
dnsTesting.value = false;
const dns = settingsForm.value.dns;
const saved = settings.value?.dns;
dnsTestError.value = "";
dns.credential_ref = "";
dns.verification_token = "";
dns.expires_at = "";
dns.test_report = null;
const hasNewInput = Boolean(dns.test_domain.trim()) ||
Object.values(dns.credentials || {}).some((value) => String(value || "").trim());
if (!hasNewInput && dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
dns.verification_state = saved.verification_state || "verified";
return;
}
dns.verification_state = dns.provider ? "pending" : "not_configured";
}
function clearDNSCredentials() {
settingsForm.value.dns.credentials = {};
dnsTestError.value = "";
}
async function testDNS() {
if (!canManageSystemSettings.value) {
dnsTestError.value = "只有超级管理员可以进行 DNS 在线测试";
return;
}
handleDNSProviderChange();
const dns = settingsForm.value.dns;
const provider = selectedDNSProvider.value;
dnsTestError.value = "";
dns.test_report = null;
if (!dns.provider || !provider) {
dnsTestError.value = "请选择 DNS 验证服务商";
return;
}
if (!dns.test_domain.trim()) {
dnsTestError.value = "请输入 DNS 在线测试域名";
return;
}
const credentials = Object.fromEntries(
Object.entries(dns.credentials || {})
.map(([key, value]) => [key, String(value || "").trim()])
.filter(([, value]) => value),
);
const missingField = (provider.credential_fields || []).find(
(field) => field.required && !credentials[field.name],
);
if (missingField) {
dnsTestError.value = `请填写${missingField.label}`;
return;
}
const requestVersion = dnsRequestVersion;
dnsTesting.value = true;
dns.verification_state = "pending";
startDNSVerificationTimer(requestVersion, dns.provider);
try {
const response = await fetchAdmin("/api/v1/dns/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: dns.provider,
test_domain: dns.test_domain.trim(),
credentials,
}),
});
const result = await response.json().catch(() => ({}));
if (requestVersion !== dnsRequestVersion) return;
updateDNSVerificationTimeoutSeconds(result.verification_timeout_secs);
dns.test_report = result.test_report || null;
if (!response.ok) {
throw new Error(result.error || "DNS API 在线测试失败");
}
dns.credential_ref = result.credential_ref || "";
dns.verification_token = result.verification_token || "";
dns.expires_at = result.expires_at || "";
dns.verification_state = "verified";
dns.credential_configured = true;
notice.value = "DNS API 在线测试通过,可以保存系统设置";
} catch (err) {
if (requestVersion !== dnsRequestVersion) return;
dns.credential_ref = "";
dns.verification_token = "";
dns.verification_state = "pending";
dnsTestError.value = err.message || "DNS API 在线测试失败";
} finally {
if (requestVersion === dnsRequestVersion) {
stopDNSVerificationTimer(requestVersion);
dnsTesting.value = false;
}
}
}
function openSettings() {
settingsError.value = "";
dnsTestError.value = "";
settingsForm.value = normalizeSettings(settings.value);
showSettings.value = true;
}
function closeSettings() {
if (!settingsSaving.value) {
handleDNSProviderChange();
showSettings.value = false;
}
}
async function saveSettings() {
settingsError.value = "";
if (!canManageSystemSettings.value) {
settingsError.value = "只有超级管理员可以保存系统设置";
return;
}
verificationClock.value = Date.now();
if (!dnsReadyToSave.value) {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
return;
}
settingsSaving.value = true;
try {
const payload = settingsPayload(settingsForm.value);
const response = await fetchAdmin("/api/v1/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存系统设置失败");
settings.value = result.settings;
showSettings.value = false;
notice.value = result.message || "系统设置已保存";
await loadData();
} catch (err) {
settingsError.value = err.message || "保存系统设置失败";
} finally {
settingsSaving.value = false;
}
}
function openCertificateImport(item) {
importDomain.value = item;
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
importIdempotencyKey = `import-${item.id}-${suffix}`;
importFiles.value = {
certificate: null,
private_key: null,
issuer_bundle: null,
};
importResult.value = null;
importError.value = "";
showCertificateImport.value = true;
}
function closeCertificateImport() {
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) {
importFiles.value[kind] = event.target.files?.[0] || null;
importError.value = "";
}
async function importCertificate() {
if (importSaving.value || importResult.value) return;
importError.value = "";
importResult.value = null;
if (!importFiles.value.certificate || !importFiles.value.private_key) {
importError.value = "请选择证书文件和证书密钥文件";
return;
}
const maxSize = 20 * 1024 * 1024;
const files = Object.values(importFiles.value).filter(Boolean);
if (files.some((file) => file.size > maxSize)) {
importError.value = "单个证书文件不能超过 20 MB";
return;
}
importSaving.value = true;
try {
const body = new FormData();
body.append("certificate", importFiles.value.certificate);
body.append("private_key", importFiles.value.private_key);
if (importFiles.value.issuer_bundle) {
body.append("issuer_bundle", importFiles.value.issuer_bundle);
}
const response = await fetchAdmin(
`/api/v1/domains/${importDomain.value.id}/certificate/import`,
{ method: "POST", body, headers: { "Idempotency-Key": importIdempotencyKey } },
);
const result = await response.json().catch(() => ({}));
if (response.status === 202 && !result.task?.id) {
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
}
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
(response.status === 202 || result.accepted || result.replayed)) {
importResult.value = result;
showCertificateImport.value = false;
notice.value = `导入任务已提交:${result.task.id},可在运行概览查看执行结果`;
deploymentResult.value = {
domain: importDomain.value.name, running: false, pending: true,
taskID: result.task.id, taskStatus: result.task.status,
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
};
await loadData();
return;
}
if (!response.ok && result.imported !== true) {
throw new Error(result.error || "导入证书失败");
}
importResult.value = result;
notice.value = `域名 ${importDomain.value.name} 的证书已导入`;
if (!response.ok || result.deployment?.total > 0) {
showCertificateImport.value = false;
deploymentResult.value = {
domain: importDomain.value.name,
running: false,
certificateImported: true,
report: result.deployment || null,
error: response.ok ? "" : result.error || "证书已导入,但部署未完成。请重试部署,无需重新导入。",
};
}
await loadData();
} catch (err) {
importError.value = err.message || "导入证书失败";
} finally {
importSaving.value = false;
}
}
async function deleteServer(item) {
if (!window.confirm(`确定删除服务器“${item.name}”吗?关联域名也会解除。`))
return;
try {
const response = await fetchAdmin(`/api/v1/servers/${item.id}`, {
method: "DELETE",
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "删除服务器失败");
notice.value = "服务器已删除";
await loadData();
} catch (err) {
error.value = err.message || "删除服务器失败";
}
}
async function deleteDomain(item) {
if (
!window.confirm(
`确定删除域名“${item.name}”吗?删除前请确认没有正在执行的任务。`,
)
)
return;
error.value = "";
notice.value = "";
try {
const response = await fetchAdmin(`/api/v1/domains/${item.id}`, {
method: "DELETE",
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(result.error || "删除域名失败");
}
notice.value = "域名已删除";
await loadData();
} catch (err) {
error.value = err.message || "删除域名失败";
}
}
async function loadData() {
loading.value = true;
error.value = "";
try {
const [
statusResponse,
authResponse,
domainResponse,
serverResponse,
certificateResponse,
taskResponse,
auditResponse,
settingsResponse,
dnsProviderResponse,
] = await Promise.all([
fetchAdmin("/api/v1/status"),
fetchAdmin("/api/v1/auth/session"),
fetchAdmin("/api/v1/domains"),
fetchAdmin("/api/v1/servers"),
fetchAdmin("/api/v1/certificates"),
fetchAdmin("/api/v1/tasks?limit=20"),
fetchAdmin("/api/v1/audit-logs?limit=20"),
fetchAdmin("/api/v1/settings"),
fetchAdmin("/api/v1/dns/providers"),
]);
const failedResponse = [
statusResponse,
domainResponse,
serverResponse,
certificateResponse,
taskResponse,
auditResponse,
settingsResponse,
dnsProviderResponse,
].find((response) => !response.ok);
if (failedResponse) {
throw new Error(await responseError(failedResponse, "读取管理数据失败"));
}
status.value = await statusResponse.json();
currentUser.value = await authResponse.json();
domains.value = (await domainResponse.json()).items || [];
servers.value = (await serverResponse.json()).items || [];
certificates.value = (await certificateResponse.json()).items || [];
tasks.value = (await taskResponse.json()).items || [];
auditLogs.value = (await auditResponse.json()).items || [];
settings.value = await settingsResponse.json();
const providerResult = await dnsProviderResponse.json();
dnsProviderCatalog.value = (providerResult.items || []).map((item) => ({
...item,
value: item.id,
label: item.name,
}));
settingsForm.value = normalizeSettings(settings.value);
const associations = await Promise.all(
domains.value.map(async (item) => {
const response = await fetchAdmin(
`/api/v1/domains/${item.id}/servers`,
);
return [
item.id,
response.ok ? (await response.json()).items || [] : [],
];
}),
);
domainServers.value = Object.fromEntries(associations);
} catch (err) {
error.value = err.message || "无法连接核心服务,请先启动 scert serve";
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
verificationTimer = window.setInterval(() => {
verificationClock.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
dnsRequestVersion += 1;
stopDNSVerificationTimer();
if (verificationTimer) window.clearInterval(verificationTimer);
document.body.style.overflow = previousBodyOverflow;
});
return {
...domainForm,
...taskMonitor,
status,
currentUser,
canManageSystemSettings,
canRunRenewal,
domains,
servers,
certificates,
tasks,
auditLogs,
settings,
settingsForm,
dnsProviderCatalog,
selectedDNSProvider,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
dnsVerificationRequired,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,
domainServers,
serverDomains,
domainPage,
domainPageSize,
domainPageSizeOptions,
domainPageCount,
paginatedDomains,
domainPageStart,
domainPageEnd,
previousDomainPage,
nextDomainPage,
loading,
saving,
error,
notice,
formError,
showForm,
editingId,
showServerForm,
serverEditingId,
serverSaving,
serverFormError,
certificateTypeLabels,
domainStatusLabels,
taskStatusLabels,
taskTypeLabels,
auditActionLabels,
taskStageLabels,
attemptStatusLabels,
httpsModeLabels,
dnsProviders,
environmentLabels,
webServerLabels,
serverAuthLabel,
isEditing,
formTitle,
form,
serverForm,
showSettings,
settingsSaving,
settingsError,
showCertificateImport,
importDomain,
importSaving,
importError,
importResult,
defaultCertificatePath,
defaultCertificateChainPath,
defaultPrivateKeyPath,
openCreate,
openEdit,
closeForm,
openServerCreate,
openServerEdit,
closeServerForm,
openCertificateImport,
closeCertificateImport,
selectCertificateImportFile,
importCertificate,
showCertificateVersions,
versionDomain,
certificateVersions,
versionsLoading,
versionsError,
restoreSubmitting,
openCertificateVersions,
closeCertificateVersions,
restoreCertificateVersion,
saveServer,
deleteServer,
saveDomain,
deleteDomain,
issueDomain,
validateDomain,
deployDomain,
cancelTask,
retryTask,
showTaskDetails,
taskDetails,
taskAttempts,
taskDetailsLoading,
taskDetailsError,
deploymentResult,
taskTypeLabel,
auditActionLabel,
taskStageLabel,
attemptStatusLabel,
runRenewalNow,
renewalSubmitting,
openSettings,
closeSettings,
saveSettings,
loadData,
displayLabel,
formatDate,
daysRemaining,
statusClass,
};
}