Scert/frontend/src/views/SetupWizard/SetupWizard.vue
xuans 01232e4c68 修改前检查点:保留初始化通知精简进度
保存仅保留 SMTP 的在途修改;旧校验测试待更新,尚未完成实机验收。
2026-09-26 22:04:00 +08:00

857 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { useSetupWizard } from "./SetupWizard.js";
import AccessShell from "../components/AccessShell/AccessShell.vue";
import DatabaseSetup from "./DatabaseSetup.vue";
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const emit = defineEmits(["initialized"]);
const {
isBootstrap,
completed,
databaseForm,
databaseTesting,
databaseError,
databaseVerified,
testDatabase,
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,
} = useSetupWizard(props, emit);
</script>
<template>
<AccessShell variant="setup" aria-label="Scert 初始化向导">
<template #brand>
<img class="access-logo" :src="scertLogo" alt="Scert 标志" />
<p class="access-eyebrow">SCERT 首次运行</p>
<h1 class="access-brand-title">配置证书管理服务</h1>
<p class="access-brand-description">
Scert 与 Scert-admin 共享本次配置。完成初始化后,即可通过管理页面统一管理证书。
</p>
<div class="warning-card">
<strong>初始化提示</strong>
<span>
请先完成 DNS API 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。
</span>
<span v-if="isBootstrap">
Linux 服务由管理员预先配置防火墙和云安全组。请确认正式管理端口已按需放行;反向代理模式仅允许代理访问内部端口。初始化完成不代表外部网络已验证可达。
</span>
</div>
</template>
<section class="setup-card">
<header>
<div>
<p class="setup-eyebrow">初始化向导</p>
<h2>基础配置</h2>
</div>
<span class="local-badge">{{ isBootstrap ? "受保护的 HTTPS 初始化" : "仅限本机" }}</span>
</header>
<nav class="setup-progress" aria-label="初始化步骤">
<span
class="progress-step"
:class="{ active: currentStep === 1, complete: currentStep > 1 }"
><b>1</b><strong>管理员</strong><small>账号安全</small></span
>
<i aria-hidden="true"></i>
<span
class="progress-step"
:class="{ active: currentStep === 2, complete: currentStep > 2 }"
><b>2</b><strong>服务通知</strong><small>邮件通知</small></span
>
<i aria-hidden="true"></i>
<span
class="progress-step"
:class="{ active: currentStep === 3, complete: currentStep > 3 }"
><b>3</b><strong>DNS 验证</strong><small>权限测试</small></span
>
<i aria-hidden="true"></i>
<span class="progress-step" :class="{ active: currentStep === 4 }"
><b>4</b><strong>管理页面证书</strong><small>访问方式</small></span
>
</nav>
<form
id="setup-form"
name="setup_form"
class="setup-form"
@submit.prevent="handleSubmit"
>
<div class="setup-step-grid">
<fieldset v-if="currentStep === 1" class="setup-step-card" :disabled="submitting || completed">
<legend><span>01</span>超级管理员</legend>
<div class="setup-section-heading admin-account-heading">
<strong>用户信息</strong>
</div>
<div class="setup-grid two-columns admin-account-grid">
<label
><span>用户名</span
><input
id="admin-username"
name="admin_username"
v-model.trim="form.admin_username"
required
autocomplete="username"
placeholder="例如 scert-admin"
/></label>
<label class="email-field"
><span>
绑定邮箱
<HelpTip>
可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。
</HelpTip>
</span><div class="email-input-group">
<input
ref="emailAutofillElement"
class="email-autofill-probe"
id="admin-email-autofill"
name="email"
autocomplete="section-scert email"
tabindex="-1"
aria-hidden="true"
type="email"
@input="handleEmailAutofill"
@change="handleEmailAutofill"
@blur="handleEmailAutofill"
/>
<input
id="admin-email"
name="admin_email"
ref="emailInputElement"
:value="emailLocalPart"
@input="handleEmailInput($event.target.value)"
@paste="handleEmailPaste"
@change="synchronizeEmailField($event.target.value)"
@focus="synchronizeEmailField($event.target.value)"
@blur="handleEmailBlur($event.target.value)"
@animationstart="handleEmailAutofill"
type="text"
autocomplete="email"
inputmode="email"
placeholder="请输入邮箱名称"
/>
<select
id="admin-email-domain"
v-model="selectedEmailDomain"
aria-label="常用邮箱"
@change="applyEmailDomain(selectedEmailDomain)"
>
<option
v-for="option in commonEmailDomains"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</label>
<label
><span>密码</span
><input
id="admin-password"
name="admin_password"
v-model="form.admin_password"
required
minlength="8"
maxlength="12"
type="password"
autocomplete="new-password"
placeholder="8-12 个字符"
/></label>
<label
><span>确认密码</span
><input
id="confirm-password"
name="confirm_password"
v-model="form.confirm_password"
required
minlength="8"
maxlength="12"
type="password"
autocomplete="new-password"
placeholder="再次输入密码"
/></label>
</div>
<div class="setup-section-heading">
<strong>
服务访问配置
<HelpTip>
先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。
</HelpTip>
</strong>
</div>
<div class="setup-grid service-access-grid">
<div class="access-target-field">
<span>管理页面访问方式</span>
<div class="target-switch" role="group" aria-label="管理页面访问方式">
<button
type="button"
:class="{ active: targetType === 'hostname' }"
:aria-pressed="targetType === 'hostname'"
@click="targetType = 'hostname'"
>
使用域名
</button>
<button
type="button"
:class="{ active: targetType === 'ip' }"
:aria-pressed="targetType === 'ip'"
@click="targetType = 'ip'"
>
使用公网 IP
</button>
</div>
</div>
<label v-if="targetType === 'hostname'">
<span>管理页面域名</span>
<input
id="web-hostname"
name="web_hostname"
v-model.trim="form.web_hostname"
required
placeholder="scert.example.com"
/>
</label>
<label v-else>
<span>公网 IP 地址</span>
<input
id="web-public-ip"
name="web_public_ip"
v-model.trim="form.web_public_ip"
required
placeholder="203.0.113.10"
/>
</label>
<label>
<span>
Scert 主服务端口
<HelpTip>
管理页面和 API 对外提供服务使用的端口。
</HelpTip>
</span>
<input
id="server-port"
name="server_port"
v-model.number="form.server_port"
required
min="1"
max="65535"
type="number"
/>
</label>
</div>
<DatabaseSetup
v-if="isBootstrap"
:form="databaseForm"
:testing="databaseTesting"
:verified="databaseVerified"
:error="databaseError"
@test="testDatabase"
/>
<div class="provider-description reset-preview">
<strong>密码重置地址预览</strong>
<span>{{ resetURLPreview }}</span>
<span>只有手动启动 Scert-admin 后,此地址才会临时可用。</span>
</div>
</fieldset>
<fieldset
v-if="currentStep === 2"
class="setup-step-card setup-notification-step"
>
<legend><span>02</span>服务与通知</legend>
<div class="notification-layout">
<div class="notification-row notification-service-row">
<label class="confirm-row compact-row">
<input
id="email-smtp-enabled"
v-model="form.email_smtp_enabled"
type="checkbox"
/>
<span>
<strong>
启用 SMTP 通知
<HelpTip>
通过 SMTP 出站客户端发送任务通知。
</HelpTip>
</strong>
</span>
</label>
</div>
<div v-if="form.email_smtp_enabled" class="notification-basic-panel smtp-basic-panel">
<div class="notification-section-heading">
<div>
<strong>
SMTP 通知配置
<HelpTip>
Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。
</HelpTip>
</strong>
</div>
<span class="notification-recommended-badge">推荐 STARTTLS</span>
</div>
<div class="notification-row smtp-config-row">
<label>
<span>SMTP 主机</span>
<input
id="email-smtp-host"
name="email_smtp_host"
v-model.trim="form.email_smtp_host"
placeholder="smtp.example.com"
autocomplete="off"
/>
</label>
<label>
<span>SMTP 端口</span>
<input
id="email-smtp-port"
name="email_smtp_port"
v-model.number="form.email_smtp_port"
type="number"
min="1"
max="65535"
inputmode="numeric"
/>
</label>
<label>
<span>SMTP 用户名</span>
<input
id="email-smtp-username"
name="email_smtp_username"
v-model.trim="form.email_smtp_username"
placeholder="发件邮箱或账号"
autocomplete="username"
/>
</label>
<label>
<span>SMTP 密码</span>
<input
id="email-smtp-password"
name="email_smtp_password"
v-model="form.email_smtp_password"
type="password"
placeholder="请输入 SMTP 密码或授权码"
autocomplete="new-password"
/>
</label>
</div>
<div class="notification-row smtp-options-row">
<label>
<span>连接模式</span>
<select v-model="form.email_smtp_mode">
<option value="starttls">STARTTLS(推荐)</option>
<option value="implicit_tls">隐式 TLS</option>
<option value="plain">明文</option>
</select>
</label>
<label class="confirm-row compact-row smtp-plaintext-row">
<input
id="email-smtp-allow-plaintext-auth"
v-model="form.email_smtp_allow_plaintext_auth"
type="checkbox"
/>
<span>
<strong>允许明文认证</strong>
<small>仅在明文模式下使用,存在凭据泄露风险。</small>
</span>
</label>
</div>
<div class="notification-row notification-sender-row">
<label>
<span>发件人名称</span>
<input
id="email-sender-name"
v-model.trim="form.email_sender_name"
placeholder="Scert"
/>
</label>
<label>
<span>发件人地址(可选)</span>
<input
id="email-sender-email"
v-model.trim="form.email_sender_email"
type="email"
placeholder="留空使用管理员邮箱"
/>
</label>
<label>
<span>
通知收件人
<HelpTip>
续期、部署和错误通知将发送到这些地址。
</HelpTip>
</span>
<input
id="recipients"
name="recipients"
v-model="form.recipients"
placeholder="多个地址使用逗号分隔"
/>
</label>
</div>
</div>
<div class="provider-description reset-preview">
<strong>密码重置地址</strong>
<span>{{ resetURLPreview }}</span>
<span>只有手动启动 Scert-admin 后,此地址才会临时可用。</span>
</div>
<div v-if="form.email_smtp_enabled" class="notification-advanced-panel">
<button
class="notification-advanced-toggle"
type="button"
:aria-expanded="notificationAdvancedVisible"
@click="toggleNotificationAdvanced"
>
<span>
<strong>高级配置</strong>
</span>
<span class="notification-advanced-action">
{{ notificationAdvancedVisible ? "收起配置" : "展开配置" }}
</span>
</button>
<div v-if="notificationAdvancedVisible" class="notification-advanced-content">
<div class="notification-row notification-delivery-row">
<label>
<span>通知事件模式</span>
<select v-model="form.email_event_mode">
<option value="detailed">详细事件</option>
<option value="unified">统一事件</option>
</select>
</label>
<label>
<span>请求超时(秒)</span>
<input
v-model.number="form.email_timeout_secs"
type="number"
min="1"
/>
</label>
<label>
<span>重试次数</span>
<input
v-model.number="form.email_retry_count"
type="number"
min="1"
/>
</label>
<label>
<span>重试间隔(秒)</span>
<input
v-model.trim="form.email_retry_intervals_secs"
placeholder="30,120,600"
/>
</label>
</div>
<div class="notification-bottom-row">
<div class="setup-grid three-columns compact-options email-option-grid">
<label class="confirm-row compact-row">
<input
v-model="form.email_include_deployment_details"
type="checkbox"
/>
<span>部署明细</span>
</label>
<label class="confirm-row compact-row">
<input
v-model="form.email_include_certificate_serial"
type="checkbox"
/>
<span>证书序列号</span>
</label>
<label class="confirm-row compact-row">
<input
v-model="form.email_include_management_url"
type="checkbox"
/>
<span>管理后台链接</span>
</label>
</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset v-if="currentStep === 3" class="setup-step-card">
<legend><span>03</span>DNS 验证</legend>
<div class="setup-grid two-columns">
<label
><span>DNS 服务商</span
><select
id="dns-provider"
name="dns_provider"
v-model="form.dns_provider"
required
:disabled="dnsCatalogLoading"
>
<option v-if="dnsCatalogLoading" value="">
正在读取服务商……
</option>
<option
v-for="provider in dnsProviders"
:key="provider.id"
:value="provider.id"
>
{{ provider.name
}}{{ provider.available ? "" : "(适配器待开放)" }}
</option>
</select></label
>
<label
><span>
DNS 权限测试域名
<HelpTip>
使用域名时可填写管理页面域名;使用公网 IP
时必须另填一个由当前 DNS 账号管理的域名。
</HelpTip>
</span><input
id="dns-test-domain"
name="dns_test_domain"
v-model.trim="dnsTestDomain"
required
placeholder="例如 example.com"
/></label
>
</div>
<div class="setup-grid two-columns propagation-grid">
<label>
<span>
DNS 传播最大等待时间(秒)
<HelpTip>
默认 300 秒,最长允许等待 86400 秒。
</HelpTip>
</span>
<input
id="propagation-timeout-secs"
name="propagation_timeout_secs"
v-model.number="form.propagation_timeout_secs"
type="number"
min="1"
max="86400"
required
/>
</label>
<label>
<span>
DNS 传播检测间隔(秒)
<HelpTip>
默认 5 秒,不能大于最大等待时间。
</HelpTip>
</span>
<input
id="propagation-polling-interval-secs"
name="propagation_polling_interval_secs"
v-model.number="form.propagation_polling_interval_secs"
type="number"
min="1"
:max="form.propagation_timeout_secs"
required
/>
</label>
</div>
<p class="field-hint">
阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT
记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01
传播等待与轮询。
</p>
<p v-if="dnsCatalogError" class="inline-error">
{{ dnsCatalogError }}
</p>
<div
v-if="selectedDNSProvider"
class="provider-description"
:class="{ unavailable: !selectedDNSProvider.available }"
>
<strong>{{
selectedDNSProvider.available
? "在线验证可用"
: "当前仅提供字段模型"
}}</strong>
<span>{{ selectedDNSProvider.description }}</span>
</div>
<div
v-if="selectedDNSProvider"
class="setup-grid two-columns credential-grid"
>
<div class="credential-visibility-row">
<span>DNS 凭据</span>
<label class="credential-visibility-control">
<input
id="dns-credentials-visible"
name="dns_credentials_visible"
v-model="dnsCredentialsVisible"
type="checkbox"
/>
<span>显示凭据明文</span>
</label>
</div>
<label
v-for="field in selectedDNSProvider.credential_fields"
:key="field.name"
>
<span
>{{ field.label
}}{{ field.required ? "(必填)" : "(可选)" }}</span
>
<textarea
v-if="field.name.includes('json') && dnsCredentialsVisible"
:id="`dns-credential-json-${field.name}`"
:name="`dns_credential_${field.name}`"
v-model="dnsCredentials[field.name]"
:required="field.required"
:placeholder="field.placeholder"
rows="4"
autocomplete="off"
></textarea>
<textarea
v-else-if="field.name.includes('json')"
:id="`dns-credential-masked-${field.name}`"
:name="`dns_credential_${field.name}`"
:value="maskCredential(dnsCredentials[field.name])"
rows="4"
readonly
autocomplete="off"
></textarea>
<input
v-else
:id="`dns-credential-value-${field.name}`"
:name="`dns_credential_${field.name}`"
v-model="dnsCredentials[field.name]"
:required="field.required"
:type="field.secret && !dnsCredentialsVisible ? 'password' : 'text'"
:placeholder="field.placeholder"
autocomplete="off"
/>
</label>
</div>
<div class="dns-test-actions">
<button
class="dns-test-button"
type="button"
:disabled="
dnsTesting ||
dnsCatalogLoading ||
!selectedDNSProvider?.available
"
@click="testDNS"
>
{{
dnsTesting
? "正在创建、查询并清理测试记录……"
: "测试 DNS API"
}}
</button>
<span
>测试会临时创建 DNS-01 TXT
记录并自动清理,明文凭据不会写入普通配置文件。</span
>
</div>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>
<p v-if="dnsTestError" class="inline-error">{{ dnsTestError }}</p>
<div v-if="dnsVerificationValid" class="dns-verification-success">
<strong>DNS API 在线验证通过</strong>
<span
>凭据已加密保存,本次验证结果有效至
{{
formatVerificationExpiry(dnsVerification.expires_at)
}}。修改服务商、测试域名或凭据后需要重新测试。</span
>
</div>
<div
v-if="dnsVerification && dnsVerification.test_report"
class="dns-verification-report"
>
<div class="dns-report-heading">
<strong>在线测试过程</strong>
<span v-if="dnsVerification.test_report.record_name">
临时记录:{{ dnsVerification.test_report.record_name }}
</span>
</div>
<div
v-for="stage in dnsVerification.test_report.stages"
:key="stage.name"
class="dns-verification-stage"
:class="`is-${stage.status}`"
>
<span class="dns-stage-name">{{ stage.name }}</span>
<strong>{{ formatVerificationStageStatus(stage.status) }}</strong>
<small>{{ stage.message }}</small>
</div>
</div>
</fieldset>
<fieldset v-if="currentStep === 4" class="setup-step-card" :disabled="submitting || completed">
<legend><span>04</span>证书目录与剩余设置</legend>
<div class="setup-grid two-columns">
<label>
<span>证书部署根目录</span>
<input
id="certificate-root"
name="certificate_root"
v-model.trim="form.certificate_root"
:readonly="isBootstrap"
required
/>
</label>
<label v-if="isBootstrap">
<span>证书签发环境</span>
<select v-model="form.acme_environment">
<option value="staging">测试环境(证书不受公共信任)</option>
<option value="production">正式环境(实际申请受信任域名证书)</option>
</select>
<small>IP 管理入口使用自签名证书,不会因选择正式环境而自动受信任。</small>
</label>
<label>
<span>HTTPS 模式</span>
<select
id="https-mode"
name="https_mode"
v-model="form.https_mode"
>
<option value="direct">默认:核心服务直接终止 HTTPS</option>
<option value="proxy">
高级:Nginx/Caddy 终止公网 HTTPS
</option>
</select>
</label>
<label v-if="form.https_mode === 'proxy'">
<span>代理公开 HTTPS 端口</span>
<input
id="proxy-https-port"
name="proxy_https_port"
v-model.number="form.proxy_https_port"
type="number"
min="1"
max="65535"
required
/>
</label>
</div>
<label class="confirm-row">
<input
id="web-enabled"
name="web_enabled"
v-model="form.web_enabled"
:disabled="isBootstrap"
type="checkbox"
/>
<span>
<strong>
初始化完成后开启管理页面
<HelpTip>
桌面端可通过此开关决定是否启用浏览器管理页面。
</HelpTip>
</strong>
</span>
</label>
</fieldset>
</div>
<p v-if="error" class="setup-error">{{ error }}</p>
<p v-if="resultMessage" class="setup-success">{{ resultMessage }}</p>
<footer>
<span
>请放行初始化及正式服务端口。验证凭据加密保存;完成后关闭临时入口并切换正式管理地址。</span
>
<div class="wizard-actions">
<button
v-if="currentStep > 1"
class="back-button"
type="button"
:disabled="submitting || completed"
@click="previousStep"
>
上一步</button
><button type="submit" :disabled="submitting || dnsTesting || databaseTesting || completed">
{{
currentStep < 4
? "下一步"
: submitting
? "正在保存……"
: "完成初始化"
}}
</button>
</div>
</footer>
</form>
</section>
</AccessShell>
</template>
<style scoped src="./SetupWizard.css"></style>
<style src="./SetupWizardLayout.css"></style>