Files
acme-auto/frontend/src/views/Domains.vue
T

379 lines
17 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>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="font-size: 20px; font-weight: 700; margin: 0;">域名管理</h2>
<el-button type="primary" round :icon="Plus" @click="openCreate">新增域名</el-button>
</div>
<el-card>
<el-table :data="domains" stripe style="width: 100%;">
<el-table-column prop="domain" label="域名" width="240">
<template #default="{ row }">
<span style="font-weight: 600;">{{ row.domain }}</span>
</template>
</el-table-column>
<el-table-column label="服务器" width="120">
<template #default="{ row }">
<el-tag v-if="row.server_name" size="small">{{ row.server_name }}</el-tag>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="ACME 配置" width="120">
<template #default="{ row }">
<el-tag v-if="row.acme_config_name" type="info" size="small">{{ row.acme_config_name }}</el-tag>
<span v-else style="color: #c0c4cc;">默认</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.deploy_success === true ? 'success' : row.deploy_success === false ? 'danger' : 'info'" size="small">
{{ row.deploy_success === true ? '成功' : row.deploy_success === false ? '失败' : '未部署' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<template #default="{ row }">
<span :style="{ color: isExpiringSoon(row.expiry_date) ? '#f56c6c' : '#606266' }">
{{ row.expiry_date || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="版本" width="70">
<template #default="{ row }">{{ row.version }}</template>
</el-table-column>
<el-table-column label="操作" width="300" align="right">
<template #default="{ row }">
<div style="display: flex; align-items: center; gap: 4px; justify-content: flex-end;">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="success" link size="small" @click="handleIssue(row)">申请证书</el-button>
<el-button type="primary" link size="small" @click="openScript(row)">脚本</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleCertAction(cmd, row)">
<el-button type="primary" link size="small">
证书 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="fullchain">下载证书</el-dropdown-item>
<el-dropdown-item command="private">下载私钥</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑域名弹窗 -->
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑域名' : '新增域名'"
width="500px"
destroy-on-close
>
<el-form :model="domainForm" label-width="100px">
<el-form-item label="域名">
<el-input v-model="domainForm.domain" placeholder="example.com 或 *.example.com" :disabled="isEdit" />
</el-form-item>
<el-form-item label="关联服务器">
<el-select v-model="domainForm.server_id" placeholder="选择服务器" style="width: 100%;" clearable>
<el-option v-for="s in serverList" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="ACME 配置">
<el-select v-model="domainForm.acme_config_id" placeholder="使用默认配置" style="width: 100%;" clearable>
<el-option v-for="c in acmeConfigs" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button round @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" round :loading="saving" @click="saveDomain">保存</el-button>
</template>
</el-dialog>
<!-- 部署脚本弹窗 -->
<el-dialog
v-model="scriptDialogVisible"
title="部署脚本"
width="720px"
destroy-on-close
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<span style="font-weight: 600; font-size: 16px;">{{ scriptDomain?.domain }}</span>
<el-tag v-if="scriptDomain?.server_name" type="info" size="small">{{ scriptDomain.server_name }}</el-tag>
</div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 20px;">
<el-radio-group v-model="scriptOs" size="small" @change="loadScript">
<el-radio-button value="linux">Linux</el-radio-button>
<el-radio-button value="windows">Windows</el-radio-button>
</el-radio-group>
</div>
<!-- Linux 部署步骤 -->
<template v-if="scriptOs === 'linux'">
<el-steps direction="vertical" :active="3" style="margin-bottom: 12px;">
<el-step title="下载脚本" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px; word-break: break-all;">curl -fsSLk -H "Authorization: Bearer {{ adminToken }}" {{ scriptDownloadUrl }} -o /opt/deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.sh</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`curl -fsSLk -H 'Authorization: Bearer ${adminToken}' ${scriptDownloadUrl} -o /opt/deploy-${scriptDomain?.domain?.replace('*.', '')}.sh`)" />
</div>
</template>
</el-step>
<el-step title="赋予执行权限" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px;">chmod +x /opt/deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.sh</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`chmod +x /opt/deploy-${scriptDomain?.domain?.replace('*.', '')}.sh`)" />
</div>
</template>
</el-step>
<el-step title="手动执行一次,然后添加定时任务" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px;">/opt/deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.sh && echo "0 * * * * /opt/deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.sh >> /var/log/cert-deploy.log 2>&1" | crontab -</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`/opt/deploy-${scriptDomain?.domain?.replace('*.', '')}.sh && echo '0 * * * * /opt/deploy-${scriptDomain?.domain?.replace('*.', '')}.sh >> /var/log/cert-deploy.log 2>&1' | crontab -`)" />
</div>
</template>
</el-step>
</el-steps>
</template>
<!-- Windows 部署步骤 -->
<template v-if="scriptOs === 'windows'">
<el-steps direction="vertical" :active="3" style="margin-bottom: 12px;">
<el-step title="下载脚本" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px; word-break: break-all;">Invoke-WebRequest -Uri "{{ scriptDownloadUrl }}" -Headers @{Authorization="Bearer {{ adminToken }}"} -OutFile "C:\cert\deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.ps1" -SkipCertificateCheck</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`Invoke-WebRequest -Uri '${scriptDownloadUrl}' -Headers @{Authorization='Bearer ${adminToken}'} -OutFile 'C:\\cert\\deploy-${scriptDomain?.domain?.replace('*.', '')}.ps1' -SkipCertificateCheck`)" />
</div>
</template>
</el-step>
<el-step title="测试运行" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px;">powershell -ExecutionPolicy Bypass -File "C:\cert\deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.ps1"</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`powershell -ExecutionPolicy Bypass -File 'C:\\cert\\deploy-${scriptDomain?.domain?.replace('*.', '')}.ps1'`)" />
</div>
</template>
</el-step>
<el-step title="手动执行一次,然后添加计划任务" status="process">
<template #description>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
<code style="flex: 1; background: #1d1e1f; color: #e5eaf3; padding: 8px 12px; border-radius: 6px; font-size: 12px;">powershell -ExecutionPolicy Bypass -File "C:\cert\deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.ps1"; schtasks /create /tn "CertDeploy-{{ scriptDomain?.domain?.replace('*.', '') }}" /tr "powershell -ExecutionPolicy Bypass -File C:\cert\deploy-{{ scriptDomain?.domain?.replace('*.', '') }}.ps1" /sc hourly /f</code>
<el-button type="primary" link size="small" :icon="DocumentCopy" @click="copyText(`powershell -ExecutionPolicy Bypass -File 'C:\\cert\\deploy-${scriptDomain?.domain?.replace('*.', '')}.ps1'; schtasks /create /tn 'CertDeploy-${scriptDomain?.domain?.replace('*.', '')}' /tr 'powershell -ExecutionPolicy Bypass -File C:\\cert\\deploy-${scriptDomain?.domain?.replace('*.', '')}.ps1' /sc hourly /f`)" />
</div>
</template>
</el-step>
</el-steps>
</template>
<!-- 脚本内容 -->
<div style="display: flex; align-items: center; justify-content: space-between; margin: 16px 0 8px;">
<span style="font-weight: 600; font-size: 14px;">脚本内容</span>
<div style="display: flex; gap: 8px;">
<el-button type="primary" round size="small" :icon="DocumentCopy" @click="copyScript">复制脚本</el-button>
<el-button round size="small" :icon="Download" @click="downloadScript">下载脚本</el-button>
</div>
</div>
<el-input
v-model="scriptContent"
type="textarea"
:rows="14"
readonly
style="font-family: 'Courier New', monospace;"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, ArrowDown, DocumentCopy, Download } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getDomains, createDomain, updateDomain, deleteDomain, getServers, issueCert } from '../api'
import api from '../api'
const domains = ref([])
const serverList = ref([])
const acmeConfigs = ref([])
// 域名表单弹窗
const formDialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const saving = ref(false)
const domainForm = ref({ domain: '', server_id: null, acme_config_id: null })
// 脚本弹窗
const scriptDialogVisible = ref(false)
const scriptDomain = ref(null)
const scriptOs = ref('linux')
const scriptContent = ref('')
const scriptDownloadUrl = ref('')
const adminToken = ref(localStorage.getItem('admin_token') || '')
const load = async () => {
const { data } = await getDomains()
domains.value = data
}
const loadServers = async () => {
const { data } = await getServers()
serverList.value = data
}
const loadAcmeConfigs = async () => {
const { data } = await api.get('/acme/configs')
acmeConfigs.value = data
}
const isExpiringSoon = (dateStr) => {
if (!dateStr) return false
const diff = (new Date(dateStr) - new Date()) / (1000 * 60 * 60 * 24)
return diff < 30
}
// ---- 域名表单 ----
const openCreate = () => {
isEdit.value = false
editId.value = null
domainForm.value = { domain: '', server_id: null, acme_config_id: null }
formDialogVisible.value = true
}
const openEdit = (row) => {
isEdit.value = true
editId.value = row.id
domainForm.value = { domain: row.domain, server_id: row.server_id, acme_config_id: row.acme_config_id }
formDialogVisible.value = true
}
const saveDomain = async () => {
saving.value = true
try {
if (isEdit.value) {
await updateDomain(editId.value, domainForm.value)
} else {
await createDomain(domainForm.value)
}
ElMessage.success('保存成功')
formDialogVisible.value = false
await load()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
// ---- 脚本弹窗 ----
const openScript = async (row) => {
if (!row.server_name) {
ElMessage.warning('该域名未关联服务器,请先编辑关联服务器')
return
}
scriptDomain.value = row
scriptOs.value = 'linux'
scriptContent.value = ''
// 构建脚本下载 URL(供 curl 使用)
const base = window.location.origin
const params = new URLSearchParams({ domain: row.domain, server_name: row.server_name, os: 'linux' })
scriptDownloadUrl.value = `${base}/admin/api/script/download?${params}`
scriptDialogVisible.value = true
await loadScript()
}
const loadScript = async () => {
try {
const resp = await api.get('/script', {
params: {
domain: scriptDomain.value.domain,
server_name: scriptDomain.value.server_name,
os: scriptOs.value,
},
})
scriptContent.value = resp.data.script
// 更新下载 URL 的 os 参数
const base = window.location.origin
const params = new URLSearchParams({ domain: scriptDomain.value.domain, server_name: scriptDomain.value.server_name, os: scriptOs.value })
scriptDownloadUrl.value = `${base}/admin/api/script/download?${params}`
} catch (e) {
scriptContent.value = '# 加载失败: ' + (e.response?.data?.detail || e.message)
}
}
const copyText = (text) => {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
})
}
const copyScript = () => {
navigator.clipboard.writeText(scriptContent.value).then(() => {
ElMessage.success('已复制到剪贴板')
})
}
const downloadScript = () => {
const ext = scriptOs.value === 'windows' ? '.ps1' : '.sh'
const blob = new Blob([scriptContent.value], { type: 'text/plain' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `deploy-${scriptDomain.value.domain}${ext}`
link.click()
URL.revokeObjectURL(link.href)
}
// ---- 证书下载 ----
const handleCertAction = async (cmd, row) => {
try {
const { data } = await api.get(`/cert-download/${row.id}/${cmd}`, { responseType: 'blob' })
const blob = new Blob([data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = cmd === 'fullchain' ? `${row.domain}.pem` : `${row.domain}.key`
link.click()
URL.revokeObjectURL(link.href)
} catch (e) {
ElMessage.error(e.response?.data?.detail || '下载失败')
}
}
// ---- 删除 ----
// ---- 申请证书 ----
const handleIssue = async (row) => {
try {
await ElMessageBox.confirm(
`确认为 "${row.domain}" 申请证书?`,
'申请证书',
{ type: 'info', confirmButtonText: '立即申请', cancelButtonText: '取消' }
)
const { data } = await issueCert(row.id)
ElMessage.success(data.message || '证书申请成功')
await load()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e.response?.data?.detail || '证书申请失败')
}
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确认删除域名 "${row.domain}"`, '确认删除', { type: 'warning' })
await deleteDomain(row.id)
await load()
} catch {}
}
onMounted(() => {
load()
loadServers()
loadAcmeConfigs()
})
</script>