Files
tiantian-system/app/pages/developer/resources/ssl.vue
2026-04-08 17:10:58 +08:00

1109 lines
37 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>
<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">支持 PEMPKCS#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>