1109 lines
37 KiB
Vue
1109 lines
37 KiB
Vue
<template>
|
||
<div class="dev-page">
|
||
<!-- 工具栏 -->
|
||
<div class="page-toolbar">
|
||
<div class="toolbar-left">
|
||
<h3 class="page-title">SSL 证书管理</h3>
|
||
<a-tag color="blue">{{ list.length }} 个</a-tag>
|
||
<a-select v-model:value="selectedAppId" placeholder="全部应用" allow-clear style="width: 180px" @change="loadList">
|
||
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">{{ app.productName }}</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<a-select v-model:value="filterCertType" placeholder="证书类型" style="width: 120px" allow-clear @change="loadList">
|
||
<a-select-option value="DV">DV 型</a-select-option>
|
||
<a-select-option value="OV">OV 型</a-select-option>
|
||
<a-select-option value="EV">EV 型</a-select-option>
|
||
</a-select>
|
||
<a-select v-model:value="filterStatus" placeholder="证书状态" style="width: 120px" allow-clear @change="loadList">
|
||
<a-select-option value="valid">有效</a-select-option>
|
||
<a-select-option value="expiring">即将到期</a-select-option>
|
||
<a-select-option value="expired">已过期</a-select-option>
|
||
</a-select>
|
||
<a-input-search v-model:value="searchText" placeholder="搜索证书 / 域名" style="width: 200px" allow-clear @search="loadList" />
|
||
<PermissionGuard :app-id="selectedAppId" permission="canEditResource" :show-tip="true">
|
||
<a-button type="primary" @click="showAdd = true">
|
||
<template #icon><PlusOutlined /></template>添加证书
|
||
</a-button>
|
||
</PermissionGuard>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 通知栏 -->
|
||
<div class="notice-bar">
|
||
<SafetyCertificateOutlined class="notice-icon" />
|
||
<span>在此管理 SSL/TLS 证书,支持保护多个域名,提供自动到期提醒(<strong>私钥会使用 AES 加密存储</strong>)。</span>
|
||
</div>
|
||
|
||
<!-- SSL 证书卡片列表 -->
|
||
<div v-if="loading" class="card-loading"><a-spin size="large" /></div>
|
||
<div v-else-if="list.length === 0" class="card-empty">
|
||
<a-empty description="暂无 SSL 证书,点击添加按钮上传证书">
|
||
<a-button type="primary" @click="showAdd = true"><template #icon><PlusOutlined /></template>添加证书</a-button>
|
||
</a-empty>
|
||
</div>
|
||
<div v-else class="ssl-grid">
|
||
<div v-for="item in list" :key="item.resourceId" class="ssl-card" :class="{ 'card-expiring': isNearExpiry(item.expireAt) }">
|
||
<!-- 卡片头部 -->
|
||
<div class="card-header">
|
||
<div class="card-header-left">
|
||
<div class="ssl-name" @click="copyText(item.name!)">
|
||
<SafetyCertificateOutlined />
|
||
<span class="ssl-text">{{ item.name }}</span>
|
||
<CopyOutlined class="copy-icon" />
|
||
</div>
|
||
<div class="ssl-tags">
|
||
<a-tag :color="certTypeColor[item.certType] || 'default'" size="small">{{ item.certType || '-' }}</a-tag>
|
||
<a-tag :color="getStatusColor(item.status)" size="small">{{ certStatusLabel[item.status] || item.status }}</a-tag>
|
||
<a-tag v-if="isNearExpiry(item.expireAt)" color="orange" size="small">即将到期</a-tag>
|
||
<a-tag v-if="isExpired(item.expireAt)" color="red" size="small">已过期</a-tag>
|
||
</div>
|
||
</div>
|
||
<a-dropdown :trigger="['click']">
|
||
<a-button type="text" size="small"><template #icon><EllipsisOutlined /></template></a-button>
|
||
<template #overlay>
|
||
<a-menu @click="({ key }: any) => handleMenuAction(key, item)">
|
||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="edit"><template #icon><EditOutlined /></template>编辑</a-menu-item>
|
||
<a-menu-item key="viewDetails"><template #icon><EyeOutlined /></template>查看证书内容</a-menu-item>
|
||
<a-menu-item key="copyCert"><template #icon><CopyOutlined /></template>复制证书内容</a-menu-item>
|
||
<a-menu-divider v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" />
|
||
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="delete" danger><template #icon><DeleteOutlined /></template>移除</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</div>
|
||
|
||
<!-- 卡片信息区 -->
|
||
<div class="card-info">
|
||
<div class="info-row"><span class="info-label">绑定域名</span><span class="info-value domain-value" @click="copyText(item.domain!)">{{ item.domain || '-' }}<CopyOutlined class="copy-icon-small" /></span></div>
|
||
<div class="info-row"><span class="info-label">颁发机构</span><span class="info-value">{{ item.issuer || '-' }}</span></div>
|
||
<div class="info-row"><span class="info-label">关联应用</span><span class="info-value">{{ item.appName || '未关联' }}</span></div>
|
||
<div class="info-row">
|
||
<span class="info-label">到期时间</span>
|
||
<span class="info-value" :class="{ 'text-warning': isNearExpiry(item.expireAt), 'text-danger': isExpired(item.expireAt) }">{{ item.expireAt || '-' }}</span>
|
||
</div>
|
||
<!-- 安全状态 -->
|
||
<div class="info-row">
|
||
<span class="info-label">安全状态</span>
|
||
<span class="info-value">
|
||
<template v-if="recordContainsPrivateKey(item)"><SafetyOutlined style="color: #52c41a; margin-right: 4px;" />私钥已保存</template>
|
||
<template v-else-if="item.certificate"><SafetyOutlined style="color: #1890ff; margin-right: 4px;" />证书已保存</template>
|
||
<span v-else class="text-muted">未配置</span>
|
||
</span>
|
||
</div>
|
||
<!-- 协作者标识 -->
|
||
<div v-if="!item.isOwner && !getAppPermission(item.appId)?.canEditResource" class="info-row">
|
||
<span class="info-label"></span>
|
||
<span class="info-value" style="font-size: 12px; color: #faad14;">
|
||
<LockOutlined /> {{ (item.accessLevel ?? 1) >= 2 ? '协作者(开发者)' : '协作者(只读)' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷操作区 -->
|
||
<div class="card-actions">
|
||
<a-button v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" size="small" @click="handleEdit(item)"><template #icon><EditOutlined /></template>编辑</a-button>
|
||
<a-button size="small" @click="handleViewDetails(item)"><template #icon><EyeOutlined /></template>查看</a-button>
|
||
<a-button v-if="item.certificate" size="small" @click="handleCopyCert(item)"><template #icon><CopyOutlined /></template>复制证书</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div v-if="list.length > 0" class="card-pagination">
|
||
<a-pagination
|
||
v-model:current="pagination.current"
|
||
v-model:pageSize="pagination.pageSize"
|
||
:total="pagination.total"
|
||
show-size-changer
|
||
size="small"
|
||
@change="loadList"
|
||
/>
|
||
</div>
|
||
|
||
<a-modal
|
||
v-model:open="showAdd"
|
||
:title="editRecord ? '编辑证书' : '添加证书'"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
:confirm-loading="saveLoading"
|
||
width="560px"
|
||
@ok="handleSave"
|
||
@cancel="resetForm"
|
||
>
|
||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
|
||
<a-form-item label="证书名称" name="name">
|
||
<a-input v-model:value="form.name" placeholder="如:example.com-ssl" />
|
||
</a-form-item>
|
||
<a-form-item label="绑定域名" name="domain">
|
||
<a-input v-model:value="form.domain" placeholder="如:example.com 或 *.example.com(通配符)" />
|
||
</a-form-item>
|
||
<a-form-item label="证书类型" name="certType">
|
||
<a-select v-model:value="form.certType" placeholder="请选择">
|
||
<a-select-option value="DV">DV(域名验证型)</a-select-option>
|
||
<a-select-option value="OV">OV(组织验证型)</a-select-option>
|
||
<a-select-option value="EV">EV(扩展验证型)</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="颁发机构" name="issuer">
|
||
<a-input v-model:value="form.issuer" placeholder="如:腾讯云 TrustAsia、Let's Encrypt" />
|
||
</a-form-item>
|
||
<a-form-item label="关联应用" name="appId">
|
||
<a-select v-model:value="form.appId" placeholder="可选,关联到应用" allow-clear>
|
||
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">
|
||
{{ app.productName }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="到期时间" name="expireAt">
|
||
<a-date-picker v-model:value="form.expireAt" value-format="YYYY-MM-DD" style="width: 100%" placeholder="选择证书到期日期" />
|
||
</a-form-item>
|
||
<a-form-item label="证书文件" name="certificate">
|
||
<a-textarea v-model:value="form.certificate" :rows="4" placeholder="粘贴完整的证书内容(包含BEGIN/END标记)" :maxlength="8000" />
|
||
<div style="margin-top: 4px; font-size: 12px; color: #999">支持 PEM 格式,建议直接复制证书文件内容</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||
<a-button size="small" type="link" @click="insertSampleCertificate" style="padding: 0; font-size: 12px;">
|
||
<template #icon><CopyOutlined /></template>插入示例格式
|
||
</a-button>
|
||
<a-button size="small" type="link" @click="formatCertificate" style="padding: 0; font-size: 12px;">
|
||
<template #icon><FormOutlined /></template>格式化证书
|
||
</a-button>
|
||
<a-button size="small" type="link" @click="validateCertificateInput" style="padding: 0; font-size: 12px;">
|
||
<template #icon><CheckCircleOutlined /></template>检查格式
|
||
</a-button>
|
||
</div>
|
||
<div v-if="certificateInfo" style="margin-top: 8px; padding: 8px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 4px;">
|
||
<div style="color: #52c41a; font-weight: 500; margin-bottom: 4px;">证书信息预览</div>
|
||
<div style="font-size: 12px; color: #666;">
|
||
<div style="margin-bottom: 2px;">域名: {{ certificateInfo.subject }}</div>
|
||
<div style="margin-bottom: 2px;">颁发机构: {{ certificateInfo.issuer }}</div>
|
||
<div style="margin-bottom: 2px;">有效期: {{ certificateInfo.validity }}</div>
|
||
<div v-if="certificateInfo.hasDetectedExpiry && form.expireAt" style="margin-top: 8px; padding: 4px; background: #fffbe6; border: 1px dashed #faad14; border-radius: 2px;">
|
||
<div style="color: #faad14; display: flex; align-items: center; gap: 4px;">
|
||
<CheckCircleOutlined style="font-size: 12px;" />
|
||
<span>已自动识别到期时间: {{ form.expireAt }},保存时将入库</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="私钥(敏感信息)" name="privateKey" :extra="privateKeyExtra">
|
||
<a-textarea v-model:value="form.privateKey" :rows="4" placeholder="必填:粘贴完整的私钥内容(包含BEGIN/END标记,AES加密存储)" :maxlength="8000" />
|
||
<div style="margin-top: 4px; font-size: 12px; color: #999">支持 PEM、PKCS#8 格式,私钥内容会自动加密存储</div>
|
||
<div style="margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||
<a-button size="small" type="link" @click="insertSamplePrivateKey" style="padding: 0; font-size: 12px;">
|
||
<template #icon><CopyOutlined /></template>插入示例格式
|
||
</a-button>
|
||
<a-button size="small" type="link" @click="formatPrivateKey" style="padding: 0; font-size: 12px;">
|
||
<template #icon><FormOutlined /></template>格式化私钥
|
||
</a-button>
|
||
<a-button size="small" type="link" @click="validatePrivateKeyInput" style="padding: 0; font-size: 12px;">
|
||
<template #icon><CheckCircleOutlined /></template>检查格式
|
||
</a-button>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="备注" name="remark">
|
||
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { PlusOutlined, InfoCircleOutlined, SafetyCertificateOutlined, GlobalOutlined, CopyOutlined, EllipsisOutlined, EditOutlined, DeleteOutlined, EyeOutlined, SafetyOutlined, LockOutlined, FormOutlined, CheckCircleOutlined } from '@ant-design/icons-vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import dayjs from 'dayjs'
|
||
import { pageAppResource, addAppResource, updateAppResource, removeAppResource } from '@/api/app/appResource'
|
||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||
import { useAppPermission } from '@/composables/useAppPermission'
|
||
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
|
||
import type { AppResource } from '@/api/app/appResource/model'
|
||
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
|
||
|
||
definePageMeta({ layout: 'developer' })
|
||
useHead({ title: 'SSL 证书管理 - 开发者中心' })
|
||
|
||
const loading = ref(false)
|
||
const saveLoading = ref(false)
|
||
const showAdd = ref(false)
|
||
const searchText = ref('')
|
||
const editRecord = ref<any>(null)
|
||
const formRef = ref()
|
||
const selectedAppId = ref<number | undefined>(undefined)
|
||
const { getAppPermission } = useAppPermission()
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
domain: '',
|
||
certType: undefined as string | undefined,
|
||
issuer: 'Let\'s Encrypt', // 默认颁发机构
|
||
certificate: '',
|
||
privateKey: '',
|
||
appId: undefined as number | undefined,
|
||
expireAt: null as any,
|
||
remark: '',
|
||
})
|
||
|
||
const hasCertificateContent = computed(() => form.certificate && form.certificate.trim().length > 0)
|
||
const hasPrivateKeyContent = computed(() => form.privateKey && form.privateKey.trim().length > 0)
|
||
const isValidCertificateFormat = computed(() => {
|
||
if (!hasCertificateContent.value) return false
|
||
const cert = form.certificate.trim()
|
||
return cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----')
|
||
})
|
||
const isValidPrivateKeyFormat = computed(() => {
|
||
if (!hasPrivateKeyContent.value) return false
|
||
const key = form.privateKey.trim()
|
||
return key.includes('-----BEGIN') && (key.includes('PRIVATE KEY-----') || key.includes('RSA PRIVATE KEY-----') || key.includes('ENCRYPTED PRIVATE KEY-----'))
|
||
})
|
||
|
||
const privateKeyExtra = computed(() => {
|
||
if (editRecord.value) {
|
||
if (form.privateKey === '[私钥已保存]') {
|
||
return '显示的"[私钥已保存]"为占位符,实际私钥会保留;如需修改私钥,请完整输入新的私钥内容'
|
||
}
|
||
return '如需修改私钥,请完整输入新的私钥内容;如需保留原私钥,请勿更改此字段'
|
||
}
|
||
return '请粘贴完整的私钥内容(包含BEGIN/END标记),此字段为必填,新增证书必须提供私钥'
|
||
})
|
||
|
||
const filterCertType = ref<string>()
|
||
const filterStatus = ref<string>()
|
||
|
||
// 证书信息解析
|
||
const certificateInfo = computed(() => {
|
||
if (!form.certificate || !form.certificate.trim()) {
|
||
return { subject: '-', issuer: '-', validity: '-' }
|
||
}
|
||
|
||
try {
|
||
const certContent = form.certificate.trim()
|
||
|
||
// 尝试解析证书内容
|
||
let subject = '-'
|
||
let issuer = '-'
|
||
let validity = '-'
|
||
let detectedExpiry = null
|
||
|
||
// 去除多余的空行和空格
|
||
const cleanContent = certContent.replace(/\r\n/g, '\n').replace(/\s+/g, ' ')
|
||
|
||
// 尝试多种格式提取主题(Subject)
|
||
let subjectMatch = cleanContent.match(/Subject:(.*?)(?:\n|$)/)
|
||
if (!subjectMatch) {
|
||
// 尝试其他格式
|
||
subjectMatch = cleanContent.match(/CN=(.*?)(?:\s|,|$)/)
|
||
}
|
||
if (subjectMatch) {
|
||
subject = subjectMatch[1].trim()
|
||
}
|
||
|
||
// 尝试多种格式提取颁发者(Issuer)
|
||
let issuerMatch = cleanContent.match(/Issuer:(.*?)(?:\n|$)/)
|
||
if (!issuerMatch) {
|
||
issuerMatch = cleanContent.match(/O=(.*?)(?:\s|,|$)/)
|
||
}
|
||
if (issuerMatch) {
|
||
issuer = issuerMatch[1].trim()
|
||
}
|
||
|
||
// 尝试多种格式提取有效期(Not Before / Not After)
|
||
const notBeforePatterns = [
|
||
/Not Before\s*:\s*(.*?)(?:\n|$)/i,
|
||
/NotBefore\s*:\s*(.*?)(?:\n|$)/i,
|
||
/Validity\s*Not Before\s*:\s*(.*?)(?:\n|$)/i,
|
||
]
|
||
|
||
const notAfterPatterns = [
|
||
/Not After\s*:\s*(.*?)(?:\n|$)/i,
|
||
/NotAfter\s*:\s*(.*?)(?:\n|$)/i,
|
||
/Validity\s*Not After\s*:\s*(.*?)(?:\n|$)/i,
|
||
]
|
||
|
||
let notBefore = null
|
||
let notAfter = null
|
||
|
||
for (const pattern of notBeforePatterns) {
|
||
const match = cleanContent.match(pattern)
|
||
if (match) {
|
||
notBefore = match[1].trim()
|
||
break
|
||
}
|
||
}
|
||
|
||
for (const pattern of notAfterPatterns) {
|
||
const match = cleanContent.match(pattern)
|
||
if (match) {
|
||
notAfter = match[1].trim()
|
||
break
|
||
}
|
||
}
|
||
|
||
if (notBefore && notAfter) {
|
||
validity = `${notBefore} 至 ${notAfter}`
|
||
|
||
// 解析到期时间,自动设置到表单的 expireAt 字段
|
||
if (notAfter) {
|
||
// 尝试多种日期格式解析
|
||
const dateFormats = [
|
||
// RFC 格式: Apr 6 05:26:00 2026 GMT
|
||
{ pattern: /([A-Za-z]+)\s+(\d+)\s+(\d+):(\d+):(\d+)\s+(\d+)\s+GMT/,
|
||
handler: (match: RegExpMatchArray) => {
|
||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||
const month = months.indexOf(match[1]) + 1
|
||
const day = parseInt(match[2])
|
||
const year = parseInt(match[6])
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
},
|
||
// ISO 格式: 2026-04-06T05:26:00Z
|
||
{ pattern: /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z/,
|
||
handler: (match: RegExpMatchArray) => {
|
||
const year = parseInt(match[1])
|
||
const month = parseInt(match[2])
|
||
const day = parseInt(match[3])
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
},
|
||
// 简单日期格式: 2026-04-06
|
||
{ pattern: /(\d{4})-(\d{2})-(\d{2})/,
|
||
handler: (match: RegExpMatchArray) => {
|
||
const year = parseInt(match[1])
|
||
const month = parseInt(match[2])
|
||
const day = parseInt(match[3])
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
},
|
||
]
|
||
|
||
let expiryDate = null
|
||
for (const format of dateFormats) {
|
||
const match = notAfter.match(format.pattern)
|
||
if (match) {
|
||
try {
|
||
expiryDate = format.handler(match)
|
||
if (expiryDate && !isNaN(expiryDate.getTime())) {
|
||
break
|
||
}
|
||
} catch (e) {
|
||
// 继续尝试下一个格式
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
if (expiryDate && !isNaN(expiryDate.getTime())) {
|
||
// 格式化为 YYYY-MM-DD 格式
|
||
const year = expiryDate.getFullYear()
|
||
const month = String(expiryDate.getMonth() + 1).padStart(2, '0')
|
||
const day = String(expiryDate.getDate()).padStart(2, '0')
|
||
const expiryStr = `${year}-${month}-${day}`
|
||
|
||
// 只在确实识别到新时间时才更新,避免覆盖用户手动输入
|
||
if (!form.expireAt || form.expireAt !== expiryStr) {
|
||
form.expireAt = expiryStr
|
||
detectedExpiry = expiryStr
|
||
|
||
// 如果表单正在编辑,提示用户
|
||
if (editRecord.value) {
|
||
console.log('检测到证书到期时间:', expiryStr, '已自动更新到期时间字段')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
subject,
|
||
issuer,
|
||
validity,
|
||
hasDetectedExpiry: !!detectedExpiry
|
||
}
|
||
} catch (error) {
|
||
console.warn('证书解析失败:', error)
|
||
return { subject: '-', issuer: '-', validity: '-', hasDetectedExpiry: false }
|
||
}
|
||
})
|
||
|
||
const validatePrivateKey = (_: any, value: string) => {
|
||
// 新增模式:必须提供私钥内容
|
||
if (!editRecord.value) {
|
||
if (!value || !value.trim()) {
|
||
return Promise.reject(new Error('新增证书必须提供私钥内容'))
|
||
}
|
||
// 检查格式是否基本正确(包含BEGIN标记)
|
||
const trimmed = value.trim()
|
||
if (!trimmed.includes('-----BEGIN')) {
|
||
return Promise.reject(new Error('私钥格式不正确,应包含BEGIN标记'))
|
||
}
|
||
return Promise.resolve()
|
||
}
|
||
|
||
// 编辑模式:允许显示"[私钥已保存]"或新输入的私钥
|
||
if (!value || !value.trim() || value === '[私钥已保存]') {
|
||
// 空值或占位符表示保留原私钥
|
||
return Promise.resolve()
|
||
}
|
||
|
||
// 如果用户输入了新内容,检查格式
|
||
const trimmed = value.trim()
|
||
if (!trimmed.includes('-----BEGIN')) {
|
||
return Promise.reject(new Error('私钥格式不正确,应包含BEGIN标记'))
|
||
}
|
||
|
||
return Promise.resolve()
|
||
}
|
||
|
||
const rules = {
|
||
name: [{ required: true, message: '请输入证书名称' }],
|
||
domain: [{ required: true, message: '请输入绑定域名' }],
|
||
certType: [{ required: true, message: '请选择证书类型' }],
|
||
certificate: [{ required: true, message: '请输入证书内容' }],
|
||
privateKey: [{ validator: validatePrivateKey }],
|
||
}
|
||
|
||
const certStatusLabel: Record<string, string> = {
|
||
valid: '有效',
|
||
expired: '已过期',
|
||
expiring: '即将过期',
|
||
running: '有效',
|
||
stopped: '已停用',
|
||
pending: '配置中',
|
||
}
|
||
|
||
const certTypeColor: Record<string, string> = {
|
||
DV: 'blue',
|
||
OV: 'purple',
|
||
EV: 'gold',
|
||
}
|
||
|
||
|
||
|
||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||
const list = ref<AppResource[]>([])
|
||
const appOptions = ref<any[]>([])
|
||
|
||
function isNearExpiry(dateStr: string): boolean {
|
||
if (!dateStr || dateStr === '-') return false
|
||
return dayjs(dateStr).diff(dayjs(), 'day') <= 30
|
||
}
|
||
|
||
function isExpired(dateStr: string): boolean {
|
||
if (!dateStr || dateStr === '-') return false
|
||
return dayjs(dateStr).isBefore(dayjs(), 'day')
|
||
}
|
||
|
||
function getStatusColor(status: string | undefined): string {
|
||
switch (status) {
|
||
case 'valid': return 'green'
|
||
case 'expiring': return 'orange'
|
||
case 'expired': return 'red'
|
||
default: return 'default'
|
||
}
|
||
}
|
||
|
||
function handleMenuAction(key: string, record: AppResource) {
|
||
switch (key) {
|
||
case 'edit':
|
||
handleEdit(record)
|
||
break
|
||
case 'viewDetails':
|
||
handleViewDetails(record)
|
||
break
|
||
case 'copyCert':
|
||
handleCopyCert(record)
|
||
break
|
||
case 'delete':
|
||
handleDelete(record.resourceId!)
|
||
break
|
||
}
|
||
}
|
||
|
||
function recordContainsPrivateKey(record: AppResource): boolean {
|
||
// 检查记录是否包含私钥(通过后端字段或内容判断)
|
||
return !!record.privateKey && record.privateKey.trim().length > 0 && record.privateKey.includes('-----BEGIN')
|
||
}
|
||
|
||
function copyText(text: string) {
|
||
navigator.clipboard.writeText(text)
|
||
.then(() => message.success('已复制到剪贴板'))
|
||
.catch(() => message.error('复制失败'))
|
||
}
|
||
|
||
function handleViewDetails(record: AppResource) {
|
||
// 查看证书详细信息
|
||
const certContent = record.certificate || '无证书内容'
|
||
const modal = Modal.info({
|
||
title: `证书详情 - ${record.name}`,
|
||
width: 800,
|
||
content: (
|
||
`<div style="max-height: 400px; overflow: auto; font-family: monospace;">
|
||
<pre style="margin: 0; font-size: 12px;">${certContent}</pre>
|
||
</div>`
|
||
),
|
||
okText: '关闭',
|
||
onOk() {
|
||
modal.destroy()
|
||
},
|
||
})
|
||
}
|
||
|
||
function handleCopyCert(record: AppResource) {
|
||
if (record.certificate) {
|
||
copyText(record.certificate)
|
||
} else {
|
||
message.warning('证书内容为空')
|
||
}
|
||
}
|
||
|
||
function insertSampleCertificate() {
|
||
form.certificate = `-----BEGIN CERTIFICATE-----
|
||
MIIDYTCCAkWgAwIBAgIQDlRj+E8dQq1TjMh+9f2BmH1TANBgkqhkiG9w0BAQsFADBZ
|
||
MQswCQYDVQQGEwJDTjEQMA4GA1UECBMHQmVpamluZzEQMA4GA1UEBxMHQmVpamlu
|
||
ZzEUMBIGA1UEChMLRXhhbXBsZSBMTEMxEjAQBgNVBAMTCWV4YW1wbGUuY29tMB4X
|
||
DTI0MDEwMTAwMDAwMFoXDTI1MDEwMTAwMDAwMFowWTELMAkGA1UEBhMCQ04xEDAO
|
||
BgNVBAgTB0JlaWppbmcxEDAOBgNVBAcTB0JlaWppbmcxFDASBgNVBAoTC0V4YW1w
|
||
bGUgTExDMRIwEAYDVQQDEwlleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD
|
||
ggEPADCCAQoCggEBALR8K7T5e4H5VKtVrN7lzQ7X8N9ZQwKpXqKp4j9Yw+2WqW9
|
||
tL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z
|
||
8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZb
|
||
JN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL
|
||
8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+QpzL7zL8fQ5xZbJN0jK7z8+
|
||
-----END CERTIFICATE-----`
|
||
message.info('已插入示例证书格式')
|
||
}
|
||
|
||
function insertSamplePrivateKey() {
|
||
form.privateKey = `-----BEGIN PRIVATE KEY-----
|
||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0fCu0+XuB+VSr
|
||
Vaze5c0O1/DfWUMCqV6iqeI/WMPtlqlvbS/H0OcWWyTdIyu8/PkKcy+8y/H0OcW
|
||
WyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y
|
||
/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/Pk
|
||
Kcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdI
|
||
yu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0OcWWyTdIyu8/PkKcy+8y/H0Oc
|
||
-----END PRIVATE KEY-----`
|
||
message.info('已插入示例私钥格式')
|
||
}
|
||
|
||
// 格式清理功能
|
||
function formatCertificate() {
|
||
if (!form.certificate) return
|
||
// 移除多余的空格和换行
|
||
let cert = form.certificate.trim()
|
||
// 确保 BEGIN 和 END 标记在单独的行
|
||
cert = cert.replace(/-----BEGIN CERTIFICATE-----\s*/g, '-----BEGIN CERTIFICATE-----\n')
|
||
cert = cert.replace(/\s*-----END CERTIFICATE-----/g, '\n-----END CERTIFICATE-----')
|
||
form.certificate = cert
|
||
message.success('证书格式已优化')
|
||
}
|
||
|
||
function formatPrivateKey() {
|
||
if (!form.privateKey) return
|
||
// 移除多余的空格和换行
|
||
let key = form.privateKey.trim()
|
||
// 确保 BEGIN 和 END 标记在单独的行
|
||
key = key.replace(/-----BEGIN [\w\s]+-----\s*/g, (match) => match + '\n')
|
||
key = key.replace(/\s*-----END [\w\s]+-----/g, '\n-----END')
|
||
form.privateKey = key
|
||
message.success('私钥格式已优化')
|
||
}
|
||
|
||
function validatePrivateKeyInput() {
|
||
if (!form.privateKey || !form.privateKey.trim()) {
|
||
message.warning('请输入私钥内容后再检查格式')
|
||
return
|
||
}
|
||
|
||
const trimmed = form.privateKey.trim()
|
||
const hasBegin = trimmed.includes('-----BEGIN')
|
||
const hasPrivateKeyEnd = trimmed.includes('PRIVATE KEY-----') || trimmed.includes('RSA PRIVATE KEY-----') || trimmed.includes('ENCRYPTED PRIVATE KEY-----')
|
||
|
||
if (hasBegin && hasPrivateKeyEnd) {
|
||
message.success('私钥格式正确')
|
||
} else if (!hasBegin && !hasPrivateKeyEnd) {
|
||
message.warning('私钥缺少 BEGIN 和 PRIVATE KEY 标记')
|
||
} else if (!hasBegin) {
|
||
message.warning('私钥缺少 BEGIN 标记')
|
||
} else {
|
||
message.warning('私钥缺少 PRIVATE KEY 标记')
|
||
}
|
||
}
|
||
|
||
// 输入校验提示
|
||
function validateCertificateInput() {
|
||
if (!form.certificate || !form.certificate.trim()) {
|
||
message.warning('请输入证书内容后再检查格式')
|
||
return
|
||
}
|
||
|
||
const cert = form.certificate.trim()
|
||
const hasBegin = cert.includes('-----BEGIN CERTIFICATE-----')
|
||
const hasEnd = cert.includes('-----END CERTIFICATE-----')
|
||
|
||
if (hasBegin && hasEnd) {
|
||
message.success('证书格式正确')
|
||
} else if (!hasBegin && !hasEnd) {
|
||
message.warning('证书缺少 BEGIN 和 END 标记')
|
||
} else if (!hasBegin) {
|
||
message.warning('证书缺少 BEGIN CERTIFICATE 标记')
|
||
} else {
|
||
message.warning('证书缺少 END CERTIFICATE 标记')
|
||
}
|
||
}
|
||
|
||
// 加载应用下拉列表(仅当前用户可访问的应用)
|
||
async function loadAppOptions() {
|
||
try {
|
||
appOptions.value = await getMyAccessibleApps()
|
||
}
|
||
catch {
|
||
appOptions.value = []
|
||
}
|
||
}
|
||
|
||
async function loadList() {
|
||
loading.value = true
|
||
try {
|
||
// 构建过滤参数
|
||
const filterParams: any = {
|
||
resourceType: 'ssl',
|
||
keywords: searchText.value || undefined,
|
||
appId: selectedAppId.value,
|
||
certType: filterCertType.value,
|
||
page: pagination.current,
|
||
limit: pagination.pageSize,
|
||
}
|
||
|
||
// 状态过滤(简化处理)
|
||
if (filterStatus.value === 'valid') {
|
||
filterParams.status = 'valid'
|
||
} else if (filterStatus.value === 'expired') {
|
||
filterParams.status = 'expired'
|
||
} else if (filterStatus.value === 'expiring') {
|
||
// 即将到期的需要在客户端过滤
|
||
filterParams.status = 'valid' // 先加载有效的
|
||
}
|
||
|
||
const result = await pageAppResource(filterParams)
|
||
let filteredList = enrichResourcesWithPermission(result?.list ?? [])
|
||
|
||
// 客户端过滤:即将到期的证书
|
||
if (filterStatus.value === 'expiring') {
|
||
filteredList = filteredList.filter(item => isNearExpiry(item.expireAt!))
|
||
}
|
||
|
||
list.value = filteredList
|
||
pagination.total = filterStatus.value === 'expiring' ? filteredList.length : (result?.count ?? 0)
|
||
}
|
||
catch (e: any) {
|
||
message.error(e.message || '加载失败')
|
||
}
|
||
finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleTableChange(pag: any) {
|
||
pagination.current = pag.current
|
||
loadList()
|
||
}
|
||
|
||
function handleEdit(record: AppResource) {
|
||
if (!record.isOwner) {
|
||
message.warning('只有证书创建者才能编辑')
|
||
return
|
||
}
|
||
editRecord.value = record
|
||
|
||
// 检查是否有私钥
|
||
const hasPrivateKey = record.privateKey && record.privateKey.trim().length > 0
|
||
|
||
Object.assign(form, {
|
||
name: record.name,
|
||
domain: record.domain,
|
||
certType: record.certType,
|
||
issuer: record.issuer || '',
|
||
certificate: record.certificate || '',
|
||
privateKey: hasPrivateKey ? '[私钥已保存]' : '', // 编辑时显示提示,不显示实际私钥内容
|
||
appId: record.appId ? Number(record.appId) : undefined,
|
||
expireAt: record.expireAt ? record.expireAt : null,
|
||
remark: record.remark || '',
|
||
resourceId: record.resourceId,
|
||
})
|
||
showAdd.value = true
|
||
}
|
||
|
||
async function handleDelete(resourceId: number) {
|
||
const item = list.value.find(r => r.resourceId === resourceId)
|
||
if (item && !item.isOwner) {
|
||
message.warning('只有证书创建者才能删除')
|
||
return
|
||
}
|
||
try {
|
||
await removeAppResource(resourceId)
|
||
message.success('已移除')
|
||
loadList()
|
||
}
|
||
catch (e: any) {
|
||
message.error(e.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
await formRef.value?.validate()
|
||
saveLoading.value = true
|
||
try {
|
||
// 检查证书格式
|
||
if (form.certificate && !isValidCertificateFormat.value) {
|
||
message.warning('证书格式可能不正确,请确认包含正确的 BEGIN/END 标记')
|
||
}
|
||
|
||
const payload: AppResource = {
|
||
resourceType: 'ssl',
|
||
name: form.name,
|
||
domain: form.domain,
|
||
certType: form.certType,
|
||
issuer: form.issuer || undefined,
|
||
certificate: form.certificate,
|
||
privateKey: form.privateKey || undefined,
|
||
publicKey: '', // 后端可能需要这个字段,即使为空
|
||
appId: form.appId ? Number(form.appId) : undefined,
|
||
expireAt: form.expireAt ? dayjs(form.expireAt).format('YYYY-MM-DD') : undefined,
|
||
remark: form.remark || undefined,
|
||
}
|
||
|
||
// 处理私钥字段:如果是编辑模式且用户输入了"[私钥已保存]"或空值,则保留原私钥
|
||
if (editRecord.value) {
|
||
payload.resourceId = editRecord.value.resourceId
|
||
// 保持原有的publicKey值(如果有的话)
|
||
payload.publicKey = editRecord.value.publicKey || ''
|
||
|
||
// 判断是否需要保留原私钥
|
||
const privateKeyValue = form.privateKey.trim()
|
||
if (privateKeyValue === '[私钥已保存]' || !privateKeyValue) {
|
||
// 保留原私钥,不更新privateKey字段
|
||
if (payload.privateKey !== undefined) {
|
||
delete payload.privateKey
|
||
}
|
||
} else {
|
||
// 用户输入了新私钥,使用新值
|
||
payload.privateKey = privateKeyValue
|
||
}
|
||
|
||
await updateAppResource(payload)
|
||
message.success('保存成功')
|
||
}
|
||
else {
|
||
// 新增模式:私钥已在验证器中检查过格式
|
||
await addAppResource(payload)
|
||
message.success('证书添加成功')
|
||
}
|
||
showAdd.value = false
|
||
resetForm()
|
||
loadList()
|
||
}
|
||
catch (e: any) {
|
||
console.error('SSL证书保存失败:', e)
|
||
if (e.response?.data?.message) {
|
||
message.error(e.response.data.message)
|
||
} else if (e.message && e.message !== 'Network Error') {
|
||
message.error(e.message)
|
||
} else {
|
||
message.error('保存失败,请检查网络连接和表单填写')
|
||
}
|
||
}
|
||
finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
editRecord.value = null
|
||
formRef.value?.resetFields()
|
||
Object.assign(form, {
|
||
name: '',
|
||
domain: '',
|
||
certType: undefined,
|
||
issuer: 'Let\'s Encrypt', // 重置时也使用默认值
|
||
certificate: '',
|
||
privateKey: '',
|
||
appId: undefined,
|
||
expireAt: null,
|
||
remark: '',
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadAppOptions()
|
||
loadList()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dev-page { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||
|
||
/* 工具栏 */
|
||
.page-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 24px;
|
||
}
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 通知栏 */
|
||
.notice-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: linear-gradient(90deg, #f0f7ff 0%, #e6f4ff 100%);
|
||
border: 1px solid #91caff;
|
||
border-radius: 8px;
|
||
padding: 12px 18px;
|
||
margin-bottom: 24px;
|
||
font-size: 14px;
|
||
color: #1677ff;
|
||
}
|
||
.notice-icon {
|
||
font-size: 16px;
|
||
color: #1677ff;
|
||
}
|
||
.notice-bar strong {
|
||
color: #0958d9;
|
||
}
|
||
|
||
/* 卡片布局 */
|
||
.ssl-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.ssl-card {
|
||
background: #fff;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
transition: all 0.2s ease;
|
||
}
|
||
.ssl-card:hover {
|
||
border-color: #d9d9d9;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.card-expiring {
|
||
border-color: #ffd591;
|
||
background: linear-gradient(145deg, #fffbe6, #fff2cc);
|
||
}
|
||
|
||
/* 卡片头部 */
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 16px;
|
||
}
|
||
.card-header-left {
|
||
flex: 1;
|
||
}
|
||
.ssl-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
}
|
||
.ssl-name:hover {
|
||
color: #1890ff;
|
||
}
|
||
.ssl-text {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
.copy-icon {
|
||
color: #8c8c8c;
|
||
font-size: 13px;
|
||
opacity: 0.6;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.ssl-name:hover .copy-icon {
|
||
opacity: 1;
|
||
}
|
||
.ssl-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 卡片信息区 */
|
||
.card-info {
|
||
margin-bottom: 16px;
|
||
}
|
||
.info-row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
}
|
||
.info-label {
|
||
flex: 0 0 70px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
}
|
||
.info-value {
|
||
flex: 1;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
word-break: break-all;
|
||
}
|
||
.domain-value {
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.domain-value:hover {
|
||
color: #1890ff;
|
||
}
|
||
.copy-icon-small {
|
||
font-size: 11px;
|
||
color: #8c8c8c;
|
||
}
|
||
.text-muted {
|
||
color: rgba(0, 0, 0, 0.25);
|
||
}
|
||
.text-warning {
|
||
color: #fa8c16;
|
||
font-weight: 500;
|
||
}
|
||
.text-danger {
|
||
color: #f5222d;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 卡片操作区 */
|
||
.card-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
/* 加载和空状态 */
|
||
.card-loading {
|
||
text-align: center;
|
||
padding: 60px 0;
|
||
}
|
||
.card-empty {
|
||
text-align: center;
|
||
padding: 80px 0;
|
||
}
|
||
|
||
/* 分页 */
|
||
.card-pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* 表单样式 */
|
||
.form-row-2 {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.form-item-half {
|
||
flex: 1;
|
||
}
|
||
|
||
/* 表单输入框盒子 */
|
||
.input-box {
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
background: #fafafa;
|
||
}
|
||
.certificate-box {
|
||
border-color: #b7eb8f;
|
||
background: #f6ffed;
|
||
}
|
||
.private-key-box {
|
||
border-color: #ffd591;
|
||
background: #fffbe6;
|
||
}
|
||
.input-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
.security-notice {
|
||
font-size: 12px;
|
||
color: #fa8c16;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.certificate-preview {
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
background: #e6fffb;
|
||
border: 1px solid #87e8de;
|
||
border-radius: 6px;
|
||
}
|
||
.preview-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #08979c;
|
||
}
|
||
.security-tip {
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
background: #f0f7ff;
|
||
border: 1px solid #91caff;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: #1677ff;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 安全卡片 */
|
||
.security-card {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16px;
|
||
background: #f0f7ff;
|
||
border: 1px solid #91caff;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* 可选提示 */
|
||
.optional-notice {
|
||
font-size: 12px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
margin-bottom: 4px;
|
||
}
|
||
</style>
|