import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { settingsPayload } from "./SettingsPayload.js"; import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.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 domains = ref([]); const servers = ref([]); const sshKeys = ref([]); const certificates = ref([]); const tasks = ref([]); const auditLogs = ref([]); const settings = ref(null); const dnsProviderCatalog = ref([]); const domainServers = ref({}); const domainPage = ref(1); const domainPageSize = ref(5); const domainPageSizeOptions = [5, 10, 15, 30]; const loading = ref(true); const saving = ref(false); const error = ref(""); const notice = ref(""); const formError = ref(""); const showForm = ref(false); const editingId = ref(""); const showServerForm = ref(false); const serverEditingId = ref(""); const serverSaving = ref(false); const serverFormError = ref(""); const showSSHKeyForm = ref(false); const sshKeySaving = ref(false); const sshKeyFormError = ref(""); const sshKeyPlatform = ref("windows"); const sshKeyPassphraseVisible = ref(false); const sshKeyCommandUsername = ref("用户名"); const sshKeyCommandHost = ref("服务器地址"); const sshKeyRandomSuffix = ref(createSSHKeyRandomSuffix()); 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 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 hasOpenModal = computed( () => showForm.value || showServerForm.value || showSSHKeyForm.value || showSettings.value || showCertificateImport.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 sshKeyFileName = computed( () => `${sanitizeSSHKeySegment(sshKeyCommandUsername.value, "用户名")}+scert-${sshKeyRandomSuffix.value}`, ); const sshKeyPath = computed(() => `/ssl/ssh/${sshKeyFileName.value}`); const sshKeyPrivatePath = computed(() => `${sshKeyPath.value}.key`); const sshKeyPublicPath = computed(() => sshKeyPath.value); const sshKeyRemoteTarget = computed( () => `${sanitizeSSHKeySegment(sshKeyCommandUsername.value, "用户名")}@${sanitizeSSHHost(sshKeyCommandHost.value)}`, ); const sshKeyCommands = computed(() => ({ windows: `New-Item -ItemType Directory -Force -Path "/ssl/ssh" | Out-Null; ssh-keygen -t ed25519 -C "scert-deploy" -f "${sshKeyPrivatePath.value}"; Move-Item -Force "${sshKeyPrivatePath.value}.pub" "${sshKeyPublicPath.value}"`, linux: `sudo install -d -m 700 -o "$(id -un)" -g "$(id -gn)" /ssl/ssh && ssh-keygen -t ed25519 -C "scert-deploy" -f "${sshKeyPrivatePath.value}" && mv -f "${sshKeyPrivatePath.value}.pub" "${sshKeyPublicPath.value}" && chmod 644 "${sshKeyPublicPath.value}"`, })); const sshPublicKeyCommands = computed(() => ({ windows: `Get-Content "${sshKeyPublicPath.value}" | ssh ${sshKeyRemoteTarget.value} "umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys"`, linux: `ssh-copy-id -i "${sshKeyPublicPath.value}" ${sshKeyRemoteTarget.value}`, })); const certificateTypeLabels = { single: "单域名", wildcard: "泛域名", san: "SAN 多域名", }; const taskStatusLabels = { queued: "排队中", running: "执行中", success: "已完成", failed: "失败", cancelled: "已取消", }; taskStatusLabels.error = taskStatusLabels.failed; const taskTypeLabels = { management_renewal: "管理证书续期", certificate_issue: "申请证书", certificate_import: "导入证书", certificate_deploy: "部署证书", certificate_validate: "校验证书", certificate_restore: "恢复证书版本", renewal_run: "自动续期", dns_test: "DNS 在线测试", }; const auditActionLabels = { 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: "删除服务器", ssh_key_create: "添加 SSH 私钥", ssh_key_delete: "删除 SSH 私钥", renewal_run: "执行自动续期", }; const taskStageLabels = { 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: "已取消", }; 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 isEditing = computed(() => Boolean(editingId.value)); const formTitle = computed(() => isEditing.value ? "编辑域名配置" : "添加托管域名", ); 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 form = ref(createEmptyForm()); const serverForm = ref(createEmptyServerForm()); const sshKeyForm = ref(createEmptySSHKeyForm()); 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 createEmptyForm() { return { name: "", certificate_type: "single", sans: "", auto_renew: true, acme_environment: "staging", dns_provider: "", server_ids: [], }; } function createEmptyServerForm() { return { name: "", address: "", port: 22, username: "", auth_method: "password", private_key_ref: "", 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 createEmptySSHKeyForm() { return { name: "", private_key: "", passphrase: "" }; } function createSSHKeyRandomSuffix() { const bytes = new Uint8Array(6); crypto.getRandomValues(bytes); return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join( "", ); } function sanitizeSSHKeySegment(value, fallback) { const normalized = String(value || "") .trim() .replace(/[^\p{L}\p{N}._-]+/gu, "-") .replace(/^-+|-+$/g, ""); return normalized || fallback; } function sanitizeSSHHost(value) { const normalized = String(value || "") .trim() .replace(/[^a-zA-Z0-9.:[\]-]+/g, ""); return normalized || "服务器地址"; } 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 openCreate() { editingId.value = ""; form.value = createEmptyForm(); formError.value = ""; showForm.value = true; } function openEdit(item) { editingId.value = item.id; form.value = { name: item.name, certificate_type: item.certificate_type, sans: (item.sans || []).filter((name) => name !== item.name).join("\n"), auto_renew: item.auto_renew !== false, acme_environment: item.acme_environment || "staging", dns_provider: item.dns_provider || "", server_ids: (domainServers.value[item.id] || []).map( (server) => server.id, ), }; formError.value = ""; showForm.value = true; } function closeForm() { if (!saving.value) showForm.value = false; } function openServerCreate() { serverEditingId.value = ""; serverForm.value = createEmptyServerForm(); serverFormError.value = ""; showServerForm.value = true; } function openServerEdit(item) { serverEditingId.value = item.id; serverForm.value = { ...item, 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 openSSHKeyCreate() { sshKeyForm.value = createEmptySSHKeyForm(); sshKeyFormError.value = ""; sshKeyPlatform.value = "windows"; sshKeyPassphraseVisible.value = false; sshKeyCommandUsername.value = serverForm.value.username.trim() || "用户名"; sshKeyCommandHost.value = serverForm.value.address.trim() || "服务器地址"; sshKeyRandomSuffix.value = createSSHKeyRandomSuffix(); sshKeyForm.value.name = sshKeyFileName.value; showSSHKeyForm.value = true; } function closeSSHKeyForm() { if (sshKeySaving.value) return; sshKeyForm.value = createEmptySSHKeyForm(); sshKeyFormError.value = ""; sshKeyPassphraseVisible.value = false; showSSHKeyForm.value = false; } async function importSSHKeyFile(event) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { const content = await file.text(); if (!content.trim()) throw new Error("选择的私钥文件为空"); sshKeyForm.value.private_key = content; sshKeyFormError.value = ""; notice.value = `已读取私钥文件:${file.name}`; } catch (err) { sshKeyFormError.value = err.message || "读取私钥文件失败"; } } async function copySSHKeyCommand(value, message) { try { await navigator.clipboard.writeText(value); notice.value = message; } catch { sshKeyFormError.value = "浏览器未允许访问剪贴板,请手动复制命令"; } } async function saveSSHKey() { sshKeyFormError.value = ""; sshKeySaving.value = true; try { const response = await fetchAdmin("/api/v1/ssh-keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sshKeyForm.value), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存 SSH 私钥失败"); sshKeyForm.value = createEmptySSHKeyForm(); showSSHKeyForm.value = false; notice.value = "SSH 私钥已加密保存"; await loadData(); } catch (err) { sshKeyFormError.value = err.message || "保存 SSH 私钥失败"; } finally { sshKeySaving.value = false; } } async function deleteSSHKey(item) { if ( !window.confirm( `确定删除 SSH 私钥“${item.name}”吗?正在被服务器使用的私钥无法删除。`, ) ) return; try { const response = await fetchAdmin(`/api/v1/ssh-keys/${item.id}`, { method: "DELETE", }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "删除 SSH 私钥失败"); notice.value = "SSH 私钥已删除"; await loadData(); } catch (err) { error.value = err.message || "删除 SSH 私钥失败"; } } 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, auth_method: serverForm.value.auth_method, private_key_ref: serverForm.value.private_key_ref, 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), }; if (serverForm.value.auth_method === "password") { 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 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") { const suffix = crypto.randomUUID ? crypto.randomUUID() : createSSHKeyRandomSuffix(); options.headers = { "Idempotency-Key": `issue-${item.id}-${suffix}` }; } const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options); const result = await response.json().catch(() => ({})); 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 openTaskDetails(item) { showTaskDetails.value = true; taskDetails.value = null; taskAttempts.value = []; taskDetailsError.value = ""; taskDetailsLoading.value = true; try { const [taskResponse, attemptsResponse] = await Promise.all([ fetchAdmin(`/api/v1/tasks/${item.id}`), fetchAdmin(`/api/v1/tasks/${item.id}/attempts`), ]); if (!taskResponse.ok) { throw new Error(await responseError(taskResponse, "读取任务详情失败")); } if (!attemptsResponse.ok) { throw new Error(await responseError(attemptsResponse, "读取任务尝试记录失败")); } taskDetails.value = await taskResponse.json(); taskAttempts.value = (await attemptsResponse.json()).items || []; } catch (err) { taskDetailsError.value = err.message || "读取任务详情失败"; } finally { taskDetailsLoading.value = false; } } function closeTaskDetails() { showTaskDetails.value = false; taskDetails.value = null; taskAttempts.value = []; taskDetailsError.value = ""; } 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 = "证书任务已取消"; await loadData(); } catch (err) { error.value = err.message || "取消任务失败"; } } async function runRenewalNow() { error.value = ""; notice.value = ""; try { const response = await fetchAdmin("/api/v1/renewal/run", { method: "POST" }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "执行续期检查失败"); const report = result.report || {}; notice.value = `续期检查完成:检查 ${report.checked || 0} 项,选中 ${report.selected || 0} 项,成功 ${report.succeeded || 0} 项,失败 ${report.failed || 0} 项`; await loadData(); } 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() { 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 = ""; 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; 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; } function selectCertificateImportFile(kind, event) { importFiles.value[kind] = event.target.files?.[0] || null; event.target.value = ""; importError.value = ""; } async function importCertificate() { 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 }, ); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(result.error || "导入证书失败"); } importResult.value = result; notice.value = `域名 ${importDomain.value.name} 的证书已导入`; 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 saveDomainServers(domainId, serverIds) { const response = await fetchAdmin(`/api/v1/domains/${domainId}/servers`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ server_ids: serverIds }), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存域名服务器关联失败"); domainServers.value = { ...domainServers.value, [domainId]: result.items || [], }; } function parseSANs(value) { return value .split(/[\n,,]/) .map((item) => item.trim()) .filter(Boolean); } async function saveDomain() { formError.value = ""; notice.value = ""; if (!form.value.name.trim()) { formError.value = "请输入主域名"; return; } if ( form.value.certificate_type === "san" && parseSANs(form.value.sans).length === 0 ) { formError.value = "SAN 证书至少需要填写一个附加域名"; return; } saving.value = true; try { const payload = { name: form.value.name, certificate_type: form.value.certificate_type, sans: parseSANs(form.value.sans), auto_renew: form.value.auto_renew, acme_environment: form.value.acme_environment, dns_provider: form.value.dns_provider, }; const path = isEditing.value ? `/api/v1/domains/${editingId.value}` : "/api/v1/domains"; const response = await fetchAdmin(path, { method: isEditing.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 || "保存域名失败"); await saveDomainServers(result.id, form.value.server_ids); showForm.value = false; notice.value = isEditing.value ? "域名配置已更新" : "域名已添加,等待申请证书"; await loadData(); } catch (err) { formError.value = err.message || "保存域名失败"; } finally { saving.value = false; } } 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, sshKeyResponse, 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/ssh-keys"), 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, sshKeyResponse, 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 || []; sshKeys.value = (await sshKeyResponse.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 { status, currentUser, domains, servers, sshKeys, certificates, tasks, auditLogs, settings, settingsForm, dnsProviderCatalog, selectedDNSProvider, dnsTesting, dnsTestError, dnsVerificationElapsedSeconds, dnsVerificationTimeoutSeconds, dnsReadyToSave, dnsVerificationRequired, handleDNSProviderChange, clearDNSCredentials, testDNS, domainServers, domainPage, domainPageSize, domainPageSizeOptions, domainPageCount, paginatedDomains, domainPageStart, domainPageEnd, previousDomainPage, nextDomainPage, loading, saving, error, notice, formError, showForm, editingId, showServerForm, serverEditingId, serverSaving, serverFormError, showSSHKeyForm, sshKeySaving, sshKeyFormError, certificateTypeLabels, domainStatusLabels, taskStatusLabels, taskTypeLabels, auditActionLabels, taskStageLabels, attemptStatusLabels, httpsModeLabels, dnsProviders, environmentLabels, webServerLabels, serverAuthLabel, isEditing, formTitle, form, serverForm, sshKeyForm, sshKeyPlatform, sshKeyPassphraseVisible, sshKeyCommandUsername, sshKeyCommandHost, sshKeyFileName, sshKeyPath, sshKeyPrivatePath, sshKeyPublicPath, showSettings, settingsSaving, settingsError, showCertificateImport, importDomain, importSaving, importError, importResult, sshKeyCommands, sshPublicKeyCommands, defaultCertificatePath, defaultCertificateChainPath, defaultPrivateKeyPath, openCreate, openEdit, closeForm, openServerCreate, openServerEdit, closeServerForm, openSSHKeyCreate, closeSSHKeyForm, importSSHKeyFile, copySSHKeyCommand, saveSSHKey, deleteSSHKey, openCertificateImport, closeCertificateImport, selectCertificateImportFile, importCertificate, saveServer, deleteServer, saveDomain, deleteDomain, issueDomain, validateDomain, deployDomain, cancelTask, openTaskDetails, closeTaskDetails, showTaskDetails, taskDetails, taskAttempts, taskDetailsLoading, taskDetailsError, deploymentResult, taskTypeLabel, auditActionLabel, taskStageLabel, attemptStatusLabel, runRenewalNow, openSettings, closeSettings, saveSettings, loadData, displayLabel, formatDate, daysRemaining, statusClass, }; }