2026-09-23 09:40:59 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<PageHeader
|
|
|
|
|
|
title="证书管理"
|
|
|
|
|
|
description="通过 ACME 自动申请、续签,或导入已有证书,用于为域名提供安全的 HTTPS 访问"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #actions>
|
|
|
|
|
|
<n-button @click="openImport">导入证书</n-button>
|
|
|
|
|
|
<n-button type="primary" @click="openApply">
|
|
|
|
|
|
<template #icon><n-icon :component="AddOutline" /></template>
|
|
|
|
|
|
申请证书
|
|
|
|
|
|
</n-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</PageHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="stats-row">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<n-icon :component="FolderOpenOutline" class="stat-card__icon stat-card__icon--blue" />
|
|
|
|
|
|
<div class="stat-card__value">{{ records.length }}</div>
|
|
|
|
|
|
<div class="stat-card__label">证书总数</div>
|
|
|
|
|
|
<div class="stat-card__sub">当前已配置的证书</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<n-icon :component="CheckmarkCircleOutline" class="stat-card__icon stat-card__icon--green" />
|
|
|
|
|
|
<div class="stat-card__value stat-card__value--green">{{ normalCount }}</div>
|
|
|
|
|
|
<div class="stat-card__label">正常证书</div>
|
|
|
|
|
|
<div class="stat-card__sub">运行正常</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<n-icon :component="AlertCircleOutline" class="stat-card__icon stat-card__icon--amber" />
|
|
|
|
|
|
<div class="stat-card__value stat-card__value--amber">{{ expiringCount }}</div>
|
|
|
|
|
|
<div class="stat-card__label">即将过期</div>
|
|
|
|
|
|
<div class="stat-card__sub">30 天内到期</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<n-icon :component="CloseCircleOutline" class="stat-card__icon stat-card__icon--red" />
|
|
|
|
|
|
<div class="stat-card__value stat-card__value--red">{{ expiredCount }}</div>
|
|
|
|
|
|
<div class="stat-card__label">已过期</div>
|
|
|
|
|
|
<div class="stat-card__sub">需要处理</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard flush class="cert-panel">
|
|
|
|
|
|
<div class="cert-toolbar">
|
|
|
|
|
|
<n-input
|
|
|
|
|
|
v-model:value="searchQuery"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
placeholder="搜索证书名称或关联域名..."
|
|
|
|
|
|
clearable
|
|
|
|
|
|
class="cert-toolbar__search"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #prefix>
|
|
|
|
|
|
<n-icon :component="SearchOutline" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</n-input>
|
|
|
|
|
|
<n-select
|
|
|
|
|
|
v-model:value="statusFilter"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
:options="statusFilterOptions"
|
|
|
|
|
|
class="cert-toolbar__filter"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<n-select
|
|
|
|
|
|
v-model:value="issuerFilter"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
:options="issuerFilterOptions"
|
|
|
|
|
|
class="cert-toolbar__filter"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="cert-toolbar__spacer" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="loading" class="cert-loading">
|
|
|
|
|
|
<n-spin size="medium" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="filteredRecords.length > 0" class="cert-table-wrap">
|
|
|
|
|
|
<n-data-table
|
|
|
|
|
|
class="cert-table"
|
|
|
|
|
|
:columns="columns"
|
|
|
|
|
|
:data="paginatedRecords"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
:scroll-x="1100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-if="filteredRecords.length > pageSize" class="cert-pagination">
|
|
|
|
|
|
<n-pagination
|
|
|
|
|
|
v-model:page="page"
|
|
|
|
|
|
:page-size="pageSize"
|
|
|
|
|
|
:item-count="filteredRecords.length"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
v-else-if="records.length === 0"
|
|
|
|
|
|
title="还没有证书"
|
|
|
|
|
|
description="申请证书时选择 DNS API 完成验证即可;本地测试也可直接导入已有证书。"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #action>
|
|
|
|
|
|
<div class="cert-empty-actions">
|
|
|
|
|
|
<n-button @click="openImport">导入证书</n-button>
|
|
|
|
|
|
<n-button type="primary" @click="openApply">
|
|
|
|
|
|
<template #icon><n-icon :component="AddOutline" /></template>
|
|
|
|
|
|
申请证书
|
|
|
|
|
|
</n-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</EmptyState>
|
|
|
|
|
|
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
v-else
|
|
|
|
|
|
title="没有匹配的证书"
|
|
|
|
|
|
description="试试调整搜索关键词或筛选条件。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="tip-box">
|
|
|
|
|
|
<n-icon :component="InformationCircleOutline" class="tip-box__icon" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>使用提示:</strong>
|
|
|
|
|
|
证书属于域名本身;DNS 服务商仅作为申请/续签时的 DNS-01 验证通道,不会在证书上长期绑定。一张证书可包含多个域名和通配符,续签时系统会自动选用可用的 DNS 凭证完成验证。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 申请(三步向导) -->
|
|
|
|
|
|
<n-modal
|
|
|
|
|
|
v-model:show="showApply"
|
|
|
|
|
|
preset="card"
|
|
|
|
|
|
:mask-closable="applyStep === 3 && applyFinished"
|
|
|
|
|
|
:close-on-esc="applyStep !== 3 || applyFinished"
|
|
|
|
|
|
:style="{ width: applyStep === 3 ? 'min(720px, 96vw)' : 'min(640px, 92vw)' }"
|
|
|
|
|
|
:segmented="{ content: true, footer: 'soft' }"
|
|
|
|
|
|
:content-style="{ paddingTop: '8px' }"
|
|
|
|
|
|
@after-leave="resetApplyWizard"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<div class="modal-header__title">申请证书</div>
|
|
|
|
|
|
<div class="modal-header__desc">通过 DNS-01 验证自动申请证书,证书归属于域名而非 DNS 服务商</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="apply-steps">
|
|
|
|
|
|
<div class="apply-step" :class="{ 'apply-step--active': applyStep === 1, 'apply-step--done': applyStep > 1 }">
|
|
|
|
|
|
<span class="apply-step__dot">1</span>
|
|
|
|
|
|
<span class="apply-step__label">填写信息</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="apply-step__line" :class="{ 'apply-step__line--done': applyStep > 1 }" />
|
|
|
|
|
|
<div class="apply-step" :class="{ 'apply-step--active': applyStep === 2, 'apply-step--done': applyStep > 2 }">
|
|
|
|
|
|
<span class="apply-step__dot">2</span>
|
|
|
|
|
|
<span class="apply-step__label">DNS 验证</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="apply-step__line" :class="{ 'apply-step__line--done': applyStep > 2 }" />
|
|
|
|
|
|
<div class="apply-step" :class="{ 'apply-step--active': applyStep === 3, 'apply-step--done': applyFinished && applyResult?.ok }">
|
|
|
|
|
|
<span class="apply-step__dot">3</span>
|
|
|
|
|
|
<span class="apply-step__label">申请完成</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<n-alert
|
|
|
|
|
|
v-if="applyStep === 1 && !applyEmailConfigured"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
class="form-alert apply-email-alert"
|
|
|
|
|
|
title="请先填写 ACME 邮箱"
|
|
|
|
|
|
>
|
|
|
|
|
|
首次申请证书需要注册 ACME 账户,请填写有效邮箱(如 admin@example.com),用于账户注册与到期提醒,保存后下次无需重复填写。
|
|
|
|
|
|
</n-alert>
|
|
|
|
|
|
|
|
|
|
|
|
<n-form v-if="applyStep === 1" label-placement="top" class="cert-form">
|
|
|
|
|
|
<n-form-item label="证书名称" feedback="仅用于本地识别,可自定义" :show-feedback="true" required>
|
|
|
|
|
|
<n-input v-model:value="applyCertName" placeholder="例如 example" />
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<n-form-item
|
|
|
|
|
|
label="域名"
|
|
|
|
|
|
feedback="每行一个域名,支持通配符(例如:*.example.com)"
|
|
|
|
|
|
:show-feedback="true"
|
|
|
|
|
|
required
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-input
|
|
|
|
|
|
v-model:value="applyDomainsText"
|
|
|
|
|
|
class="domains-input"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="5"
|
|
|
|
|
|
placeholder="example.com *.example.com api.example.com"
|
|
|
|
|
|
@update:value="syncCertNameFromDomains"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<n-form-item label="颁发机构" :show-feedback="false">
|
|
|
|
|
|
<div class="ca-field">
|
|
|
|
|
|
<div class="ca-cards">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="opt in applyCAOptions"
|
|
|
|
|
|
:key="opt.value"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="ca-card"
|
|
|
|
|
|
:class="{ 'ca-card--active': applyCA === opt.value }"
|
|
|
|
|
|
@click="applyCA = opt.value"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="ca-card__check" aria-hidden="true" />
|
|
|
|
|
|
<div class="ca-card__logo">
|
|
|
|
|
|
<img v-if="caLogo(opt.value)" :src="caLogo(opt.value)" :alt="caCardTitle(opt.value)" />
|
|
|
|
|
|
<span v-else class="ca-card__fallback">{{ caCardTitle(opt.value) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ca-card__sub">{{ caCardSub(opt.value) }}</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-if="applyCA === 'zerossl' && !zerosslApiKey" class="ca-hint ca-hint--warn">
|
|
|
|
|
|
使用 ZeroSSL 需在「设置」中配置 API Key
|
|
|
|
|
|
</p>
|
2026-09-27 10:10:15 +08:00
|
|
|
|
<p v-if="applyCA === 'buypass'" class="ca-hint ca-hint--warn">
|
|
|
|
|
|
Buypass 已于 2025-10-15 停止签发证书,请改选 Let's Encrypt 或 ZeroSSL
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="apply-auto-renew">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="apply-auto-renew__title">自动续期</div>
|
|
|
|
|
|
<div class="apply-auto-renew__sub">在证书到期前自动续期</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<n-switch v-model:value="applyAutoRenew" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<n-form-item
|
|
|
|
|
|
label="ACME 邮箱"
|
|
|
|
|
|
:feedback="applyEmailConfigured ? '已填写,保存后下次申请无需重复填写' : '填写标准邮箱地址,不要包含空格或 mailto: 前缀'"
|
|
|
|
|
|
:show-feedback="true"
|
|
|
|
|
|
:required="!applyEmailConfigured"
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-input v-model:value="applyEmail" placeholder="admin@example.com" />
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<n-alert v-if="applyError" type="error" :bordered="false" class="form-alert" :title="applyError" />
|
|
|
|
|
|
</n-form>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="applyStep === 2" class="verify-step">
|
|
|
|
|
|
<p class="verify-step__intro">
|
|
|
|
|
|
选择用于本次 DNS-01 验证的 DNS 任务。这只是验证通道,选错会在下一步报错,不会与证书长期绑定。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="applyDdnsConfigs.length === 0" class="verify-empty">
|
|
|
|
|
|
<p v-if="ddnsConfigs.length === 0">还没有 DNS 任务。</p>
|
|
|
|
|
|
<p v-else>没有已启用的 DNS 任务。</p>
|
|
|
|
|
|
<p class="verify-empty__sub">
|
|
|
|
|
|
{{ ddnsConfigs.length === 0
|
|
|
|
|
|
? '请先在 DDNS 页面添加 DNS API 凭证,然后返回继续申请。'
|
|
|
|
|
|
: '请先在 DDNS 页面启用至少一个 DNS 任务,然后返回继续申请。' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="verify-options">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="cfg in applyDdnsConfigs"
|
|
|
|
|
|
:key="cfg.id"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="verify-option"
|
|
|
|
|
|
:class="{ 'verify-option--active': applyDdnsConfigId === cfg.id }"
|
|
|
|
|
|
@click="selectVerificationChannel(cfg)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="provider-logo">
|
|
|
|
|
|
<img :src="providerIcon(cfg.provider)" :alt="providerLabel(cfg.provider)" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div class="verify-option__body">
|
|
|
|
|
|
<div class="verify-option__title">{{ providerLabel(cfg.provider) }}</div>
|
|
|
|
|
|
<div class="verify-option__sub">上次同步 {{ formatRelativeTime(cfg.last_updated_at) || '从未' }}</div>
|
|
|
|
|
|
<div class="verify-option__zones">{{ ddnsTaskSummary(cfg) }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="verify-option__check" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<n-alert v-if="applyError" type="error" :bordered="false" class="form-alert" :title="applyError" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="apply-done-step">
|
|
|
|
|
|
<p v-if="!applyFinished" class="apply-done-step__hint">
|
|
|
|
|
|
DNS 验证与证书签发可能需要 1~3 分钟,请保持窗口打开
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div ref="applyLogBox" class="apply-log-box" :class="{ paused: !applyStreaming }">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(line, i) in applyLogLines"
|
|
|
|
|
|
:key="i"
|
|
|
|
|
|
class="apply-log-line"
|
|
|
|
|
|
:class="`apply-log-line--${line.level}`"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ line.text }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="applyLogLines.length === 0 && !applyFinished" class="apply-log-empty">等待日志输出…</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<n-result
|
|
|
|
|
|
v-if="applyFinished && applyResult?.ok"
|
|
|
|
|
|
status="success"
|
|
|
|
|
|
title="证书申请成功"
|
|
|
|
|
|
class="apply-result"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="apply-result-detail">
|
|
|
|
|
|
<div><span class="apply-result-k">证书目录</span><code>{{ applyResult.cert_dir || '-' }}</code></div>
|
|
|
|
|
|
<div><span class="apply-result-k">证书文件</span><code>{{ applyResult.cert_path || '-' }}</code></div>
|
|
|
|
|
|
<div><span class="apply-result-k">私钥文件</span><code>{{ applyResult.key_path || '-' }}</code></div>
|
|
|
|
|
|
<div><span class="apply-result-k">到期时间</span>{{ formatDate(applyResult.expires_at) }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</n-result>
|
|
|
|
|
|
|
|
|
|
|
|
<n-result
|
|
|
|
|
|
v-else-if="applyFinished && applyResult && !applyResult.ok"
|
|
|
|
|
|
status="error"
|
|
|
|
|
|
title="证书申请失败"
|
|
|
|
|
|
:description="applyResult.error || '未知错误'"
|
|
|
|
|
|
class="apply-result"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<n-space v-if="applyStep === 1" :key="1" justify="end">
|
|
|
|
|
|
<n-button @click="showApply = false">取消</n-button>
|
|
|
|
|
|
<n-button type="primary" @click="goApplyStep2">下一步</n-button>
|
|
|
|
|
|
</n-space>
|
|
|
|
|
|
<n-space v-else-if="applyStep === 2" :key="2" justify="end">
|
|
|
|
|
|
<n-button @click="goApplyStep1">上一步</n-button>
|
|
|
|
|
|
<n-button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
:loading="applying"
|
|
|
|
|
|
:disabled="applyDdnsConfigs.length === 0 || !applyDdnsConfigId"
|
|
|
|
|
|
@click="submitApply"
|
|
|
|
|
|
>
|
|
|
|
|
|
开始申请
|
|
|
|
|
|
</n-button>
|
|
|
|
|
|
</n-space>
|
|
|
|
|
|
<n-space v-else-if="applyStep === 3" :key="3" justify="end">
|
|
|
|
|
|
<n-button v-if="!applyFinished" :loading="true" disabled>申请进行中…</n-button>
|
|
|
|
|
|
<n-button v-else type="primary" @click="showApply = false">完成</n-button>
|
|
|
|
|
|
</n-space>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</n-modal>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 导入 -->
|
|
|
|
|
|
<n-modal
|
|
|
|
|
|
v-model:show="showImport"
|
|
|
|
|
|
preset="card"
|
|
|
|
|
|
:style="{ width: 'min(560px, 92vw)' }"
|
|
|
|
|
|
:segmented="{ content: true, footer: 'soft' }"
|
|
|
|
|
|
:content-style="{ paddingTop: '8px' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<div class="modal-header__title">导入证书</div>
|
|
|
|
|
|
<div class="modal-header__desc">提供证书与私钥后,自动解析域名、通配符和到期时间</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<n-form label-placement="top" class="cert-form">
|
|
|
|
|
|
<n-form-item label="导入方式" :show-feedback="false">
|
|
|
|
|
|
<n-radio-group v-model:value="importMode" class="ca-buttons">
|
|
|
|
|
|
<n-radio-button value="paste">粘贴 PEM</n-radio-button>
|
|
|
|
|
|
<n-radio-button value="path">文件路径</n-radio-button>
|
|
|
|
|
|
</n-radio-group>
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-if="importMode === 'paste'">
|
|
|
|
|
|
<n-form-item label="证书 (PEM)" :show-feedback="false">
|
|
|
|
|
|
<n-input
|
|
|
|
|
|
v-model:value="importForm.certificate"
|
|
|
|
|
|
class="pem-input"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="4"
|
|
|
|
|
|
placeholder="-----BEGIN CERTIFICATE-----"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input ref="certFileInput" type="file" accept=".pem,.crt,.cer" class="file-input" @change="onCertFile" />
|
|
|
|
|
|
<n-button text type="primary" class="file-btn" @click="certFileInput?.click()">从本机文件读取</n-button>
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
<n-form-item label="私钥 (PEM)" :show-feedback="false">
|
|
|
|
|
|
<n-input
|
|
|
|
|
|
v-model:value="importForm.private_key"
|
|
|
|
|
|
class="pem-input"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="4"
|
|
|
|
|
|
placeholder="-----BEGIN PRIVATE KEY-----"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input ref="keyFileInput" type="file" accept=".pem,.key" class="file-input" @change="onKeyFile" />
|
|
|
|
|
|
<n-button text type="primary" class="file-btn" @click="keyFileInput?.click()">从本机文件读取</n-button>
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<n-form-item label="证书文件路径">
|
|
|
|
|
|
<n-input v-model:value="importForm.cert_path" placeholder=".data/certs/example.com/fullchain.pem" />
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
<n-form-item
|
|
|
|
|
|
label="私钥文件路径"
|
|
|
|
|
|
feedback="填写 Fonu 服务可访问的路径,导入后会复制到证书目录"
|
|
|
|
|
|
:show-feedback="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-input v-model:value="importForm.key_path" placeholder=".data/certs/example.com/privatekey.pem" />
|
|
|
|
|
|
</n-form-item>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</n-form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<n-space justify="end">
|
|
|
|
|
|
<n-button @click="showImport = false">取消</n-button>
|
|
|
|
|
|
<n-button type="primary" :loading="importing" @click="submitImport">导入</n-button>
|
|
|
|
|
|
</n-space>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</n-modal>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, h, onMounted, onUnmounted, ref, watch, type VNode } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
NAlert,
|
|
|
|
|
|
NButton,
|
|
|
|
|
|
NDataTable,
|
|
|
|
|
|
NForm,
|
|
|
|
|
|
NFormItem,
|
|
|
|
|
|
NIcon,
|
|
|
|
|
|
NInput,
|
|
|
|
|
|
NPagination,
|
|
|
|
|
|
NRadioButton,
|
|
|
|
|
|
NRadioGroup,
|
|
|
|
|
|
NResult,
|
|
|
|
|
|
NSelect,
|
|
|
|
|
|
NSpace,
|
|
|
|
|
|
NSpin,
|
|
|
|
|
|
NSwitch,
|
|
|
|
|
|
NTag,
|
|
|
|
|
|
useDialog,
|
|
|
|
|
|
useMessage,
|
|
|
|
|
|
type DataTableColumns,
|
|
|
|
|
|
} from 'naive-ui'
|
|
|
|
|
|
import {
|
|
|
|
|
|
AddOutline,
|
|
|
|
|
|
AlertCircleOutline,
|
|
|
|
|
|
CheckmarkCircleOutline,
|
|
|
|
|
|
CloseCircleOutline,
|
|
|
|
|
|
FolderOpenOutline,
|
|
|
|
|
|
InformationCircleOutline,
|
|
|
|
|
|
SearchOutline,
|
|
|
|
|
|
} from '@vicons/ionicons5'
|
|
|
|
|
|
import { api, asList } from '../api/client'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
CertificateCAOption,
|
|
|
|
|
|
CertificateJobDone,
|
|
|
|
|
|
CertificateJobEvent,
|
|
|
|
|
|
CertificateRecord,
|
|
|
|
|
|
DDNSConfig,
|
|
|
|
|
|
} from '../api/types'
|
|
|
|
|
|
import EmptyState from '../components/EmptyState.vue'
|
|
|
|
|
|
import FonuCard from '../components/FonuCard.vue'
|
|
|
|
|
|
import LoadError from '../components/LoadError.vue'
|
|
|
|
|
|
import PageHeader from '../components/PageHeader.vue'
|
|
|
|
|
|
import buypassLogo from '../assets/brand/ca/buypass.png'
|
|
|
|
|
|
import letsencryptLogo from '../assets/brand/ca/letsencrypt.png'
|
|
|
|
|
|
import zerosslLogo from '../assets/brand/ca/zerossl.png'
|
|
|
|
|
|
import aliyunIcon from '../assets/brand/dns/aliyun.png'
|
|
|
|
|
|
import cloudflareIcon from '../assets/brand/dns/cloudflare.png'
|
|
|
|
|
|
import dnspodIcon from '../assets/brand/dns/dnspod.png'
|
|
|
|
|
|
import tencentcloudIcon from '../assets/brand/dns/tencentcloud.png'
|
|
|
|
|
|
import volcengineIcon from '../assets/brand/dns/volcengine.png'
|
|
|
|
|
|
import { formatDate, formatRelativeTime } from '../utils/format'
|
|
|
|
|
|
import { renderTableRowActions } from '../utils/tableActions'
|
|
|
|
|
|
|
|
|
|
|
|
type ImportMode = 'paste' | 'path'
|
|
|
|
|
|
|
|
|
|
|
|
const message = useMessage()
|
|
|
|
|
|
const dialog = useDialog()
|
|
|
|
|
|
const records = ref<CertificateRecord[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const downloadingDomain = ref('')
|
|
|
|
|
|
const loadError = ref('')
|
|
|
|
|
|
const applying = ref(false)
|
|
|
|
|
|
const renewingDomain = ref('')
|
|
|
|
|
|
const importing = ref(false)
|
|
|
|
|
|
const showImport = ref(false)
|
|
|
|
|
|
const showApply = ref(false)
|
|
|
|
|
|
const caOptions = ref<CertificateCAOption[]>([])
|
|
|
|
|
|
const applyCAOptions = [
|
|
|
|
|
|
{ value: 'letsencrypt', label: "Let's Encrypt" },
|
|
|
|
|
|
{ value: 'zerossl', label: 'ZeroSSL' },
|
|
|
|
|
|
{ value: 'buypass', label: 'Buypass' },
|
|
|
|
|
|
]
|
|
|
|
|
|
const applyCA = ref('letsencrypt')
|
|
|
|
|
|
const zerosslApiKey = ref('')
|
|
|
|
|
|
const applyEmail = ref('')
|
|
|
|
|
|
const applyCertName = ref('')
|
|
|
|
|
|
const applyDomainsText = ref('')
|
|
|
|
|
|
const applyAutoRenew = ref(true)
|
|
|
|
|
|
const applyStep = ref<1 | 2 | 3>(1)
|
|
|
|
|
|
const applyDdnsConfigId = ref<number | null>(null)
|
|
|
|
|
|
const applyError = ref('')
|
|
|
|
|
|
const applyLogLines = ref<{ level: string; text: string }[]>([])
|
|
|
|
|
|
const applyResult = ref<CertificateJobDone | null>(null)
|
|
|
|
|
|
const applyFinished = ref(false)
|
|
|
|
|
|
const applyStreaming = ref(false)
|
|
|
|
|
|
const applyLogBox = ref<HTMLElement | null>(null)
|
|
|
|
|
|
let applyEventSource: EventSource | null = null
|
|
|
|
|
|
const ddnsConfigs = ref<DDNSConfig[]>([])
|
|
|
|
|
|
const applyDdnsConfigs = computed(() => ddnsConfigs.value.filter((c) => c.enabled))
|
|
|
|
|
|
const certFileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
|
|
const keyFileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
|
|
const importMode = ref<ImportMode>('paste')
|
|
|
|
|
|
const importForm = ref({
|
|
|
|
|
|
certificate: '',
|
|
|
|
|
|
private_key: '',
|
|
|
|
|
|
cert_path: '',
|
|
|
|
|
|
key_path: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const statusFilter = ref('all')
|
|
|
|
|
|
const issuerFilter = ref('all')
|
|
|
|
|
|
const page = ref(1)
|
|
|
|
|
|
const pageSize = 10
|
|
|
|
|
|
|
|
|
|
|
|
const statusFilterOptions = [
|
|
|
|
|
|
{ label: '全部状态', value: 'all' },
|
|
|
|
|
|
{ label: '正常', value: 'ok' },
|
|
|
|
|
|
{ label: '即将过期', value: 'expiring' },
|
|
|
|
|
|
{ label: '已过期', value: 'expired' },
|
|
|
|
|
|
{ label: '异常', value: 'error' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const providerMap: Record<string, { label: string; icon: string }> = {
|
|
|
|
|
|
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
|
|
|
|
|
|
dnspod: { label: 'DNSPod', icon: dnspodIcon },
|
|
|
|
|
|
alidns: { label: '阿里云 DNS', icon: aliyunIcon },
|
|
|
|
|
|
tencentcloud: { label: '腾讯云 DNS', icon: tencentcloudIcon },
|
|
|
|
|
|
volcengine: { label: '火山引擎 DNS', icon: volcengineIcon },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const caLogoMap: Record<string, string> = {
|
|
|
|
|
|
letsencrypt: letsencryptLogo,
|
|
|
|
|
|
'letsencrypt-staging': letsencryptLogo,
|
|
|
|
|
|
zerossl: zerosslLogo,
|
|
|
|
|
|
buypass: buypassLogo,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const caCardMeta: Record<string, { title: string; sub: string }> = {
|
|
|
|
|
|
letsencrypt: { title: "Let's Encrypt", sub: '免费 · 自动续期' },
|
|
|
|
|
|
'letsencrypt-staging': { title: "Let's Encrypt 测试", sub: '仅用于验证流程' },
|
|
|
|
|
|
zerossl: { title: 'ZeroSSL', sub: '免费 · 稳定' },
|
|
|
|
|
|
buypass: { title: 'Buypass', sub: '免费 · 备用' },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function providerLabel(v: string) {
|
|
|
|
|
|
return providerMap[v]?.label ?? v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function providerIcon(v: string) {
|
|
|
|
|
|
return providerMap[v]?.icon ?? ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function caCardTitle(value: string) {
|
|
|
|
|
|
return caCardMeta[value]?.title ?? caOptions.value.find((o) => o.value === value)?.label ?? value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function caCardSub(value: string) {
|
|
|
|
|
|
return caCardMeta[value]?.sub ?? 'ACME 证书'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function caLogo(value: string) {
|
|
|
|
|
|
return caLogoMap[value] ?? ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ddnsManagedZones(cfg: DDNSConfig): string[] {
|
|
|
|
|
|
const zones = new Set<string>()
|
|
|
|
|
|
const add = (zone?: string) => {
|
|
|
|
|
|
const value = zone?.trim().toLowerCase().replace(/^\*\./, '')
|
|
|
|
|
|
if (value) zones.add(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
add(cfg.root_domain)
|
|
|
|
|
|
for (const name of cfg.record_names ?? []) {
|
|
|
|
|
|
add(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (zones.size === 0 && cfg.record_name) {
|
|
|
|
|
|
add(cfg.record_name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...zones]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ddnsRecordCount(cfg: DDNSConfig) {
|
|
|
|
|
|
if (cfg.domain_records?.length) return cfg.domain_records.length
|
|
|
|
|
|
if (cfg.record_names?.length) return cfg.record_names.length
|
|
|
|
|
|
return cfg.record_name ? 1 : 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ddnsTaskSummary(cfg: DDNSConfig) {
|
|
|
|
|
|
const zones = ddnsManagedZones(cfg)
|
|
|
|
|
|
const zone = zones[0] ?? cfg.root_domain
|
|
|
|
|
|
if (!zone) return '未配置域名'
|
|
|
|
|
|
const count = ddnsRecordCount(cfg)
|
|
|
|
|
|
return count > 0 ? `${zone}(共 ${count} 条记录)` : zone
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CertLifeStatus = 'ok' | 'expiring' | 'expired' | 'error'
|
|
|
|
|
|
|
|
|
|
|
|
function certLifeStatus(row: CertificateRecord): CertLifeStatus {
|
|
|
|
|
|
if (row.status === 'error' && !row.expires_at) return 'error'
|
|
|
|
|
|
if (row.expires_at && row.days_left <= 0) return 'expired'
|
|
|
|
|
|
if (row.expires_at && row.days_left <= 30) return 'expiring'
|
|
|
|
|
|
if (row.status === 'error') return 'error'
|
|
|
|
|
|
return 'ok'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function certLifeLabel(status: CertLifeStatus) {
|
|
|
|
|
|
if (status === 'expiring') return '即将过期'
|
|
|
|
|
|
if (status === 'expired') return '已过期'
|
|
|
|
|
|
if (status === 'error') return '异常'
|
|
|
|
|
|
return '正常'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function certLifeTagType(status: CertLifeStatus): 'success' | 'warning' | 'error' | 'default' {
|
|
|
|
|
|
if (status === 'expiring') return 'warning'
|
|
|
|
|
|
if (status === 'expired' || status === 'error') return 'error'
|
|
|
|
|
|
return 'success'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function certDisplayName(domain: string) {
|
|
|
|
|
|
const base = domain.replace(/^\*\./, '').split('.')[0]
|
|
|
|
|
|
return base || domain
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function certSubtitle(row: CertificateRecord) {
|
|
|
|
|
|
const domains = row.domains?.length ? row.domains : [row.domain]
|
|
|
|
|
|
if (row.wildcard || domains.some((d) => d.startsWith('*.'))) return '通配符证书'
|
|
|
|
|
|
if (domains.length > 1) return '多域名证书'
|
|
|
|
|
|
return '主证书'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function issuerFilterOptionsComputed() {
|
|
|
|
|
|
const issuers = new Set<string>()
|
|
|
|
|
|
for (const row of records.value) {
|
|
|
|
|
|
issuers.add(row.acme_ca || 'unknown')
|
|
|
|
|
|
}
|
|
|
|
|
|
const options = [{ label: '全部颁发机构', value: 'all' }]
|
|
|
|
|
|
for (const value of issuers) {
|
|
|
|
|
|
options.push({ label: caLabel(value), value })
|
|
|
|
|
|
}
|
|
|
|
|
|
return options
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const issuerFilterOptions = computed(() => issuerFilterOptionsComputed())
|
|
|
|
|
|
|
|
|
|
|
|
const normalCount = computed(() => records.value.filter((r) => certLifeStatus(r) === 'ok').length)
|
|
|
|
|
|
const expiringCount = computed(() => records.value.filter((r) => certLifeStatus(r) === 'expiring').length)
|
|
|
|
|
|
const expiredCount = computed(() => records.value.filter((r) => certLifeStatus(r) === 'expired').length)
|
|
|
|
|
|
|
|
|
|
|
|
const filteredRecords = computed(() => {
|
|
|
|
|
|
let list = records.value
|
|
|
|
|
|
const q = searchQuery.value.trim().toLowerCase()
|
|
|
|
|
|
if (q) {
|
|
|
|
|
|
list = list.filter((row) => {
|
|
|
|
|
|
const hay = [row.domain, certDisplayName(row.domain), certSubtitle(row), ...(row.domains ?? []), caLabel(row.acme_ca)]
|
|
|
|
|
|
.join(' ')
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
return hay.includes(q)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (statusFilter.value !== 'all') {
|
|
|
|
|
|
list = list.filter((row) => certLifeStatus(row) === statusFilter.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (issuerFilter.value !== 'all') {
|
|
|
|
|
|
list = list.filter((row) => (row.acme_ca || 'unknown') === issuerFilter.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
return list
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const paginatedRecords = computed(() => {
|
|
|
|
|
|
const start = (page.value - 1) * pageSize
|
|
|
|
|
|
return filteredRecords.value.slice(start, start + pageSize)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch([searchQuery, statusFilter, issuerFilter], () => {
|
|
|
|
|
|
page.value = 1
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function caLabel(value?: string) {
|
|
|
|
|
|
return caOptions.value.find((o) => o.value === value)?.label ?? (value === 'imported' ? '手动导入' : value || '-')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderDomainTags(row: CertificateRecord) {
|
|
|
|
|
|
const domains = row.domains?.length ? row.domains : [row.domain]
|
|
|
|
|
|
const visible = domains.slice(0, 2)
|
|
|
|
|
|
const extra = domains.length - visible.length
|
|
|
|
|
|
const tags: VNode[] = visible.map((domain) =>
|
|
|
|
|
|
h(NTag, { size: 'small', round: true, bordered: false, type: 'info' }, () => domain),
|
|
|
|
|
|
)
|
|
|
|
|
|
if (extra > 0) {
|
|
|
|
|
|
tags.push(h(NTag, { size: 'small', round: true, bordered: false }, () => `+${extra}`))
|
|
|
|
|
|
}
|
|
|
|
|
|
return h('div', { class: 'table-tags' }, tags)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderIssuer(row: CertificateRecord) {
|
|
|
|
|
|
const label = caLabel(row.acme_ca)
|
|
|
|
|
|
const logo = caLogo(row.acme_ca || '')
|
|
|
|
|
|
const icon = logo
|
|
|
|
|
|
? h('span', { class: 'issuer-logo' }, [h('img', { src: logo, alt: label })])
|
|
|
|
|
|
: h(
|
|
|
|
|
|
'span',
|
|
|
|
|
|
{ class: `issuer-badge issuer-badge--${issuerTone(row.acme_ca)}` },
|
|
|
|
|
|
label.slice(0, 1).toUpperCase(),
|
|
|
|
|
|
)
|
|
|
|
|
|
return h('div', { class: 'issuer-cell' }, [icon, h('span', { class: 'issuer-cell__label' }, label)])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function issuerTone(ca?: string) {
|
|
|
|
|
|
if (ca === 'letsencrypt' || ca === 'letsencrypt-staging') return 'le'
|
|
|
|
|
|
if (ca === 'zerossl') return 'zero'
|
|
|
|
|
|
if (ca === 'buypass') return 'buypass'
|
|
|
|
|
|
if (ca === 'imported') return 'imported'
|
|
|
|
|
|
return 'default'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const applyEmailConfigured = computed(() => applyEmail.value.trim().length > 0)
|
|
|
|
|
|
|
|
|
|
|
|
function validateApplyEmail() {
|
|
|
|
|
|
const email = applyEmail.value.trim()
|
|
|
|
|
|
if (!email) {
|
|
|
|
|
|
return '请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
|
|
|
|
return 'ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址'
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validateApplyCA(domains: string[]) {
|
|
|
|
|
|
if (applyCA.value === 'buypass') {
|
|
|
|
|
|
if (domains.some((d) => d.startsWith('*.'))) {
|
|
|
|
|
|
return 'Buypass 不支持通配符证书'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (domains.length > 5) {
|
|
|
|
|
|
return 'Buypass 单张证书最多支持 5 个域名'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (applyCA.value === 'zerossl' && !zerosslApiKey.value.trim()) {
|
|
|
|
|
|
return '请先在设置中配置 ZeroSSL API Key'
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function autoRenewEnabled(row: CertificateRecord) {
|
|
|
|
|
|
return row.acme_ca !== 'imported' && row.status !== 'error'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const columns: DataTableColumns<CertificateRecord> = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '证书名称',
|
|
|
|
|
|
key: 'domain',
|
|
|
|
|
|
minWidth: 130,
|
|
|
|
|
|
render: (row) =>
|
|
|
|
|
|
h('div', { class: 'cert-name-cell' }, [
|
|
|
|
|
|
h('div', { class: 'cert-name-cell__title' }, certDisplayName(row.domain)),
|
|
|
|
|
|
h('div', { class: 'cert-name-cell__sub' }, certSubtitle(row)),
|
|
|
|
|
|
]),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '关联域名',
|
|
|
|
|
|
key: 'domains',
|
|
|
|
|
|
minWidth: 200,
|
|
|
|
|
|
render: (row) => renderDomainTags(row),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '颁发机构',
|
|
|
|
|
|
key: 'acme_ca',
|
|
|
|
|
|
minWidth: 140,
|
|
|
|
|
|
render: (row) => renderIssuer(row),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '状态',
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
width: 108,
|
|
|
|
|
|
render: (row) => {
|
|
|
|
|
|
const life = certLifeStatus(row)
|
|
|
|
|
|
return h(
|
|
|
|
|
|
NTag,
|
|
|
|
|
|
{ size: 'small', round: true, bordered: false, type: certLifeTagType(life) },
|
|
|
|
|
|
() => certLifeLabel(life),
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '到期时间',
|
|
|
|
|
|
key: 'expires_at',
|
|
|
|
|
|
minWidth: 170,
|
|
|
|
|
|
render: (row) => {
|
|
|
|
|
|
if (!row.expires_at) return h('span', { class: 'text-muted' }, '-')
|
|
|
|
|
|
const daysText = row.days_left >= 0 ? `剩余 ${row.days_left} 天` : '已过期'
|
|
|
|
|
|
return h('div', { class: 'expire-cell' }, [
|
|
|
|
|
|
h('div', { class: 'expire-cell__date' }, formatDate(row.expires_at)),
|
|
|
|
|
|
h('div', { class: 'expire-cell__days' }, daysText),
|
|
|
|
|
|
])
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '自动续期',
|
|
|
|
|
|
key: 'auto_renew',
|
|
|
|
|
|
width: 88,
|
|
|
|
|
|
render: (row) =>
|
|
|
|
|
|
h(NSwitch, {
|
|
|
|
|
|
value: autoRenewEnabled(row),
|
|
|
|
|
|
size: 'small',
|
|
|
|
|
|
disabled: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '操作',
|
|
|
|
|
|
key: 'actions',
|
|
|
|
|
|
width: 168,
|
|
|
|
|
|
fixed: 'right',
|
|
|
|
|
|
render: (row) => {
|
|
|
|
|
|
const canDownload = row.status !== 'error' || !!row.expires_at
|
|
|
|
|
|
return renderTableRowActions([
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '续签',
|
|
|
|
|
|
show: row.acme_ca !== 'imported',
|
|
|
|
|
|
onClick: () => confirmRenew(row),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '下载',
|
|
|
|
|
|
show: canDownload,
|
|
|
|
|
|
onClick: () => downloadCert(row, 'zip'),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '删除',
|
|
|
|
|
|
type: 'error',
|
|
|
|
|
|
onClick: () => confirmDelete(row),
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadCert(row: CertificateRecord, part: 'zip' | 'cert' | 'key') {
|
|
|
|
|
|
downloadingDomain.value = row.domain
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.downloadCertificate(row.domain, part)
|
|
|
|
|
|
message.success(part === 'zip' ? '证书包已下载' : '文件已下载')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error instanceof Error ? error.message : '下载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
downloadingDomain.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmRenew(row: CertificateRecord) {
|
|
|
|
|
|
if (row.acme_ca === 'imported') {
|
|
|
|
|
|
message.warning('手动导入的证书请重新导入,无法续签')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const domains = row.domains?.length ? row.domains.join('、') : row.domain
|
|
|
|
|
|
dialog.warning({
|
|
|
|
|
|
title: '续签证书',
|
|
|
|
|
|
content: `确定续签 ${domains} 的证书?系统将自动选用可用的 DNS 凭证完成验证。`,
|
|
|
|
|
|
positiveText: '续签',
|
|
|
|
|
|
negativeText: '取消',
|
|
|
|
|
|
onPositiveClick: async () => {
|
|
|
|
|
|
renewingDomain.value = row.domain
|
|
|
|
|
|
try {
|
|
|
|
|
|
const record = await api.renewCertificate(row.domain, row.acme_ca || 'letsencrypt')
|
|
|
|
|
|
records.value = [record, ...records.value.filter((r) => r.id !== record.id)]
|
|
|
|
|
|
message.success('证书续签成功')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error instanceof Error ? error.message : '续签失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
renewingDomain.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmDelete(row: CertificateRecord) {
|
|
|
|
|
|
dialog.warning({
|
|
|
|
|
|
title: '删除证书',
|
|
|
|
|
|
content: `确定删除 ${row.domain} 的证书?相关文件将被移除,使用该证书的 HTTPS 代理可能失效。`,
|
|
|
|
|
|
positiveText: '删除',
|
|
|
|
|
|
negativeText: '取消',
|
|
|
|
|
|
onPositiveClick: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteCertificate(row.domain)
|
|
|
|
|
|
records.value = records.value.filter((r) => r.id !== row.id)
|
|
|
|
|
|
message.success('证书已删除')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error instanceof Error ? error.message : '删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseDomainsInput(text: string) {
|
|
|
|
|
|
return text
|
|
|
|
|
|
.split(/[\n,]+/)
|
|
|
|
|
|
.map((item) => item.trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncCertNameFromDomains() {
|
|
|
|
|
|
if (applyCertName.value.trim()) return
|
|
|
|
|
|
const first = parseDomainsInput(applyDomainsText.value)[0]
|
|
|
|
|
|
if (!first) return
|
|
|
|
|
|
applyCertName.value = first.replace(/^\*\./, '').split('.')[0] ?? ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectVerificationChannel(cfg: DDNSConfig) {
|
|
|
|
|
|
if (!cfg.enabled) return
|
|
|
|
|
|
applyDdnsConfigId.value = cfg.id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetApplyWizard() {
|
|
|
|
|
|
applyStep.value = 1
|
|
|
|
|
|
applyCertName.value = ''
|
|
|
|
|
|
applyDomainsText.value = ''
|
|
|
|
|
|
applyDdnsConfigId.value = null
|
|
|
|
|
|
applyAutoRenew.value = true
|
|
|
|
|
|
applyError.value = ''
|
|
|
|
|
|
resetApplyProgress()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goApplyStep1() {
|
|
|
|
|
|
applyStep.value = 1
|
|
|
|
|
|
applying.value = false
|
|
|
|
|
|
applyError.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goApplyStep2() {
|
|
|
|
|
|
applyError.value = ''
|
|
|
|
|
|
if (!applyCertName.value.trim()) {
|
|
|
|
|
|
applyError.value = '请填写证书名称'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const domains = parseDomainsInput(applyDomainsText.value)
|
|
|
|
|
|
if (domains.length === 0) {
|
|
|
|
|
|
applyError.value = '请填写至少一个域名'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const emailError = validateApplyEmail()
|
|
|
|
|
|
if (emailError) {
|
|
|
|
|
|
applyError.value = emailError
|
|
|
|
|
|
message.warning(emailError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const caError = validateApplyCA(domains)
|
|
|
|
|
|
if (caError) {
|
|
|
|
|
|
applyError.value = caError
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (applyDdnsConfigs.value.length === 1) {
|
|
|
|
|
|
applyDdnsConfigId.value = applyDdnsConfigs.value[0].id
|
|
|
|
|
|
} else {
|
|
|
|
|
|
applyDdnsConfigId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
applyStep.value = 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadCAOptions() {
|
|
|
|
|
|
const [options, settings, ddns] = await Promise.all([
|
|
|
|
|
|
api.listCertificateCAOptions(),
|
|
|
|
|
|
api.getSettings(),
|
|
|
|
|
|
api.listDDNSLite(),
|
|
|
|
|
|
])
|
|
|
|
|
|
caOptions.value = options
|
|
|
|
|
|
ddnsConfigs.value = ddns
|
|
|
|
|
|
applyEmail.value = settings.acme_email ?? ''
|
|
|
|
|
|
zerosslApiKey.value = settings.zerossl_api_key ?? ''
|
|
|
|
|
|
const defaultCA = settings.acme_ca || 'letsencrypt'
|
|
|
|
|
|
applyCA.value = applyCAOptions.some((o) => o.value === defaultCA) ? defaultCA : 'letsencrypt'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
records.value = asList(await api.listCertificates())
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
loadCAOptions().catch(() => {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openApply() {
|
|
|
|
|
|
resetApplyWizard()
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadCAOptions()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('加载配置失败')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
showApply.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopApplyStream() {
|
|
|
|
|
|
applyEventSource?.close()
|
|
|
|
|
|
applyEventSource = null
|
|
|
|
|
|
applyStreaming.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendApplyLog(level: string, text: string) {
|
|
|
|
|
|
applyLogLines.value.push({ level, text })
|
|
|
|
|
|
if (applyLogLines.value.length > 300) {
|
|
|
|
|
|
applyLogLines.value = applyLogLines.value.slice(-250)
|
|
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
applyLogBox.value?.scrollTo({ top: applyLogBox.value.scrollHeight })
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleApplyJobEvent(ev: CertificateJobEvent) {
|
|
|
|
|
|
if (ev.type === 'log' && ev.message) {
|
|
|
|
|
|
appendApplyLog(ev.level || 'info', ev.message)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ev.type === 'done' && ev.result) {
|
|
|
|
|
|
applyResult.value = ev.result
|
|
|
|
|
|
applyFinished.value = true
|
|
|
|
|
|
stopApplyStream()
|
|
|
|
|
|
if (ev.result.ok) {
|
|
|
|
|
|
message.success('证书申请成功')
|
|
|
|
|
|
api.listCertificates().then((list) => {
|
|
|
|
|
|
records.value = asList(list)
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error(ev.result.error || '证书申请失败')
|
|
|
|
|
|
api.listCertificates().then((list) => {
|
|
|
|
|
|
records.value = asList(list)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startApplyStream(jobId: string) {
|
|
|
|
|
|
stopApplyStream()
|
|
|
|
|
|
applyEventSource = new EventSource(api.certificateApplyStreamURL(jobId), { withCredentials: true })
|
|
|
|
|
|
const onEvent = (event: Event) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ev = JSON.parse((event as MessageEvent).data) as CertificateJobEvent
|
|
|
|
|
|
handleApplyJobEvent(ev)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore malformed events
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
applyEventSource.addEventListener('log', onEvent)
|
|
|
|
|
|
applyEventSource.addEventListener('done', onEvent)
|
|
|
|
|
|
applyStreaming.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetApplyProgress() {
|
|
|
|
|
|
stopApplyStream()
|
|
|
|
|
|
applyLogLines.value = []
|
|
|
|
|
|
applyResult.value = null
|
|
|
|
|
|
applyFinished.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitApply() {
|
|
|
|
|
|
applyError.value = ''
|
|
|
|
|
|
const cfg = applyDdnsConfigs.value.find((c) => c.id === applyDdnsConfigId.value)
|
|
|
|
|
|
if (!cfg) {
|
|
|
|
|
|
applyError.value = '请选择已启用的 DNS 任务用于 DNS-01 验证'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const domains = parseDomainsInput(applyDomainsText.value)
|
|
|
|
|
|
if (domains.length === 0) {
|
|
|
|
|
|
applyError.value = '请填写至少一个域名'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const emailError = validateApplyEmail()
|
|
|
|
|
|
if (emailError) {
|
|
|
|
|
|
applyError.value = emailError
|
|
|
|
|
|
message.warning(emailError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const caError = validateApplyCA(domains)
|
|
|
|
|
|
if (caError) {
|
|
|
|
|
|
applyError.value = caError
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
applying.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { job_id } = await api.applyCertificate({
|
|
|
|
|
|
ddns_config_id: Number(cfg.id),
|
|
|
|
|
|
domains,
|
|
|
|
|
|
ca: applyCA.value,
|
|
|
|
|
|
email: applyEmail.value.trim(),
|
|
|
|
|
|
})
|
|
|
|
|
|
applyStep.value = 3
|
|
|
|
|
|
resetApplyProgress()
|
|
|
|
|
|
startApplyStream(job_id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const text = error instanceof Error ? error.message : '申请失败'
|
|
|
|
|
|
applyStep.value = 3
|
|
|
|
|
|
resetApplyProgress()
|
|
|
|
|
|
applyFinished.value = true
|
|
|
|
|
|
applyResult.value = {
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
error: text,
|
|
|
|
|
|
domain: domains[0],
|
|
|
|
|
|
}
|
|
|
|
|
|
message.error(text)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
applying.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openImport() {
|
|
|
|
|
|
importMode.value = 'paste'
|
|
|
|
|
|
importForm.value = {
|
|
|
|
|
|
certificate: '',
|
|
|
|
|
|
private_key: '',
|
|
|
|
|
|
cert_path: '',
|
|
|
|
|
|
key_path: '',
|
|
|
|
|
|
}
|
|
|
|
|
|
showImport.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onCertFile(event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const file = input.files?.[0]
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
importForm.value.certificate = await file.text()
|
|
|
|
|
|
input.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onKeyFile(event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const file = input.files?.[0]
|
|
|
|
|
|
if (!file) return
|
|
|
|
|
|
importForm.value.private_key = await file.text()
|
|
|
|
|
|
input.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitImport() {
|
|
|
|
|
|
if (importMode.value === 'paste') {
|
|
|
|
|
|
if (!importForm.value.certificate.trim() || !importForm.value.private_key.trim()) {
|
|
|
|
|
|
message.warning('请填写证书和私钥内容')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (!importForm.value.cert_path.trim() || !importForm.value.key_path.trim()) {
|
|
|
|
|
|
message.warning('请填写证书和私钥文件路径')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
importing.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload =
|
|
|
|
|
|
importMode.value === 'path'
|
|
|
|
|
|
? {
|
|
|
|
|
|
cert_path: importForm.value.cert_path,
|
|
|
|
|
|
key_path: importForm.value.key_path,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
|
|
|
|
|
certificate: importForm.value.certificate,
|
|
|
|
|
|
private_key: importForm.value.private_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
const record = await api.importCertificate(payload)
|
|
|
|
|
|
records.value = [record, ...records.value.filter((r) => r.id !== record.id)]
|
|
|
|
|
|
showImport.value = false
|
|
|
|
|
|
const domains = record.domains?.length ? record.domains.join(', ') : record.domain
|
|
|
|
|
|
message.success(`证书导入成功:${domains}`)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error instanceof Error ? error.message : '导入失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
importing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(load)
|
|
|
|
|
|
onUnmounted(stopApplyStream)
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.apply-log-box {
|
|
|
|
|
|
background: #0f172a;
|
|
|
|
|
|
color: #e2e8f0;
|
|
|
|
|
|
min-height: 220px;
|
|
|
|
|
|
max-height: 320px;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
padding: var(--fonu-space-4);
|
|
|
|
|
|
border-radius: var(--fonu-radius);
|
|
|
|
|
|
border: 1px solid var(--fonu-border);
|
|
|
|
|
|
font-family: var(--fonu-mono);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html[data-theme='dark'] .apply-log-box,
|
|
|
|
|
|
html.dark .apply-log-box {
|
|
|
|
|
|
background: #020617;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-log-line--warn {
|
|
|
|
|
|
color: #fbbf24;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-log-line--error {
|
|
|
|
|
|
color: #f87171;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-log-empty {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
padding: var(--fonu-space-5) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-result {
|
|
|
|
|
|
margin-top: var(--fonu-space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-result-detail {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-result-detail code {
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-result-k {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
min-width: 72px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card {
|
|
|
|
|
|
background: var(--fonu-surface);
|
|
|
|
|
|
border: 1px solid var(--fonu-border);
|
|
|
|
|
|
border-radius: var(--fonu-radius);
|
|
|
|
|
|
box-shadow: var(--fonu-shadow);
|
|
|
|
|
|
padding: var(--fonu-space-4) var(--fonu-space-5);
|
|
|
|
|
|
min-height: 118px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
|
|
|
|
|
|
.stat-card__icon--blue { color: #3b82f6; }
|
|
|
|
|
|
.stat-card__icon--green { color: #10b981; }
|
|
|
|
|
|
.stat-card__icon--amber { color: #f59e0b; }
|
|
|
|
|
|
.stat-card__icon--red { color: #ef4444; }
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card__value {
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
font-size: 30px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
line-height: 1.1;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
letter-spacing: -0.03em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card__value--green { color: #10b981; }
|
|
|
|
|
|
.stat-card__value--amber { color: #f59e0b; }
|
|
|
|
|
|
.stat-card__value--red { color: #ef4444; }
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card__label {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-card__sub {
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-panel {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-panel :deep(.fonu-card__body) {
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
padding-right: 0;
|
|
|
|
|
|
padding-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-toolbar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--fonu-space-3);
|
|
|
|
|
|
padding: var(--fonu-space-4);
|
|
|
|
|
|
border-bottom: 1px solid var(--fonu-border);
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-toolbar__search {
|
|
|
|
|
|
width: 240px;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-toolbar__filter {
|
|
|
|
|
|
width: 132px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-toolbar__spacer {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-loading {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: var(--fonu-space-6) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-empty-actions {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table-wrap {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table { width: 100%; }
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.n-data-table-td) {
|
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.cert-name-cell__title) {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.cert-name-cell__sub) {
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-pagination {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
padding: var(--fonu-space-3) var(--fonu-space-4) var(--fonu-space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-name-cell__title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-name-cell__sub {
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-cell) {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-badge) {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 24px;
|
|
|
|
|
|
height: 24px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-badge--le) { background: rgba(16, 185, 129, 0.15); color: #059669; }
|
|
|
|
|
|
.cert-table :deep(.issuer-badge--zero) { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
|
|
|
|
|
|
.cert-table :deep(.issuer-badge--buypass) { background: rgba(37, 99, 235, 0.15); color: #1d4ed8; }
|
|
|
|
|
|
.cert-table :deep(.issuer-badge--imported) { background: rgba(100, 116, 139, 0.15); color: #64748b; }
|
|
|
|
|
|
.cert-table :deep(.issuer-badge--default) { background: var(--fonu-bg); color: var(--fonu-text-secondary); }
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-logo) {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-logo img) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
max-width: 28px;
|
|
|
|
|
|
max-height: 28px;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-table :deep(.issuer-cell__label) {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expire-cell__date {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expire-cell__days {
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.text-muted { color: var(--fonu-text-muted); }
|
|
|
|
|
|
|
|
|
|
|
|
.tip-box {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-top: var(--fonu-space-4);
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(59, 130, 246, 0.08);
|
|
|
|
|
|
border: 1px solid rgba(59, 130, 246, 0.16);
|
|
|
|
|
|
color: #1d4ed8;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tip-box__icon {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-header__title {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-header__desc {
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-form :deep(.n-form-item) {
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-form :deep(.n-form-item:last-child) {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-form :deep(.n-form-item-label) {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
padding-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cert-form :deep(.n-form-item-feedback-wrapper) {
|
|
|
|
|
|
min-height: auto;
|
|
|
|
|
|
padding-top: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-field {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--fonu-space-3);
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-buttons {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-buttons :deep(.n-radio-button) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.domains-input :deep(textarea),
|
|
|
|
|
|
.pem-input :deep(textarea) {
|
|
|
|
|
|
font-family: var(--fonu-mono);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-email-alert {
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-alert {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-field {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-hint {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-hint--warn {
|
|
|
|
|
|
color: #d97706;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-steps {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0;
|
|
|
|
|
|
margin-bottom: var(--fonu-space-5);
|
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step--active {
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step--done {
|
|
|
|
|
|
color: #10b981;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step__dot {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 24px;
|
|
|
|
|
|
height: 24px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: 2px solid currentColor;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step--active .apply-step__dot,
|
|
|
|
|
|
.apply-step--done .apply-step__dot {
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
border-color: #10b981;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step__line {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 2px;
|
|
|
|
|
|
margin: 0 12px;
|
|
|
|
|
|
background: var(--fonu-border);
|
|
|
|
|
|
min-width: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-step__line--done {
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-cards {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
padding: 10px 10px 12px;
|
|
|
|
|
|
border: 1px solid var(--fonu-border);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: var(--fonu-surface);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card:hover {
|
|
|
|
|
|
border-color: rgba(16, 185, 129, 0.45);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card--active {
|
|
|
|
|
|
border-color: #10b981;
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.18);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card__check {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 8px;
|
|
|
|
|
|
right: 8px;
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
height: 20px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: 2px solid #d1d5db;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card--active .ca-card__check {
|
|
|
|
|
|
border-color: #10b981;
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card--active .ca-card__check::after {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 5px;
|
|
|
|
|
|
top: 2px;
|
|
|
|
|
|
width: 5px;
|
|
|
|
|
|
height: 9px;
|
|
|
|
|
|
border: solid #fff;
|
|
|
|
|
|
border-width: 0 2px 2px 0;
|
|
|
|
|
|
transform: rotate(45deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card__logo {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
aspect-ratio: 1;
|
|
|
|
|
|
max-height: 108px;
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card__logo img {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card__fallback {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ca-card__sub {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-auto-renew {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
border: 1px solid var(--fonu-border);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: var(--fonu-bg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-auto-renew__title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-auto-renew__sub {
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-step__intro {
|
|
|
|
|
|
margin: 0 0 var(--fonu-space-4);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-empty {
|
|
|
|
|
|
padding: var(--fonu-space-5);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
border: 1px dashed var(--fonu-border);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-empty__sub {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-options {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--fonu-space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
border: 1px solid var(--fonu-border);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: var(--fonu-surface);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
transition: border-color 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option:hover {
|
|
|
|
|
|
border-color: rgba(16, 185, 129, 0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option--active {
|
|
|
|
|
|
border-color: #10b981;
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.provider-logo {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 40px;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: var(--fonu-bg);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.provider-logo img {
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option__body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option__title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option__sub {
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option__zones {
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option__check {
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
height: 20px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: 2px solid var(--fonu-border);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.verify-option--active .verify-option__check {
|
|
|
|
|
|
border-color: #10b981;
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
box-shadow: inset 0 0 0 4px #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.apply-done-step__hint {
|
|
|
|
|
|
margin: 0 0 var(--fonu-space-3);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-btn {
|
|
|
|
|
|
margin-top: var(--fonu-space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.domain-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: var(--fonu-space-2);
|
|
|
|
|
|
min-height: 28px;
|
|
|
|
|
|
padding: var(--fonu-space-3);
|
|
|
|
|
|
border: 1px dashed var(--fonu-border);
|
|
|
|
|
|
border-radius: var(--fonu-radius-sm);
|
|
|
|
|
|
background: var(--fonu-bg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.domain-preview__empty {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-input {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.table-tags) {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1199px) {
|
|
|
|
|
|
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
|
.stats-row { grid-template-columns: 1fr; }
|
|
|
|
|
|
.cert-toolbar__filter { width: 100%; }
|
|
|
|
|
|
.ca-cards { grid-template-columns: 1fr; }
|
|
|
|
|
|
.cert-table :deep(.n-data-table-base-table) {
|
|
|
|
|
|
min-width: 900px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|