import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { useDatabaseSetup } from "./DatabaseSetup.js"; import { bootstrapPayload } from "./BootstrapPayload.js"; const defaultDNSVerificationTimeoutSeconds = 60; const aliyunDNSVerificationTimeoutSeconds = 6; const databaseRequiredMessage = "请先验证所选数据库的连接和写权限。"; const databaseExpiredMessage = "数据库验证结果无效或已过期,请重新验证。"; function dnsVerificationTimeoutFor(providerID) { return String(providerID || "").trim().toLowerCase() === "aliyun" ? aliyunDNSVerificationTimeoutSeconds : defaultDNSVerificationTimeoutSeconds; } const commonEmailDomains = [ { label: "常用邮箱", value: "" }, { label: "@163.com", value: "163.com" }, { label: "@qq.com", value: "qq.com" }, { label: "@126.com", value: "126.com" }, { label: "@outlook.com", value: "outlook.com" }, { label: "@gmail.com", value: "gmail.com" }, { label: "@foxmail.com", value: "foxmail.com" }, { label: "@aliyun.com", value: "aliyun.com" }, { label: "@139.com", value: "139.com" }, { label: "@189.cn", value: "189.cn" }, { label: "自定义后缀", value: "custom" }, ]; export function useSetupWizard(props, emit) { const scertLogo = "/Scert.png"; const isBootstrap = props.status.mode === "bootstrap"; const apiBase = isBootstrap ? "/api/v1/bootstrap" : "/api/v1/setup"; const submitting = ref(false); const error = ref(""); const resultMessage = ref(""); const dnsCatalogLoading = ref(true); const dnsCatalogError = ref(""); const dnsTesting = ref(false); const dnsTestError = ref(""); const dnsVerificationElapsedSeconds = ref(0); const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds); const dnsProviders = ref([]); const dnsCredentials = ref({}); const dnsCredentialsVisible = ref(true); const dnsTestDomain = ref(props.status.web_hostname || ""); const dnsVerification = ref(null); const verificationClock = ref(Date.now()); const database = useDatabaseSetup(verificationClock); watch(database.databaseVerified, (verified) => { if (verified && [databaseRequiredMessage, databaseExpiredMessage].includes(error.value)) { error.value = ""; } }, { flush: "sync" }); const completed = ref(false); const currentStep = ref(1); const notificationAdvancedVisible = ref(false); const selectedEmailDomain = ref(""); const emailLocalPart = ref(""); const emailInputElement = ref(null); const emailAutofillElement = ref(null); let emailVisibleEdited = false; let verificationTimer; let dnsVerificationTimer; let dnsRequestVersion = 0; let emailAutofillTimer; const targetType = ref(props.status.web_public_ip ? "ip" : "hostname"); const form = ref({ admin_username: "", admin_email: "", admin_password: "", confirm_password: "", server_port: props.status.mode === "bootstrap" ? 80 : (props.status.server_port || 80), email_smtp_enabled: false, email_smtp_host: "", email_smtp_port: 587, email_smtp_username: "", email_smtp_password: "", email_smtp_mode: "starttls", email_smtp_allow_plaintext_auth: false, email_event_mode: "detailed", email_include_deployment_details: true, email_include_certificate_serial: true, email_include_management_url: true, email_timeout_secs: 10, email_retry_count: 3, email_retry_intervals_secs: "30,120,600", recipients: "", dns_provider: props.status.dns_provider || "aliyun", propagation_timeout_secs: props.status.propagation_timeout_secs || 300, propagation_polling_interval_secs: props.status.propagation_polling_interval_secs || 5, certificate_root: props.status.certificate_root || "", web_hostname: props.status.web_hostname || "", web_public_ip: props.status.web_public_ip || "", web_enabled: props.status.web_enabled !== false, https_mode: props.status.https_mode || "direct", proxy_https_port: props.status.proxy_https_port || 443, acme_environment: props.status.acme_environment || "staging", }); const targetLabel = computed(() => targetType.value === "hostname" ? "管理页面域名" : "公网 IP 地址", ); const resetURLPreview = computed(() => { const rawHost = targetType.value === "hostname" ? form.value.web_hostname : form.value.web_public_ip; const host = String(rawHost || "") .trim() .replace(/^\[|\]$/g, ""); if (!host) return "完成域名或公网 IP 配置后自动生成"; const displayHost = host.includes(":") ? `[${host}]` : host; return `https://${displayHost}/reset`; }); const selectedDNSProvider = computed( () => dnsProviders.value.find( (provider) => provider.id === form.value.dns_provider, ) || null, ); const dnsVerificationValid = computed(() => { verificationClock.value; if ( dnsVerification.value?.verified !== true || !dnsVerification.value?.verification_token || (!isBootstrap && !dnsVerification.value?.credential_ref) ) return false; return new Date(dnsVerification.value.expires_at).getTime() > Date.now(); }); function invalidateDNSVerification() { dnsRequestVersion += 1; stopDNSVerificationTimer(); dnsTesting.value = false; dnsVerification.value = null; dnsTestError.value = ""; } 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); } function formatVerificationExpiry(value) { if (!value) return ""; return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "medium", timeZone: "Asia/Shanghai", }).format(new Date(value)); } function formatVerificationStageStatus(status) { switch (status) { case "completed": return "已完成"; case "warning": return "警告"; case "failed": return "失败"; default: return "处理中"; } } function findCommonEmailDomain(domain) { const normalizedDomain = String(domain || "").trim().toLowerCase(); return commonEmailDomains.find( (option) => option.value && option.value !== "custom" && option.value === normalizedDomain, ); } function getEmailLocalPart(value) { const text = String(value || "").trim(); const atIndex = text.indexOf("@"); return (atIndex >= 0 ? text.slice(0, atIndex) : text).trim(); } function isCompleteEmail(localPart, domain) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test( `${localPart}@${domain}`, ); } function updateEmailAutofillValue(value) { if (!emailAutofillElement.value) return; emailAutofillElement.value.value = String(value || ""); } function handleEmailInput(value, clearError = true) { emailVisibleEdited = true; if (clearError) error.value = ""; const rawValue = String(value || "").trim(); if (!rawValue) { emailLocalPart.value = ""; selectedEmailDomain.value = ""; form.value.admin_email = ""; updateEmailAutofillValue(""); return; } const atIndex = rawValue.indexOf("@"); if (atIndex < 0) { emailLocalPart.value = rawValue; form.value.admin_email = selectedEmailDomain.value && selectedEmailDomain.value !== "custom" ? `${rawValue}@${selectedEmailDomain.value}` : rawValue; updateEmailAutofillValue(form.value.admin_email); return; } const localPart = rawValue.slice(0, atIndex).trim(); const domain = rawValue.slice(atIndex + 1).trim().toLowerCase(); if (!localPart || !domain) { emailLocalPart.value = rawValue; selectedEmailDomain.value = domain ? "custom" : ""; form.value.admin_email = rawValue; updateEmailAutofillValue(form.value.admin_email); return; } const commonDomain = findCommonEmailDomain(domain); if (commonDomain) { emailLocalPart.value = localPart; selectedEmailDomain.value = commonDomain.value; form.value.admin_email = `${localPart}@${commonDomain.value}`; updateEmailAutofillValue(form.value.admin_email); return; } emailLocalPart.value = rawValue; selectedEmailDomain.value = ""; form.value.admin_email = rawValue; updateEmailAutofillValue(form.value.admin_email); } function synchronizeEmailField(value) { const visibleValue = emailInputElement.value?.value?.trim() || ""; const autofillValue = emailAutofillElement.value?.value?.trim() || ""; const inputValue = value !== undefined ? value : visibleValue.includes("@") ? visibleValue : emailVisibleEdited ? visibleValue : autofillValue.includes("@") ? autofillValue : visibleValue; const rawValue = String(inputValue || "").trim(); if (!rawValue) return; if (rawValue.includes("@") || rawValue !== emailLocalPart.value) { // 自动同步不应清除数据库、密码等其他字段的校验结果。 handleEmailInput(rawValue, false); return; } if ( selectedEmailDomain.value && selectedEmailDomain.value !== "custom" ) { form.value.admin_email = `${rawValue}@${selectedEmailDomain.value}`; updateEmailAutofillValue(form.value.admin_email); } } function handleEmailAutofill() { synchronizeEmailField(); } function handleEmailPaste(event) { const pastedValue = event.clipboardData?.getData("text") || ""; if (!pastedValue.includes("@")) return; event.preventDefault(); handleEmailInput(pastedValue); handleEmailBlur(); } function handleEmailBlur(value) { synchronizeEmailField(value); const rawValue = String(emailLocalPart.value || "").trim(); const atIndex = rawValue.indexOf("@"); if (atIndex < 0) { handleEmailInput(rawValue); return; } const localPart = rawValue.slice(0, atIndex).trim(); const domain = rawValue.slice(atIndex + 1).trim().toLowerCase(); if (localPart && isCompleteEmail(localPart, domain)) { emailLocalPart.value = localPart; selectedEmailDomain.value = findCommonEmailDomain(domain)?.value || "custom"; form.value.admin_email = `${localPart}@${domain}`; updateEmailAutofillValue(form.value.admin_email); } } function applyEmailDomain(domain) { error.value = ""; if (!domain || domain === "custom") return; const currentEmail = form.value.admin_email.trim(); const localPart = getEmailLocalPart(emailLocalPart.value) || getEmailLocalPart(currentEmail); if (!localPart) { selectedEmailDomain.value = ""; error.value = "请先输入邮箱名称,再选择常用邮箱。"; return; } emailLocalPart.value = localPart; form.value.admin_email = `${localPart}@${domain}`; updateEmailAutofillValue(form.value.admin_email); } function toggleNotificationAdvanced() { notificationAdvancedVisible.value = !notificationAdvancedVisible.value; } async function loadDNSProviders() { dnsCatalogLoading.value = true; dnsCatalogError.value = ""; try { const response = await fetch(`${apiBase}/dns/providers`); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "读取 DNS 服务商列表失败"); dnsProviders.value = Array.isArray(result.items) ? result.items : []; if ( !dnsProviders.value.some( (provider) => provider.id === form.value.dns_provider, ) ) { form.value.dns_provider = dnsProviders.value.find((provider) => provider.available)?.id || ""; } } catch (err) { dnsCatalogError.value = err.message || "读取 DNS 服务商列表失败"; } finally { dnsCatalogLoading.value = false; } } function credentialPayload() { const payload = {}; for (const field of selectedDNSProvider.value?.credential_fields || []) { const value = String(dnsCredentials.value[field.name] || "").trim(); if (value) payload[field.name] = value; } return payload; } function maskCredential(value) { const text = String(value || ""); if (!text) return ""; return "•".repeat(Math.min(Math.max(text.length, 8), 64)); } async function testDNS() { dnsTestError.value = ""; resultMessage.value = ""; invalidateDNSVerification(); const provider = selectedDNSProvider.value; if (!provider) { dnsTestError.value = "请选择 DNS 服务商。"; return; } if (!provider.available) { dnsTestError.value = "该 DNS 服务商在线验证适配器尚未开放。"; return; } const testDomain = dnsTestDomain.value.trim(); if (!testDomain) { dnsTestError.value = "请填写 DNS 权限测试域名。"; return; } for (const field of provider.credential_fields || []) { if ( field.required && !String(dnsCredentials.value[field.name] || "").trim() ) { dnsTestError.value = `请填写${field.label}。`; return; } } const requestVersion = dnsRequestVersion; dnsTesting.value = true; startDNSVerificationTimer(requestVersion, provider.id); try { const response = await fetch(`${apiBase}/dns/test`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: provider.id, test_domain: testDomain, credentials: credentialPayload(), }), }); const result = await response.json().catch(() => ({})); if (requestVersion !== dnsRequestVersion) return; updateDNSVerificationTimeoutSeconds(result.verification_timeout_secs); if (result.test_report) { dnsVerification.value = { test_report: result.test_report }; } if (!response.ok) throw new Error(result.error || "DNS API 在线验证失败"); dnsVerification.value = result; } catch (err) { if (requestVersion !== dnsRequestVersion) return; dnsTestError.value = err.message || "DNS API 在线验证失败"; } finally { if (requestVersion === dnsRequestVersion) { stopDNSVerificationTimer(requestVersion); dnsTesting.value = false; } } } function validateCurrentStep() { verificationClock.value = Date.now(); if (currentStep.value === 1) synchronizeEmailField(); error.value = ""; resultMessage.value = ""; if (currentStep.value === 1) { if (!form.value.admin_username.trim()) { error.value = "请输入超级管理员用户名。"; return false; } if (!form.value.admin_email.trim()) { error.value = "请输入超级管理员绑定邮箱。"; return false; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.admin_email.trim())) { error.value = "请输入合法的超级管理员绑定邮箱。"; return false; } if ( form.value.admin_password.length < 8 || form.value.admin_password.length > 12 ) { error.value = "超级管理员密码长度必须为 8 至 12 个字符。"; return false; } if (form.value.admin_password !== form.value.confirm_password) { error.value = "两次输入的超级管理员密码不一致。"; return false; } const port = Number(form.value.server_port); if (!Number.isInteger(port) || port < 1 || port > 65535) { error.value = "请输入 1 至 65535 之间的主服务端口。"; return false; } const hostname = targetType.value === "hostname" ? form.value.web_hostname.trim() : ""; const publicIP = targetType.value === "ip" ? form.value.web_public_ip.trim() : ""; if (!hostname && !publicIP) { error.value = `请填写${targetLabel.value}。`; return false; } if (isBootstrap && !database.databaseVerified.value) { error.value = databaseRequiredMessage; return false; } } if (currentStep.value === 2) { if (form.value.email_smtp_enabled) { if (!form.value.email_smtp_host.trim()) { error.value = "启用 SMTP 通知后必须填写 SMTP 主机。"; return false; } const smtpPort = Number(form.value.email_smtp_port); if (!Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535) { error.value = "SMTP 端口必须在 1 至 65535 之间。"; return false; } if (!form.value.email_smtp_username.trim()) { error.value = "启用 SMTP 通知后必须填写 SMTP 用户名。"; return false; } if (!form.value.email_smtp_password) { error.value = "启用 SMTP 通知后必须填写 SMTP 密码。"; return false; } if (!["plain", "starttls", "implicit_tls"].includes(form.value.email_smtp_mode)) { error.value = "SMTP 连接模式无效。"; return false; } if (form.value.email_smtp_mode === "plain" && !form.value.email_smtp_allow_plaintext_auth) { error.value = "SMTP 明文模式必须明确允许明文认证,建议改用 STARTTLS 或隐式 TLS。"; return false; } } } if (currentStep.value === 3) { const propagationTimeout = Number(form.value.propagation_timeout_secs); const propagationInterval = Number( form.value.propagation_polling_interval_secs, ); if ( !Number.isInteger(propagationTimeout) || propagationTimeout < 1 || propagationTimeout > 86400 ) { error.value = "DNS 传播最大等待时间必须在 1 至 86400 秒之间。"; return false; } if ( !Number.isInteger(propagationInterval) || propagationInterval < 1 || propagationInterval > propagationTimeout ) { error.value = "DNS 传播检测间隔必须大于 0 且不超过最大等待时间。"; return false; } const provider = selectedDNSProvider.value; if (!provider) { error.value = "请选择 DNS 服务商。"; return false; } if (!provider.available) { error.value = "该 DNS 服务商在线验证适配器尚未开放。"; return false; } if (!dnsTestDomain.value.trim()) { error.value = "请填写 DNS 权限测试域名。"; return false; } for (const field of provider.credential_fields || []) { if ( field.required && !String(dnsCredentials.value[field.name] || "").trim() ) { error.value = `请填写${field.label}。`; return false; } } if (!dnsVerificationValid.value) { error.value = "请先完成 DNS API 在线验证。"; return false; } } if (currentStep.value === 4) { if (form.value.https_mode === "proxy") { const port = Number(form.value.proxy_https_port); if (!Number.isInteger(port) || port < 1 || port > 65535) { error.value = "代理公开 HTTPS 端口必须为 1 至 65535 的整数。"; return false; } if (isBootstrap && (form.value.acme_environment !== "production" || !form.value.web_hostname.trim())) { error.value = "反向代理管理入口需要管理域名和正式环境证书。测试环境或 IP 自签名证书请使用直接 HTTPS 模式,避免初始化后出现 502。"; return false; } } if (!form.value.certificate_root.trim()) { error.value = "请填写证书部署根目录。"; return false; } } return true; } function nextStep() { if (!validateCurrentStep()) return; if (currentStep.value < 4) currentStep.value += 1; } function previousStep() { error.value = ""; resultMessage.value = ""; if (currentStep.value > 1) currentStep.value -= 1; } function handleSubmit() { if (submitting.value || completed.value) return; if (currentStep.value < 4) { nextStep(); return; } return submit(); } function recipients() { return form.value.recipients .split(/[\n,,]/) .map((item) => item.trim()) .filter(Boolean); } function retryIntervals() { return form.value.email_retry_intervals_secs .split(/[,,]/) .map((item) => Number(item.trim())) .filter((item) => Number.isInteger(item) && item > 0); } async function submit() { verificationClock.value = Date.now(); error.value = ""; resultMessage.value = ""; if (isBootstrap && !database.databaseVerified.value) { error.value = databaseExpiredMessage; return; } if (!dnsVerificationValid.value) { error.value = "必须先完成 DNS API 在线验证;验证结果已过期时请重新测试。"; return; } // 提交前重新检查全部步骤,不能依赖之前点击“下一步”时的校验结果。 for (let step = 1; step <= 4; step += 1) { currentStep.value = step; if (!validateCurrentStep()) return; } const hostname = targetType.value === "hostname" ? form.value.web_hostname.trim() : ""; const publicIP = targetType.value === "ip" ? form.value.web_public_ip.trim() : ""; if (!hostname && !publicIP) { error.value = `请填写${targetLabel.value}。`; return; } if (isBootstrap && hostname && hostname.toLowerCase().replace(/\.$/, "") !== dnsTestDomain.value.trim().toLowerCase().replace(/\.$/, "")) { error.value = "DNS 验证域名必须与管理页面域名一致,请重新验证。"; return; } submitting.value = true; try { const response = await fetch(isBootstrap ? `${apiBase}/complete` : apiBase, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify((isBootstrap ? (payload) => bootstrapPayload(payload, database.databaseVerification.value.verification_token, form.value.acme_environment) : (payload) => payload)({ admin_username: form.value.admin_username, admin_email: form.value.admin_email, admin_password: form.value.admin_password, server_port: Number(form.value.server_port), email_http_enabled: false, email_smtp_enabled: form.value.email_smtp_enabled, email_smtp_host: form.value.email_smtp_host, email_smtp_port: Number(form.value.email_smtp_port), email_smtp_username: form.value.email_smtp_username, email_smtp_password: form.value.email_smtp_password, email_smtp_mode: form.value.email_smtp_mode, email_smtp_allow_plaintext_auth: form.value.email_smtp_allow_plaintext_auth, email_event_mode: form.value.email_event_mode, email_include_deployment_details: form.value.email_include_deployment_details, email_include_certificate_serial: form.value.email_include_certificate_serial, email_include_management_url: form.value.email_include_management_url, email_timeout_secs: Number(form.value.email_timeout_secs), email_retry_count: Number(form.value.email_retry_count), email_retry_intervals_secs: retryIntervals(), recipients: recipients(), dns_provider: form.value.dns_provider, dns_credential_ref: dnsVerification.value.credential_ref, dns_verification_token: dnsVerification.value.verification_token, dns_test_domain: dnsTestDomain.value.trim(), propagation_timeout_secs: Number( form.value.propagation_timeout_secs, ), propagation_polling_interval_secs: Number( form.value.propagation_polling_interval_secs, ), certificate_root: form.value.certificate_root, web_hostname: hostname, web_public_ip: publicIP, web_enabled: form.value.web_enabled, https_mode: form.value.https_mode, proxy_https_port: form.value.https_mode === "proxy" ? Number(form.value.proxy_https_port) : 0, })), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存初始化配置失败"); if (isBootstrap) { let destination; try { destination = new URL(result?.management_url); } catch { throw new Error("初始化响应不完整,请检查服务器状态后再继续。"); } if (result?.completed !== true || destination.protocol !== "https:" || destination.username || destination.password) { throw new Error("初始化响应不完整,请检查服务器状态后再继续。"); } } completed.value = true; resultMessage.value = isBootstrap ? `初始化配置已保存,正在切换至 ${result.management_url}。请稍候;IP 自签名证书或测试证书需要人工核对。` : result.message || "初始化配置已保存。"; if (isBootstrap && Array.isArray(result.warnings)) { const warnings = result.warnings.filter((message) => typeof message === "string" && message.trim()); if (warnings.length) resultMessage.value += ` 注意:${warnings.join(";")}`; } emit("initialized", result); } catch (err) { error.value = err.message || "保存初始化配置失败"; if (isBootstrap) { // 服务端可能已领取令牌;失败重试必须重新验证,不能复用已消费状态。 database.invalidateDatabase(); invalidateDNSVerification(); } } finally { submitting.value = false; } } watch( () => form.value.dns_provider, () => { dnsCredentials.value = {}; invalidateDNSVerification(); }, { flush: "sync" }, ); watch(dnsCredentials, invalidateDNSVerification, { deep: true, flush: "sync" }); watch(dnsTestDomain, invalidateDNSVerification, { flush: "sync" }); watch(() => [targetType.value, form.value.web_hostname], ([target, hostname]) => { if (isBootstrap && target === "hostname") dnsTestDomain.value = hostname; }, { flush: "sync" }); onMounted(() => { loadDNSProviders(); emailAutofillTimer = window.setInterval(() => { if (currentStep.value === 1) synchronizeEmailField(); }, 250); verificationTimer = window.setInterval(() => { verificationClock.value = Date.now(); }, 30000); }); onUnmounted(() => { invalidateDNSVerification(); if (verificationTimer) window.clearInterval(verificationTimer); if (emailAutofillTimer) window.clearInterval(emailAutofillTimer); }); return { isBootstrap, completed, ...database, scertLogo, submitting, error, resultMessage, dnsCatalogLoading, dnsCatalogError, dnsTesting, dnsTestError, dnsVerificationElapsedSeconds, dnsVerificationTimeoutSeconds, dnsProviders, dnsCredentials, dnsCredentialsVisible, maskCredential, dnsTestDomain, dnsVerification, currentStep, notificationAdvancedVisible, targetType, form, commonEmailDomains, selectedEmailDomain, emailLocalPart, emailInputElement, emailAutofillElement, handleEmailAutofill, targetLabel, resetURLPreview, selectedDNSProvider, dnsVerificationValid, formatVerificationExpiry, formatVerificationStageStatus, applyEmailDomain, toggleNotificationAdvanced, handleEmailInput, synchronizeEmailField, handleEmailPaste, handleEmailBlur, testDNS, handleSubmit, previousStep, }; }