首次提交
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<!-- 未登录:显示登录页 -->
|
||||
<Login v-if="!isLoggedIn" @login-success="checkAuth" />
|
||||
|
||||
<!-- 已登录:显示主界面 -->
|
||||
<div v-else class="min-h-screen flex">
|
||||
<Toast />
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="w-64 bg-gray-900 text-white flex flex-col">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h1 class="text-xl font-bold">CertCenter</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">证书管理中心</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-1">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
:class="$route.path === item.path ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800'"
|
||||
>
|
||||
<span class="mr-3">{{ item.icon }}</span>
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<button @click="handleLogout" class="w-full px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="p-8">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Login from './views/Login.vue'
|
||||
import Toast from './components/Toast.vue'
|
||||
import api from './api'
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: '📊', name: '仪表盘' },
|
||||
{ path: '/servers', icon: '🖥️', name: '服务器管理' },
|
||||
{ path: '/domains', icon: '🌐', name: '域名管理' },
|
||||
{ path: '/acme', icon: '🔒', name: 'ACME 配置' },
|
||||
{ path: '/logs', icon: '📋', name: '部署日志' },
|
||||
{ path: '/acme/logs', icon: '📜', name: 'ACME 日志' },
|
||||
]
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
isLoggedIn.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.get('/me')
|
||||
isLoggedIn.value = true
|
||||
} catch {
|
||||
localStorage.removeItem('admin_token')
|
||||
isLoggedIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.post('/logout')
|
||||
} catch {}
|
||||
localStorage.removeItem('admin_token')
|
||||
isLoggedIn.value = false
|
||||
}
|
||||
|
||||
onMounted(checkAuth)
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/admin/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 请求拦截器:自动添加 token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:401 时清除 token
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
// 触发重新检查登录状态(通过刷新页面)
|
||||
window.location.reload()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Stats
|
||||
export const getStats = () => api.get('/stats')
|
||||
|
||||
// Servers
|
||||
export const getServers = () => api.get('/servers')
|
||||
export const getServer = (id) => api.get(`/servers/${id}`)
|
||||
export const createServer = (data) => api.post('/servers', data)
|
||||
export const updateServer = (id, data) => api.put(`/servers/${id}`, data)
|
||||
export const deleteServer = (id) => api.delete(`/servers/${id}`)
|
||||
|
||||
// Domains
|
||||
export const getDomains = () => api.get('/domains')
|
||||
export const getDomain = (id) => api.get(`/domains/${id}`)
|
||||
export const createDomain = (data) => api.post('/domains', data)
|
||||
export const updateDomain = (id, data) => api.put(`/domains/${id}`, data)
|
||||
export const deleteDomain = (id) => api.delete(`/domains/${id}`)
|
||||
|
||||
// Logs
|
||||
export const getLogs = (status) => api.get('/logs', { params: { status } })
|
||||
|
||||
// Script (通过管理接口获取脚本内容)
|
||||
export const getScript = (domain, serverName) =>
|
||||
axios.get(`/api/script/${domain}`, { params: { server_name: serverName } })
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="pointer-events-auto max-w-sm w-full bg-white border rounded-lg shadow-lg p-4 flex gap-3 items-start cursor-pointer select-text"
|
||||
:class="borderColor(t.type)"
|
||||
@click="copyAndDismiss(t)"
|
||||
>
|
||||
<span class="text-lg mt-0.5">{{ icon(t.type) }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-800 whitespace-pre-wrap break-all">{{ t.message }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">点击复制并关闭</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
@click.stop="dismiss(t.id)"
|
||||
>×</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function borderColor(type) {
|
||||
return {
|
||||
success: 'border-green-400',
|
||||
error: 'border-red-400',
|
||||
info: 'border-blue-400',
|
||||
warn: 'border-yellow-400',
|
||||
}[type] || 'border-gray-300'
|
||||
}
|
||||
|
||||
function icon(type) {
|
||||
return { success: '✅', error: '❌', info: 'ℹ️', warn: '⚠️' }[type] || '📢'
|
||||
}
|
||||
|
||||
function copyAndDismiss(t) {
|
||||
navigator.clipboard.writeText(t.message).catch(() => {})
|
||||
dismiss(t.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active { transition: all 0.3s ease-out; }
|
||||
.toast-leave-active { transition: all 0.2s ease-in; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(100px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(100px); }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const toasts = reactive([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message, type = 'info', duration = 5000) {
|
||||
const id = nextId++
|
||||
toasts.push({ id, message, type })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
const idx = toasts.findIndex(t => t.id === id)
|
||||
if (idx !== -1) toasts.splice(idx, 1)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts,
|
||||
dismiss,
|
||||
success: (msg, dur) => addToast(msg, 'success', dur),
|
||||
error: (msg, dur) => addToast(msg, 'error', dur),
|
||||
info: (msg, dur) => addToast(msg, 'info', dur),
|
||||
warn: (msg, dur) => addToast(msg, 'warn', dur),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/servers',
|
||||
name: 'Servers',
|
||||
component: () => import('../views/Servers.vue'),
|
||||
},
|
||||
{
|
||||
path: '/servers/new',
|
||||
name: 'ServerNew',
|
||||
component: () => import('../views/ServerForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/servers/:id/edit',
|
||||
name: 'ServerEdit',
|
||||
component: () => import('../views/ServerForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/domains',
|
||||
name: 'Domains',
|
||||
component: () => import('../views/Domains.vue'),
|
||||
},
|
||||
{
|
||||
path: '/domains/new',
|
||||
name: 'DomainNew',
|
||||
component: () => import('../views/DomainForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/domains/:id/edit',
|
||||
name: 'DomainEdit',
|
||||
component: () => import('../views/DomainForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/script/:domain',
|
||||
name: 'Script',
|
||||
component: () => import('../views/Script.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/acme',
|
||||
name: 'AcmeSettings',
|
||||
component: () => import('../views/AcmeSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/acme/logs',
|
||||
name: 'AcmeLogs',
|
||||
component: () => import('../views/AcmeLogs.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">ACME 日志</h2>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" @change="load"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="pending">进行中</option>
|
||||
</select>
|
||||
<button @click="load" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">操作</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-6 py-3 font-medium">{{ log.domain || '-' }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="actionClass(log.action)" class="px-2 py-0.5 rounded-full text-xs">
|
||||
{{ actionLabel(log.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-gray-500 max-w-xs truncate">{{ log.message }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<button v-if="log.detail" @click="showDetail(log)" class="text-blue-600 hover:underline text-xs">
|
||||
查看
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!logs.length">
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<div v-if="detailLog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="detailLog = null">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-semibold">日志详情</h3>
|
||||
<button @click="detailLog = null" class="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div><span class="text-gray-500">域名:</span>{{ detailLog.domain || '-' }}</div>
|
||||
<div><span class="text-gray-500">操作:</span>{{ actionLabel(detailLog.action) }}</div>
|
||||
<div><span class="text-gray-500">状态:</span>{{ detailLog.status }}</div>
|
||||
<div><span class="text-gray-500">消息:</span>{{ detailLog.message }}</div>
|
||||
</div>
|
||||
<div v-if="detailLog.detail" class="mt-4">
|
||||
<div class="text-sm text-gray-500 mb-1">详细日志:</div>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap">{{ detailLog.detail }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
const logs = ref([])
|
||||
const statusFilter = ref('')
|
||||
const detailLog = ref(null)
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const actionLabel = (action) => ({
|
||||
issue: '申请',
|
||||
renew: '续签',
|
||||
revoke: '吊销',
|
||||
register: '注册',
|
||||
}[action] || action)
|
||||
|
||||
const actionClass = (action) => ({
|
||||
issue: 'bg-blue-100 text-blue-700',
|
||||
renew: 'bg-orange-100 text-orange-700',
|
||||
revoke: 'bg-red-100 text-red-700',
|
||||
register: 'bg-green-100 text-green-700',
|
||||
}[action] || 'bg-gray-100 text-gray-700')
|
||||
|
||||
const statusClass = (status) => ({
|
||||
success: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
}[status] || 'bg-gray-100 text-gray-700')
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get('/acme/logs', { params: { status: statusFilter.value || undefined } })
|
||||
logs.value = data
|
||||
}
|
||||
|
||||
const showDetail = (log) => {
|
||||
detailLog.value = log
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-6">ACME 配置</h2>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<!-- ACME 服务器配置 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 class="font-semibold mb-4">基本配置</h3>
|
||||
<form @submit.prevent="saveConfig" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">ACME 服务器</label>
|
||||
<select v-model="form.acme_server"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||
<option value="https://acme-v02.api.letsencrypt.org/directory">Let's Encrypt (生产)</option>
|
||||
<option value="https://acme-staging-v02.api.letsencrypt.org/directory">Let's Encrypt (测试)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input v-model="form.email" type="email" required placeholder="admin@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">DNS 提商</label>
|
||||
<select v-model="form.dns_provider"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||
<option value="aliyun">阿里云 DNS</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">续签提前天数</label>
|
||||
<input v-model.number="form.renew_days" type="number" min="1" max="60"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
保存配置
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- DNS 凭据配置 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 class="font-semibold mb-4">DNS 凭据</h3>
|
||||
<form @submit.prevent="saveCredentials" class="space-y-4">
|
||||
<div v-if="form.dns_provider === 'aliyun'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Access Key</label>
|
||||
<input v-model="creds.access_key" type="text" placeholder="LTAI5t..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
<div v-if="form.dns_provider === 'aliyun'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Access Secret</label>
|
||||
<input v-model="creds.access_secret" type="password" placeholder="****"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
<div v-if="form.dns_provider === 'cloudflare'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">API Token</label>
|
||||
<input v-model="creds.api_token" type="password" placeholder="****"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||
保存凭据
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 操作 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h3 class="font-semibold mb-4">操作</h3>
|
||||
<div class="flex gap-4">
|
||||
<button @click="autoRenew" :disabled="renewing"
|
||||
class="px-6 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm disabled:opacity-50">
|
||||
{{ renewing ? '续签中...' : '🔄 自动续签所有' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="renewResult" class="mt-4 p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<div v-for="r in renewResult" :key="r.domain" class="flex items-center gap-2 py-1">
|
||||
<span :class="r.success ? 'text-green-600' : 'text-red-600'">{{ r.success ? '✓' : '✗' }}</span>
|
||||
<span class="font-medium">{{ r.domain }}</span>
|
||||
<span class="text-gray-500">- {{ r.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="showSaved" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm">
|
||||
配置已保存
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import api from '../api'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const form = ref({
|
||||
acme_server: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
email: '',
|
||||
dns_provider: 'aliyun',
|
||||
renew_days: 30,
|
||||
})
|
||||
|
||||
const creds = ref({
|
||||
access_key: '',
|
||||
access_secret: '',
|
||||
api_token: '',
|
||||
})
|
||||
|
||||
const showSaved = ref(false)
|
||||
const renewing = ref(false)
|
||||
const renewResult = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/acme/config')
|
||||
form.value.acme_server = data.acme_server
|
||||
form.value.email = data.email
|
||||
form.value.dns_provider = data.dns_provider
|
||||
form.value.renew_days = data.renew_days
|
||||
|
||||
// 解析已保存的凭据
|
||||
try {
|
||||
const saved = JSON.parse(data.dns_credentials || '{}')
|
||||
creds.value.access_key = saved.access_key || ''
|
||||
creds.value.access_secret = saved.access_secret ? '********' : ''
|
||||
creds.value.api_token = saved.api_token ? '********' : ''
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const saveConfig = async () => {
|
||||
await api.put('/acme/config', form.value)
|
||||
showSaved.value = true
|
||||
setTimeout(() => { showSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
const saveCredentials = async () => {
|
||||
let credentials = {}
|
||||
if (form.value.dns_provider === 'aliyun') {
|
||||
credentials = {
|
||||
access_key: creds.value.access_key,
|
||||
access_secret: creds.value.access_secret === '********' ? undefined : creds.value.access_secret,
|
||||
}
|
||||
} else if (form.value.dns_provider === 'cloudflare') {
|
||||
credentials = {
|
||||
api_token: creds.value.api_token === '********' ? undefined : creds.value.api_token,
|
||||
}
|
||||
}
|
||||
// 移除 undefined 值
|
||||
Object.keys(credentials).forEach(k => credentials[k] === undefined && delete credentials[k])
|
||||
await api.put('/acme/config', { dns_credentials: JSON.stringify(credentials) })
|
||||
showSaved.value = true
|
||||
setTimeout(() => { showSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
const autoRenew = async () => {
|
||||
renewing.value = true
|
||||
renewResult.value = null
|
||||
try {
|
||||
const { data } = await api.post('/acme/auto-renew')
|
||||
renewResult.value = data.results
|
||||
} catch (e) {
|
||||
toast.error('续签失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
renewing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-6">仪表盘</h2>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-blue-100 rounded-lg">
|
||||
<span class="text-2xl">🖥️</span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">服务器</p>
|
||||
<p class="text-2xl font-bold">{{ stats.server_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-green-100 rounded-lg">
|
||||
<span class="text-2xl">🌐</span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">域名</p>
|
||||
<p class="text-2xl font-bold">{{ stats.domain_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-red-100 rounded-lg">
|
||||
<span class="text-2xl">⚠️</span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">即将过期</p>
|
||||
<p class="text-2xl font-bold">{{ stats.expiring_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="flex gap-4 mb-8">
|
||||
<router-link to="/servers/new" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
+ 新增服务器
|
||||
</router-link>
|
||||
<router-link to="/domains/new" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||
+ 新增域名
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 最近日志 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h3 class="font-semibold">最近部署日志</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="log in stats.recent_logs" :key="log.id">
|
||||
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-6 py-3">{{ log.server }}</td>
|
||||
<td class="px-6 py-3">{{ log.domain }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-gray-500">{{ log.message }}</td>
|
||||
</tr>
|
||||
<tr v-if="!stats.recent_logs?.length">
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getStats } from '../api'
|
||||
|
||||
const stats = ref({
|
||||
server_count: 0,
|
||||
domain_count: 0,
|
||||
expiring_count: 0,
|
||||
recent_logs: [],
|
||||
})
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const statusClass = (status) => ({
|
||||
'bg-green-100 text-green-700': status === 'success',
|
||||
'bg-red-100 text-red-700': status === 'failed',
|
||||
'bg-gray-100 text-gray-700': status === 'skipped',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getStats()
|
||||
stats.value = data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑域名' : '新增域名' }}</h2>
|
||||
|
||||
<div class="max-w-xl bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">所属服务器</label>
|
||||
<select v-model="form.server_id" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||
<option value="" disabled>请选择服务器</option>
|
||||
<option v-for="s in servers" :key="s.id" :value="s.id">{{ s.name }} ({{ s.platform }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">域名</label>
|
||||
<input v-model="form.domain" type="text" required placeholder="如 example.com 或 *.example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||
<p class="text-xs text-gray-400 mt-1">泛域名填写 *.example.com,会自动同时申请裸域名证书</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">证书存放路径</label>
|
||||
<input v-model="form.cert_dir" type="text" required :placeholder="certDirPlaceholder"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">校验命令</label>
|
||||
<input v-model="form.check_cmd" type="text" required placeholder="nginx -t"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">重载命令</label>
|
||||
<input v-model="form.reload_cmd" type="text" required placeholder="systemctl reload nginx"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</button>
|
||||
<router-link to="/domains" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
取消
|
||||
</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getDomain, createDomain, updateDomain, getServers } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const servers = ref([])
|
||||
const form = ref({
|
||||
server_id: '',
|
||||
domain: '',
|
||||
cert_dir: '',
|
||||
check_cmd: 'nginx -t',
|
||||
reload_cmd: 'systemctl reload nginx',
|
||||
})
|
||||
|
||||
const selectedPlatform = computed(() => {
|
||||
const s = servers.value.find(s => s.id === form.value.server_id)
|
||||
return s?.platform || 'linux'
|
||||
})
|
||||
|
||||
const certDirPlaceholder = computed(() => {
|
||||
return selectedPlatform.value === 'windows'
|
||||
? 'C:\\certs\\example.com'
|
||||
: '/etc/nginx/ssl/example.com'
|
||||
})
|
||||
|
||||
// 切换服务器时自动调整默认值
|
||||
watch(() => form.value.server_id, () => {
|
||||
if (!isEdit.value) {
|
||||
if (selectedPlatform.value === 'windows') {
|
||||
form.value.check_cmd = 'nginx -t'
|
||||
form.value.reload_cmd = 'nginx -s reload'
|
||||
} else {
|
||||
form.value.check_cmd = 'nginx -t'
|
||||
form.value.reload_cmd = 'systemctl reload nginx'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getServers()
|
||||
servers.value = data
|
||||
|
||||
if (isEdit.value) {
|
||||
const { data: d } = await getDomain(route.params.id)
|
||||
form.value = d
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isEdit.value) {
|
||||
await updateDomain(route.params.id, form.value)
|
||||
} else {
|
||||
await createDomain(form.value)
|
||||
}
|
||||
router.push('/domains')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">域名管理</h2>
|
||||
<router-link to="/domains/new" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||
+ 新增域名
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">证书路径</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">版本</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">证书到期</th>
|
||||
<th class="px-6 py-3 text-right text-gray-500 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="d in domains" :key="d.id">
|
||||
<td class="px-6 py-3 font-medium">{{ d.domain }}</td>
|
||||
<td class="px-6 py-3 text-gray-500">{{ d.server_name }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ d.cert_dir }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ d.version }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span v-if="d.cert_not_after" :class="expiryClass(d.cert_not_after)" class="text-xs">
|
||||
{{ formatDate(d.cert_not_after) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400 text-xs">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right space-x-2">
|
||||
<button @click="handleIssue(d)" :disabled="d._issuing" class="text-orange-600 hover:underline text-xs disabled:opacity-50">
|
||||
{{ d._issuing ? '申请中...' : '申请证书' }}
|
||||
</button>
|
||||
<router-link :to="`/domains/${d.id}/edit`" class="text-blue-600 hover:underline text-xs">编辑</router-link>
|
||||
<button @click="copyDeployCmd(d)" class="text-green-600 hover:underline text-xs">复制部署命令</button>
|
||||
<router-link :to="`/script/${d.domain}?server=${d.server_name}`" class="text-purple-600 hover:underline text-xs">查看脚本</router-link>
|
||||
<button @click="handleDelete(d)" class="text-red-600 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!domains.length">
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无域名</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 复制成功提示 -->
|
||||
<div v-if="showCopied" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm transition-opacity">
|
||||
已复制到剪贴板
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getDomains, deleteDomain } from '../api'
|
||||
import api from '../api'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const domains = ref([])
|
||||
const showCopied = ref(false)
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await getDomains()
|
||||
domains.value = data.map(d => ({ ...d, _issuing: false }))
|
||||
}
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const expiryClass = (iso) => {
|
||||
if (!iso) return ''
|
||||
const days = Math.ceil((new Date(iso) - new Date()) / 86400000)
|
||||
if (days <= 7) return 'text-red-600 font-medium'
|
||||
if (days <= 30) return 'text-orange-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
const handleIssue = async (d) => {
|
||||
if (!confirm(`确认为 "${d.domain}" 申请/续签证书?`)) return
|
||||
d._issuing = true
|
||||
try {
|
||||
const { data } = await api.post(`/acme/issue/${d.id}`)
|
||||
data.success ? toast.success(`申请成功: ${data.message}`) : toast.error(`申请失败: ${data.message}`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error('操作失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
d._issuing = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (d) => {
|
||||
if (!confirm(`确认删除域名 "${d.domain}"?`)) return
|
||||
await deleteDomain(d.id)
|
||||
await load()
|
||||
}
|
||||
|
||||
const copyDeployCmd = async (d) => {
|
||||
// 根据服务器平台生成不同的部署命令
|
||||
const baseUrl = window.location.origin
|
||||
let cmd
|
||||
if (d.platform === 'windows') {
|
||||
cmd = `Invoke-WebRequest "${baseUrl}/api/script/${d.domain}?server=${d.server_name}" -OutFile C:\\scripts\\deploy-cert.ps1`
|
||||
} else {
|
||||
cmd = `curl -fsSL "${baseUrl}/api/script/${d.domain}?server=${d.server_name}" -o /usr/local/bin/deploy-cert.sh && chmod +x /usr/local/bin/deploy-cert.sh`
|
||||
}
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
showCopied.value = true
|
||||
setTimeout(() => { showCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold">🔐 CertCenter</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">请登录以继续</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||
<input v-model="username" type="text" required autofocus
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
placeholder="admin" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input v-model="password" type="password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
placeholder="密码" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
const emit = defineEmits(['login-success'])
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/login', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
})
|
||||
if (data.ok) {
|
||||
localStorage.setItem('admin_token', data.token)
|
||||
emit('login-success')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">部署日志</h2>
|
||||
<select v-model="statusFilter" @change="load"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="skipped">跳过</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-6 py-3">{{ log.server }}</td>
|
||||
<td class="px-6 py-3">{{ log.domain }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-gray-500">{{ log.message }}</td>
|
||||
</tr>
|
||||
<tr v-if="!logs.length">
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLogs } from '../api'
|
||||
|
||||
const logs = ref([])
|
||||
const statusFilter = ref('')
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const statusClass = (status) => ({
|
||||
'bg-green-100 text-green-700': status === 'success',
|
||||
'bg-red-100 text-red-700': status === 'failed',
|
||||
'bg-gray-100 text-gray-700': status === 'skipped',
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await getLogs(statusFilter.value || undefined)
|
||||
logs.value = data
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-6">脚本预览</h2>
|
||||
|
||||
<!-- 部署命令 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||
<h3 class="font-semibold mb-3">客户端部署命令</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">在业务服务器上执行以下命令下载脚本:</p>
|
||||
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<div v-if="isLinux">
|
||||
curl -fsSL \<br />
|
||||
"{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" \<br />
|
||||
-o /usr/local/bin/deploy-cert.sh<br /><br />
|
||||
chmod +x /usr/local/bin/deploy-cert.sh<br /><br />
|
||||
# 添加 cron(每 30 分钟检查一次)<br />
|
||||
echo '*/30 * * * * /usr/local/bin/deploy-cert.sh >> /var/log/deploy-cert.log 2>&1' | crontab -
|
||||
</div>
|
||||
<div v-else>
|
||||
Invoke-WebRequest ` <br />
|
||||
-Uri "{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" ` <br />
|
||||
-OutFile C:\scripts\deploy-cert.ps1<br /><br />
|
||||
# 添加计划任务(每 30 分钟)<br />
|
||||
schtasks /create /sc minute /mo 30 /tn "CertDeploy-{{ domain }}" /tr "powershell -File C:\scripts\deploy-cert.ps1"
|
||||
</div>
|
||||
</div>
|
||||
<button @click="copyCommand" class="mt-3 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
📋 复制命令
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 脚本内容 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="font-semibold">脚本内容</h3>
|
||||
<button @click="downloadScript" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
⬇️ 下载脚本
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm overflow-x-auto whitespace-pre-wrap">{{ scriptContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 复制成功提示 -->
|
||||
<div v-if="showCopied" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm">
|
||||
已复制到剪贴板
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getScript, getDomains } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const domain = route.params.domain
|
||||
const serverName = route.query.server
|
||||
const baseUrl = window.location.origin
|
||||
const scriptContent = ref('')
|
||||
const showCopied = ref(false)
|
||||
|
||||
const isLinux = computed(() => {
|
||||
// 从脚本内容判断平台
|
||||
return !scriptContent.value.includes('$ErrorActionPreference')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getScript(domain, serverName)
|
||||
scriptContent.value = data
|
||||
})
|
||||
|
||||
const copyCommand = async () => {
|
||||
const cmd = isLinux.value
|
||||
? `curl -fsSL "${baseUrl}/api/script/${domain}?server_name=${serverName}" -o /usr/local/bin/deploy-cert.sh && chmod +x /usr/local/bin/deploy-cert.sh`
|
||||
: `Invoke-WebRequest -Uri "${baseUrl}/api/script/${domain}?server_name=${serverName}" -OutFile C:\\scripts\\deploy-cert.ps1`
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
showCopied.value = true
|
||||
setTimeout(() => { showCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const downloadScript = () => {
|
||||
const ext = isLinux.value ? 'sh' : 'ps1'
|
||||
const blob = new Blob([scriptContent.value], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `deploy-cert-${domain}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑服务器' : '新增服务器' }}</h2>
|
||||
|
||||
<div class="max-w-xl bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
||||
<input v-model="form.name" type="text" required placeholder="如 vps-blog"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">平台</label>
|
||||
<select v-model="form.platform"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||
<option value="linux">Linux</option>
|
||||
<option value="windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Token</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.token" type="text" required placeholder="认证 Token"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||
<button type="button" @click="generateToken"
|
||||
class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">IP(可选,备注用)</label>
|
||||
<input v-model="form.ip" type="text" placeholder="如 1.2.3.4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</button>
|
||||
<router-link to="/servers" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
取消
|
||||
</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getServer, createServer, updateServer } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
platform: 'linux',
|
||||
token: '',
|
||||
ip: '',
|
||||
})
|
||||
|
||||
const generateToken = () => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let token = 'tok_'
|
||||
for (let i = 0; i < 24; i++) {
|
||||
token += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
form.value.token = token
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
const { data } = await getServer(route.params.id)
|
||||
form.value = data
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isEdit.value) {
|
||||
await updateServer(route.params.id, form.value)
|
||||
} else {
|
||||
await createServer(form.value)
|
||||
}
|
||||
router.push('/servers')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">服务器管理</h2>
|
||||
<router-link to="/servers/new" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||
+ 新增服务器
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">名称</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">平台</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">IP</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名数</th>
|
||||
<th class="px-6 py-3 text-left text-gray-500 font-medium">Token</th>
|
||||
<th class="px-6 py-3 text-right text-gray-500 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="server in servers" :key="server.id">
|
||||
<td class="px-6 py-3 font-medium">{{ server.name }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="server.platform === 'linux' ? 'bg-yellow-100 text-yellow-700' : 'bg-blue-100 text-blue-700'" class="px-2 py-0.5 rounded-full text-xs">
|
||||
{{ server.platform }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-gray-500">{{ server.ip || '-' }}</td>
|
||||
<td class="px-6 py-3">{{ server.domain_count }}</td>
|
||||
<td class="px-6 py-3">
|
||||
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ server.token.substring(0, 12) }}...</code>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right space-x-2">
|
||||
<router-link :to="`/servers/${server.id}/edit`" class="text-blue-600 hover:underline text-xs">编辑</router-link>
|
||||
<button @click="handleDelete(server)" class="text-red-600 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!servers.length">
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-400">暂无服务器</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getServers, deleteServer } from '../api'
|
||||
|
||||
const servers = ref([])
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await getServers()
|
||||
servers.value = data
|
||||
}
|
||||
|
||||
const handleDelete = async (server) => {
|
||||
if (!confirm(`确认删除服务器 "${server.name}"?\n\n该服务器下的所有域名配置也会被删除。`)) return
|
||||
await deleteServer(server.id)
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user