Files
-Fonu/web/src/views/CertificatesView.vue
T
mrlee 278f8267b7 chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
2026-09-23 09:40:59 +08:00

1876 lines
50 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.
<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&#10;*.example.com&#10;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>
<p v-if="applyCA === 'buypass'" class="ca-hint">
Buypass 不支持通配符,单张证书最多 5 个域名
</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>