改用element

增加acme多配置
This commit is contained in:
2026-07-18 23:17:53 +08:00
parent ca957385f2
commit 226f6a76dd
26 changed files with 1572 additions and 1922 deletions
+15
View File
@@ -4,6 +4,7 @@ ACME 核心服务 - 集成 Let's Encrypt 证书申请与续签
"""
import json
import os
import shutil
import time
import hashlib
import base64
@@ -223,6 +224,20 @@ class AcmeService:
)
output = result.stdout + "\n" + result.stderr
success = result.returncode == 0
# certbot 实际存储路径(可能忽略了 --cert-path
certbot_live = self.cert_dir / ".certbot-config" / "live" / store_dir
if success and certbot_live.exists():
import shutil
src_fullchain = certbot_live / "fullchain.pem"
src_privkey = certbot_live / "privkey.pem"
dst_fullchain = domain_dir / "fullchain.pem"
dst_privkey = domain_dir / "private.key"
if src_fullchain.exists():
shutil.copy2(str(src_fullchain), str(dst_fullchain))
if src_privkey.exists():
shutil.copy2(str(src_privkey), str(dst_privkey))
return success, output
except subprocess.TimeoutExpired:
return False, "Certbot timeout after 180s"
+31
View File
@@ -24,4 +24,35 @@ async def get_db():
async def init_db():
async with engine.begin() as conn:
# 迁移旧表:acme_config → acme_configs
try:
result = await conn.execute(
__import__("sqlalchemy").text("SELECT name FROM sqlite_master WHERE type='table' AND name='acme_config'")
)
if result.fetchone():
await conn.execute(__import__("sqlalchemy").text("ALTER TABLE acme_config RENAME TO acme_configs"))
except Exception:
pass
await conn.run_sync(Base.metadata.create_all)
# 迁移:给 domains 表加 acme_config_id 列
try:
await conn.execute(
__import__("sqlalchemy").text("ALTER TABLE domains ADD COLUMN acme_config_id INTEGER REFERENCES acme_configs(id)")
)
except Exception:
pass # 列已存在
# 确保至少有一条默认配置
result = await conn.execute(
__import__("sqlalchemy").text("SELECT COUNT(*) FROM acme_configs")
)
count = result.scalar()
if count == 0:
await conn.execute(
__import__("sqlalchemy").text(
"INSERT INTO acme_configs (name, acme_server, email, dns_provider, dns_credentials, renew_days) "
"VALUES ('默认配置', 'https://acme-v02.api.letsencrypt.org/directory', '', 'aliyun', '{}', 30)"
)
)
+9 -4
View File
@@ -16,9 +16,10 @@ from backend.routers.admin import (
login, logout, check_auth,
get_stats, list_servers, get_server, create_server, update_server, delete_server,
list_domains, get_domain, create_domain, update_domain, delete_domain,
list_logs, get_acme_config, update_acme_config,
list_logs,
list_acme_configs, get_acme_config, create_acme_config, update_acme_config, delete_acme_config,
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
download_cert_file,
download_cert_file, admin_generate_script,
)
logging.basicConfig(level=logging.DEBUG)
@@ -81,14 +82,18 @@ app.post("/admin/api/domains", status_code=201)(create_domain)
app.put("/admin/api/domains/{domain_id}")(update_domain)
app.delete("/admin/api/domains/{domain_id}")(delete_domain)
app.get("/admin/api/logs")(list_logs)
app.get("/admin/api/acme/config")(get_acme_config)
app.put("/admin/api/acme/config")(update_acme_config)
app.get("/admin/api/acme/configs")(list_acme_configs)
app.get("/admin/api/acme/configs/{config_id}")(get_acme_config)
app.post("/admin/api/acme/configs")(create_acme_config)
app.put("/admin/api/acme/configs/{config_id}")(update_acme_config)
app.delete("/admin/api/acme/configs/{config_id}")(delete_acme_config)
app.post("/admin/api/acme/issue/{domain_id}")(issue_cert)
app.post("/admin/api/acme/renew/{domain_id}")(renew_cert)
app.post("/admin/api/acme/auto-renew")(auto_renew_all)
app.get("/admin/api/acme/logs")(list_acme_logs)
app.get("/admin/api/cert-info/{domain_id}")(get_cert_info)
app.get("/admin/api/cert-download/{domain_id}/{file_type}")(download_cert_file)
app.get("/admin/api/script")(admin_generate_script)
logger.info("=== 路由注册完成 ===")
for route in app.routes:
+9 -3
View File
@@ -23,6 +23,7 @@ class Domain(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
acme_config_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("acme_configs.id"), nullable=True)
domain: Mapped[str] = mapped_column(String(200), nullable=False)
cert_dir: Mapped[str] = mapped_column(String(500), nullable=False)
check_cmd: Mapped[str] = mapped_column(String(200), nullable=False)
@@ -33,6 +34,7 @@ class Domain(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
server: Mapped["Server"] = relationship(back_populates="domains")
acme_config: Mapped["AcmeConfig | None"] = relationship(back_populates="domains")
logs: Mapped[list["DeployLog"]] = relationship(back_populates="domain")
@@ -50,10 +52,11 @@ class DeployLog(Base):
class AcmeConfig(Base):
"""ACME 全局配置(单例"""
__tablename__ = "acme_config"
"""ACME 配置(支持多个"""
__tablename__ = "acme_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, default="默认配置")
# ACME 服务器地址
acme_server: Mapped[str] = mapped_column(String(500), default="https://acme-v02.api.letsencrypt.org/directory")
# 邮箱
@@ -66,8 +69,11 @@ class AcmeConfig(Base):
account_key: Mapped[str | None] = mapped_column(Text, nullable=True)
# 续签提前天数
renew_days: Mapped[int] = mapped_column(Integer, default=30)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
domains: Mapped[list["Domain"]] = relationship(back_populates="acme_config")
class AcmeLog(Base):
"""ACME 操作日志"""
+168 -49
View File
@@ -5,11 +5,15 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from datetime import datetime, timedelta
from pathlib import Path
import hashlib
import hmac
import json
import logging
import time
logger = logging.getLogger(__name__)
from backend.database import get_db
from backend.models import Server, Domain, DeployLog, AcmeConfig, AcmeLog
from backend.config import get_settings
@@ -115,13 +119,15 @@ class ServerUpdate(BaseModel):
class DomainCreate(BaseModel):
server_id: int
domain: str
cert_dir: str
acme_config_id: int | None = None
cert_dir: str | None = None
check_cmd: str = "nginx -t"
reload_cmd: str = "systemctl reload nginx"
class DomainUpdate(BaseModel):
server_id: int | None = None
acme_config_id: int | None = None
domain: str | None = None
cert_dir: str | None = None
check_cmd: str | None = None
@@ -238,7 +244,7 @@ async def delete_server(server_id: int, db: AsyncSession = Depends(get_db), _: b
@router.get("/domains")
async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(
select(Domain).options(selectinload(Domain.server)).order_by(Domain.id)
select(Domain).options(selectinload(Domain.server), selectinload(Domain.acme_config)).order_by(Domain.id)
)
domains = result.scalars().all()
return [
@@ -246,6 +252,8 @@ async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(req
"id": d.id,
"server_id": d.server_id,
"server_name": d.server.name if d.server else None,
"acme_config_id": d.acme_config_id,
"acme_config_name": d.acme_config.name if d.acme_config else None,
"domain": d.domain,
"cert_dir": d.cert_dir,
"check_cmd": d.check_cmd,
@@ -266,6 +274,7 @@ async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
return {
"id": d.id,
"server_id": d.server_id,
"acme_config_id": d.acme_config_id,
"domain": d.domain,
"cert_dir": d.cert_dir,
"check_cmd": d.check_cmd,
@@ -276,7 +285,10 @@ async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
@router.post("/domains", status_code=201)
async def create_domain(data: DomainCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
domain = Domain(**data.model_dump())
dump = data.model_dump()
if not dump.get("cert_dir"):
dump["cert_dir"] = dump["domain"].lstrip("*.")
domain = Domain(**dump)
db.add(domain)
await db.flush()
await db.refresh(domain)
@@ -331,9 +343,19 @@ async def list_logs(status: str | None = None, db: AsyncSession = Depends(get_db
]
# ──────────────── ACME 配置 ────────────────
# ──────────────── ACME 配置(多配置)────────────────
class AcmeConfigCreate(BaseModel):
name: str = "默认配置"
acme_server: str = "https://acme-v02.api.letsencrypt.org/directory"
email: str = ""
dns_provider: str = "aliyun"
dns_credentials: str = "{}"
renew_days: int = 30
class AcmeConfigUpdate(BaseModel):
name: str | None = None
acme_server: str | None = None
email: str | None = None
dns_provider: str | None = None
@@ -341,62 +363,111 @@ class AcmeConfigUpdate(BaseModel):
renew_days: int | None = None
@router.get("/acme/config")
async def get_acme_config(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
# 返回默认配置
return {
"acme_server": "https://acme-v02.api.letsencrypt.org/directory",
"email": "",
"dns_provider": "aliyun",
"dns_credentials": "{}",
"renew_days": 30,
"has_account_key": False,
}
def _serialize_acme_config(c: AcmeConfig) -> dict:
return {
"acme_server": config.acme_server,
"email": config.email,
"dns_provider": config.dns_provider,
"dns_credentials": config.dns_credentials,
"renew_days": config.renew_days,
"has_account_key": config.account_key is not None,
"id": c.id,
"name": c.name,
"acme_server": c.acme_server,
"email": c.email,
"dns_provider": c.dns_provider,
"dns_credentials": c.dns_credentials,
"renew_days": c.renew_days,
"has_account_key": c.account_key is not None,
"domain_count": len(c.domains) if c.domains else 0,
}
@router.put("/acme/config")
async def update_acme_config(data: AcmeConfigUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
@router.get("/acme/configs")
async def list_acme_configs(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).options(selectinload(AcmeConfig.domains)).order_by(AcmeConfig.id))
configs = result.scalars().all()
return [_serialize_acme_config(c) for c in configs]
@router.get("/acme/configs/{config_id}")
async def get_acme_config(config_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
c = result.scalar_one_or_none()
if not c:
raise HTTPException(404, "ACME config not found")
return {
"id": c.id,
"name": c.name,
"acme_server": c.acme_server,
"email": c.email,
"dns_provider": c.dns_provider,
"dns_credentials": c.dns_credentials,
"renew_days": c.renew_days,
"has_account_key": c.account_key is not None,
}
@router.post("/acme/configs", status_code=201)
async def create_acme_config(data: AcmeConfigCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
config = AcmeConfig(**data.model_dump())
db.add(config)
await db.flush()
await db.refresh(config)
return {"id": config.id, "name": config.name}
@router.put("/acme/configs/{config_id}")
async def update_acme_config(config_id: int, data: AcmeConfigUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
config = result.scalar_one_or_none()
if not config:
config = AcmeConfig(id=1)
db.add(config)
raise HTTPException(404, "ACME config not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(config, key, value)
return {"id": config.id}
@router.delete("/acme/configs/{config_id}")
async def delete_acme_config(config_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(404, "ACME config not found")
# 解除关联的域名
result = await db.execute(select(Domain).where(Domain.acme_config_id == config_id))
for d in result.scalars().all():
d.acme_config_id = None
await db.delete(config)
return {"ok": True}
# ──────────────── ACME 操作 ────────────────
async def _get_domain_acme_config(domain: Domain, db: AsyncSession) -> AcmeConfig:
"""获取域名关联的 ACME 配置,未关联则使用第一个可用配置"""
if domain.acme_config_id:
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == domain.acme_config_id))
config = result.scalar_one_or_none()
if config:
return config
# 回退:使用第一个有邮箱的配置
result = await db.execute(select(AcmeConfig).where(AcmeConfig.email != "").order_by(AcmeConfig.id))
config = result.scalars().first()
if not config:
raise HTTPException(400, "无可用的 ACME 配置,请先配置邮箱")
return config
@router.post("/acme/issue/{domain_id}")
async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
"""为指定域名申请证书"""
from backend.acme_service import AcmeService
from backend.config import get_settings
# 获取 ACME 配置
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config or not config.email:
raise HTTPException(400, "ACME not configured, please set email first")
# 获取域名
result = await db.execute(select(Domain).where(Domain.id == domain_id))
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(404, "Domain not found")
# 获取该域名关联的 ACME 配置
config = await _get_domain_acme_config(domain, db)
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
@@ -411,7 +482,6 @@ async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
log.message = msg
if success:
# 更新域名的证书到期时间
info = service.get_cert_info(domain.domain)
if info:
domain.cert_not_after = datetime.fromisoformat(info["not_after"])
@@ -438,23 +508,30 @@ async def auto_renew_all(db: AsyncSession = Depends(get_db), _: bool = Depends(r
from backend.acme_service import AcmeService
from backend.config import get_settings
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config or not config.email:
raise HTTPException(400, "ACME not configured")
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
# 获取所有域名
result = await db.execute(select(Domain))
result = await db.execute(select(Domain).options(selectinload(Domain.acme_config)))
domains = result.scalars().all()
# 按 ACME 配置分组,为每个配置创建一个 service 实例
services: dict[int, AcmeService] = {}
results = []
for d in domains:
try:
config = await _get_domain_acme_config(d, db)
except HTTPException:
results.append({"domain": d.domain, "success": False, "message": "无可用 ACME 配置", "days_left": 0})
continue
if config.id not in services:
services[config.id] = AcmeService(config, settings.cert_dir)
service = services[config.id]
need_renew, days_left = service.check_expiry(d.domain)
if need_renew:
log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"Auto-renewing {d.domain}")
log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"Auto-renewing {d.domain} (config: {config.name})")
db.add(log)
await db.flush()
@@ -503,6 +580,49 @@ async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(g
]
# ──────────────── 部署脚本 ────────────────
@router.get("/script")
async def admin_generate_script(
domain: str,
server_name: str,
os: str = "linux",
db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth),
):
"""管理端生成部署脚本"""
from jinja2 import Environment, FileSystemLoader
from fastapi.responses import PlainTextResponse
template_dir = Path(__file__).parent.parent / "templates_cert"
jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
result = await db.execute(select(Server).where(Server.name == server_name))
server = result.scalar_one_or_none()
if not server:
raise HTTPException(404, "Server not found")
result = await db.execute(
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
)
d = result.scalar_one_or_none()
if not d:
raise HTTPException(404, "Domain not found for this server")
settings = get_settings()
template_name = "deploy-cert.ps1.j2" if os == "windows" else "deploy-cert.sh.j2"
tpl = jinja_env.get_template(template_name)
script = tpl.render(
domain=domain,
base_url=settings.base_url,
token=server.token,
cert_dir=d.cert_dir,
check_cmd=d.check_cmd,
reload_cmd=d.reload_cmd,
)
return PlainTextResponse(script)
# ──────────────── 证书信息 ────────────────
@router.get("/cert-info/{domain_id}")
@@ -516,10 +636,7 @@ async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: b
if not domain:
raise HTTPException(404, "Domain not found")
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = AcmeConfig(id=1)
config = await _get_domain_acme_config(domain, db)
settings = get_settings()
service = AcmeService(config, settings.cert_dir)
@@ -565,8 +682,10 @@ async def download_cert_file(domain_id: int, file_type: str, db: AsyncSession =
file_path = Path(settings.cert_dir) / store_dir / "private.key"
filename = f"{store_dir}-private.key"
logger.debug(f"下载证书: domain={domain.domain}, store_dir={store_dir}, file_path={file_path}, exists={file_path.exists()}")
if not file_path.exists():
raise HTTPException(404, "文件不存在,请先申请证书")
raise HTTPException(404, f"文件不存在 ({file_path}),请先申请证书")
return FileResponse(
str(file_path),
+2 -2
View File
@@ -5,8 +5,8 @@ After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/acme-auto
ExecStart=/opt/acme-auto/.venv/bin/python -m backend.main
WorkingDirectory=/usr/local/acme-auto
ExecStart=/usr/local/acme-auto/.venv/bin/python -m backend.main
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
+524 -919
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -9,15 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.0",
"element-plus": "^2.14.3",
"unplugin-element-plus": "^0.11.2",
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"axios": "^1.7.0"
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
"vite": "^5.4.0"
}
}
+1 -4
View File
@@ -1,6 +1,3 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {},
}
+43 -45
View File
@@ -1,59 +1,57 @@
<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 v-if="!isLoggedIn">
<Login @login-success="checkAuth" />
</div>
<el-container v-else style="height: 100vh;">
<el-aside width="220px" style="background: #1d1e1f; display: flex; flex-direction: column;">
<div style="padding: 20px 16px; color: #fff;">
<div style="font-size: 18px; font-weight: 700;">CertCenter</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">证书管理中心</div>
</div>
<el-divider style="border-color: #333; margin: 0;" />
<el-menu
:default-active="activeMenu"
router
background-color="#1d1e1f"
text-color="#bbb"
active-text-color="#409eff"
style="border-right: none; flex: 1;"
>
<el-menu-item v-for="item in navItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.name }}</span>
</el-menu-item>
</el-menu>
<div style="padding: 12px 16px;">
<el-button round style="width: 100%;" @click="handleLogout">退出登录</el-button>
</div>
</el-aside>
<el-main style="background: #f5f7fa; padding: 24px; overflow-y: auto;">
<router-view />
</el-main>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
Odometer, Monitor, SetUp, Setting, Document, Files,
} from '@element-plus/icons-vue'
import Login from './views/Login.vue'
import Toast from './components/Toast.vue'
import api from './api'
const route = useRoute()
const isLoggedIn = ref(false)
const activeMenu = computed(() => route.path)
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 日志' },
{ path: '/', icon: Odometer, name: '仪表盘' },
{ path: '/servers', icon: Monitor, name: '服务器管理' },
{ path: '/domains', icon: SetUp, name: '域名管理' },
{ path: '/acme', icon: Setting, name: 'ACME 配置' },
{ path: '/logs', icon: Document, name: '部署日志' },
{ path: '/acme/logs', icon: Files, name: 'ACME 日志' },
]
const checkAuth = async () => {
+14 -14
View File
@@ -1,20 +1,20 @@
<template>
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
<div style="position: fixed; top: 16px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; 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)"
style="pointer-events: auto; max-width: 360px; width: 100%; background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.15); padding: 16px; display: flex; gap: 12px; align-items: flex-start; cursor: pointer; border-left: 4px solid;"
:style="{ borderLeftColor: 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>
<span style="font-size: 14px; margin-top: 2px;">{{ icon(t.type) }}</span>
<div style="flex: 1; min-width: 0;">
<p style="font-size: 13px; color: #303133; white-space: pre-wrap; word-break: break-all; margin: 0;">{{ t.message }}</p>
<p style="font-size: 11px; color: #c0c4cc; margin: 4px 0 0 0;">点击复制并关闭</p>
</div>
<button
class="text-gray-400 hover:text-gray-600 text-lg leading-none"
style="color: #c0c4cc; background: none; border: none; font-size: 18px; cursor: pointer; padding: 0; line-height: 1;"
@click.stop="dismiss(t.id)"
>&times;</button>
</div>
@@ -29,15 +29,15 @@ 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'
success: '#67c23a',
error: '#f56c6c',
info: '#409eff',
warn: '#e6a23c',
}[type] || '#dcdfe6'
}
function icon(type) {
return { success: '', error: '', info: '', warn: '⚠️' }[type] || '📢'
return { success: '[OK]', error: '[ERR]', info: '[i]', warn: '[!]' }[type] || '[*]'
}
function copyAndDismiss(t) {
+4 -1
View File
@@ -1,6 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
-25
View File
@@ -11,36 +11,11 @@ const routes = [
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',
+5 -3
View File
@@ -1,3 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global styles */
html, body {
margin: 0;
padding: 0;
}
+63 -80
View File
@@ -1,80 +1,61 @@
<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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="font-size: 20px; font-weight: 700; margin: 0;">ACME 日志</h2>
<div style="display: flex; gap: 8px;">
<el-select v-model="statusFilter" style="width: 160px;" @change="load">
<el-option label="全部状态" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="进行中" value="pending" />
</el-select>
<el-button round @click="load">刷新</el-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>
<el-card>
<el-table :data="logs" stripe style="width: 100%;">
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="domain" label="域名" width="200">
<template #default="{ row }">
<span style="font-weight: 600;">{{ row.domain || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-tag :type="actionType(row.action)" size="small">{{ actionLabel(row.action) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" show-overflow-tooltip />
<el-table-column label="详情" width="80" align="center">
<template #default="{ row }">
<el-button v-if="row.detail" type="primary" link size="small" @click="showDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 详情弹窗 -->
<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>
<el-dialog v-model="showDialog" title="日志详情" width="680px">
<template v-if="detailLog">
<div style="line-height: 2; margin-bottom: 16px;">
<div><span style="color: #909399;">域名</span>{{ detailLog.domain || '-' }}</div>
<div><span style="color: #909399;">操作</span>{{ actionLabel(detailLog.action) }}</div>
<div><span style="color: #909399;">状态</span>{{ detailLog.status }}</div>
<div><span style="color: #909399;">消息</span>{{ detailLog.message }}</div>
</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 v-if="detailLog.detail">
<div style="color: #909399; font-size: 13px; margin-bottom: 8px;">详细日志</div>
<pre style="background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; font-size: 12px; white-space: pre-wrap; word-break: break-all; margin: 0;">{{ detailLog.detail }}</pre>
</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>
</template>
</el-dialog>
</div>
</template>
@@ -85,6 +66,7 @@ import api from '../api'
const logs = ref([])
const statusFilter = ref('')
const detailLog = ref(null)
const showDialog = ref(false)
const formatTime = (iso) => {
if (!iso) return '-'
@@ -98,18 +80,18 @@ const actionLabel = (action) => ({
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 actionType = (action) => ({
issue: '',
renew: 'warning',
revoke: 'danger',
register: 'success',
}[action] || 'info')
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 statusType = (status) => ({
success: 'success',
failed: 'danger',
pending: 'warning',
}[status] || 'info')
const load = async () => {
const { data } = await api.get('/acme/logs', { params: { status: statusFilter.value || undefined } })
@@ -118,6 +100,7 @@ const load = async () => {
const showDetail = (log) => {
detailLog.value = log
showDialog.value = true
}
onMounted(load)
+189 -141
View File
@@ -1,174 +1,222 @@
<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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="font-size: 20px; font-weight: 700; margin: 0;">ACME 配置</h2>
<el-button type="primary" round :icon="Plus" @click="openCreate">新增配置</el-button>
</div>
<el-card>
<el-table :data="configs" stripe style="width: 100%;">
<el-table-column prop="name" label="名称" width="160">
<template #default="{ row }">
<span style="font-weight: 600;">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="acme_server" label="ACME 服务器" width="200">
<template #default="{ row }">
{{ row.acme_server.includes('staging') ? '测试' : '生产' }}
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" width="180" />
<el-table-column prop="dns_provider" label="DNS 提商" width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.dns_provider === 'aliyun' ? '阿里云' : row.dns_provider }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="renew_days" label="续签天数" width="100" />
<el-table-column label="关联域名" width="100">
<template #default="{ row }">{{ row.domain_count }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="warning" link size="small" :loading="renewingId === row.id" @click="autoRenew(row)">续签</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 续签结果 -->
<el-card v-if="renewResult" style="margin-top: 16px;">
<template #header>
<span style="font-weight: 600;">续签结果</span>
</template>
<div v-for="r in renewResult" :key="r.domain" style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
<el-icon :color="r.success ? '#67c23a' : '#f56c6c'" :size="16">
<component :is="r.success ? CircleCheckFilled : CircleCloseFilled" />
</el-icon>
<span style="font-weight: 600;">{{ r.domain }}</span>
<span style="color: #909399;">- {{ r.message }}</span>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑 ACME 配置' : '新增 ACME 配置'"
width="560px"
destroy-on-close
>
<el-form :model="form" label-width="120px">
<el-form-item label="配置名称">
<el-input v-model="form.name" placeholder="如:生产环境、测试环境" />
</el-form-item>
<el-form-item label="ACME 服务器">
<el-select v-model="form.acme_server" style="width: 100%;">
<el-option label="Let's Encrypt (生产)" value="https://acme-v02.api.letsencrypt.org/directory" />
<el-option label="Let's Encrypt (测试)" value="https://acme-staging-v02.api.letsencrypt.org/directory" />
</el-select>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="admin@example.com" />
</el-form-item>
<el-form-item label="DNS 提商">
<el-select v-model="form.dns_provider" style="width: 100%;">
<el-option label="阿里云 DNS" value="aliyun" />
<el-option label="Cloudflare" value="cloudflare" />
</el-select>
</el-form-item>
<el-form-item v-if="form.dns_provider === 'aliyun'" label="Access Key">
<el-input v-model="dnsCreds.access_key" placeholder="LTAI5t..." />
</el-form-item>
<el-form-item v-if="form.dns_provider === 'aliyun'" label="Access Secret">
<el-input v-model="dnsCreds.access_secret" type="password" placeholder="****" show-password />
</el-form-item>
<el-form-item v-if="form.dns_provider === 'cloudflare'" label="API Token">
<el-input v-model="dnsCreds.api_token" type="password" placeholder="****" show-password />
</el-form-item>
<el-form-item label="续签提前天数">
<el-input-number v-model="form.renew_days" :min="1" :max="90" />
</el-form-item>
</el-form>
<template #footer>
<el-button round @click="dialogVisible = false">取消</el-button>
<el-button type="primary" round :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted } from 'vue'
import { Plus, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import api from '../api'
import { useToast } from '../composables/useToast'
const toast = useToast()
const configs = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const saving = ref(false)
const renewingId = ref(null)
const renewResult = ref(null)
const form = ref({
const defaultForm = () => ({
name: '',
acme_server: 'https://acme-v02.api.letsencrypt.org/directory',
email: '',
dns_provider: 'aliyun',
renew_days: 30,
})
const form = ref(defaultForm())
const dnsCreds = ref({ access_key: '', access_secret: '', api_token: '' })
const creds = ref({
access_key: '',
access_secret: '',
api_token: '',
})
const load = async () => {
const { data } = await api.get('/acme/configs')
configs.value = data
}
const showSaved = ref(false)
const renewing = ref(false)
const renewResult = ref(null)
const openCreate = () => {
isEdit.value = false
editId.value = null
form.value = defaultForm()
dnsCreds.value = { access_key: '', access_secret: '', api_token: '' }
dialogVisible.value = true
}
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
// 解析已保存的凭据
const openEdit = async (row) => {
isEdit.value = true
editId.value = row.id
const { data } = await api.get(`/acme/configs/${row.id}`)
form.value = {
name: data.name,
acme_server: data.acme_server,
email: data.email,
dns_provider: data.dns_provider,
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,
dnsCreds.value = {
access_key: saved.access_key || '',
access_secret: saved.access_secret ? '********' : '',
api_token: saved.api_token ? '********' : '',
}
} catch {
dnsCreds.value = { access_key: '', access_secret: '', 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)
dialogVisible.value = true
}
const autoRenew = async () => {
renewing.value = true
const save = async () => {
saving.value = true
try {
// 构建凭据
let credentials = {}
if (form.value.dns_provider === 'aliyun') {
credentials = {
access_key: dnsCreds.value.access_key,
access_secret: dnsCreds.value.access_secret === '********' ? undefined : dnsCreds.value.access_secret,
}
} else if (form.value.dns_provider === 'cloudflare') {
credentials = {
api_token: dnsCreds.value.api_token === '********' ? undefined : dnsCreds.value.api_token,
}
}
Object.keys(credentials).forEach(k => credentials[k] === undefined && delete credentials[k])
const payload = { ...form.value, dns_credentials: JSON.stringify(credentials) }
if (isEdit.value) {
await api.put(`/acme/configs/${editId.value}`, payload)
} else {
await api.post('/acme/configs', payload)
}
ElMessage.success('保存成功')
dialogVisible.value = false
await load()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
const autoRenew = async (row) => {
renewingId.value = row.id
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))
ElMessage.error('续签失败: ' + (e.response?.data?.detail || e.message))
} finally {
renewing.value = false
renewingId.value = null
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确认删除配置 "${row.name}"\n关联的域名将解除绑定,但不会被删除。`,
'确认删除',
{ type: 'warning' }
)
await api.delete(`/acme/configs/${row.id}`)
await load()
} catch {}
}
onMounted(load)
</script>
+57 -82
View File
@@ -1,94 +1,69 @@
<template>
<div>
<h2 class="text-2xl font-bold mb-6">仪表盘</h2>
<h2 style="font-size: 20px; font-weight: 700; margin-bottom: 24px;">仪表盘</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>
<el-row :gutter="16" style="margin-bottom: 24px;">
<el-col :span="8">
<el-card shadow="hover">
<div style="display: flex; align-items: center; gap: 16px;">
<el-icon :size="40" color="#409eff"><Monitor /></el-icon>
<div>
<div style="color: #909399; font-size: 13px;">服务器</div>
<div style="font-size: 28px; font-weight: 700;">{{ stats.server_count }}</div>
</div>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">服务器</p>
<p class="text-2xl font-bold">{{ stats.server_count }}</p>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div style="display: flex; align-items: center; gap: 16px;">
<el-icon :size="40" color="#67c23a"><SetUp /></el-icon>
<div>
<div style="color: #909399; font-size: 13px;">域名</div>
<div style="font-size: 28px; font-weight: 700;">{{ stats.domain_count }}</div>
</div>
</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>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div style="display: flex; align-items: center; gap: 16px;">
<el-icon :size="40" color="#f56c6c"><WarningFilled /></el-icon>
<div>
<div style="color: #909399; font-size: 13px;">即将过期</div>
<div style="font-size: 28px; font-weight: 700;">{{ stats.expiring_count }}</div>
</div>
</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>
</el-card>
</el-col>
</el-row>
<!-- 最近日志 -->
<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>
<el-card>
<template #header>
<span style="font-weight: 600;">最近部署日志</span>
</template>
<el-table :data="stats.recent_logs" stripe style="width: 100%;">
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="server" label="服务器" width="140" />
<el-table-column prop="domain" label="域名" width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Monitor, SetUp, WarningFilled } from '@element-plus/icons-vue'
import { getStats } from '../api'
const stats = ref({
@@ -103,11 +78,11 @@ const formatTime = (iso) => {
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 statusType = (status) => ({
success: 'success',
failed: 'danger',
skipped: 'info',
}[status] || 'info')
onMounted(async () => {
const { data } = await getStats()
-114
View File
@@ -1,114 +0,0 @@
<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>
+243 -113
View File
@@ -1,149 +1,279 @@
<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 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>
<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>
<button @click="downloadCert(d, 'fullchain')" class="text-teal-600 hover:underline text-xs">下载证书</button>
<button @click="downloadCert(d, 'private')" class="text-teal-600 hover:underline text-xs">下载密钥</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>
<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="240" 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="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>
<!-- 复制成功提示 -->
<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>
<!-- 新增/编辑域名弹窗 -->
<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="680px"
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>
<p style="color: #909399; font-size: 13px; margin-bottom: 16px;">
将以下命令添加到服务器的定时任务中实现证书自动拉取与部署
</p>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<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>
<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>
<el-input
v-model="scriptContent"
type="textarea"
:rows="12"
readonly
style="font-family: 'Courier New', monospace;"
/>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getDomains, deleteDomain } from '../api'
import { Plus, ArrowDown, DocumentCopy, Download } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getDomains, createDomain, updateDomain, deleteDomain, getServers } from '../api'
import api from '../api'
import { useToast } from '../composables/useToast'
const toast = useToast()
const domains = ref([])
const showCopied = ref(false)
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 load = async () => {
const { data } = await getDomains()
domains.value = data.map(d => ({ ...d, _issuing: false }))
domains.value = data
}
const formatDate = (iso) => {
if (!iso) return '-'
return new Date(iso).toLocaleDateString('zh-CN')
const loadServers = async () => {
const { data } = await getServers()
serverList.value = data
}
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 loadAcmeConfigs = async () => {
const { data } = await api.get('/acme/configs')
acmeConfigs.value = data
}
const handleIssue = async (d) => {
if (!confirm(`确认为 "${d.domain}" 申请/续签证书?`)) return
d._issuing = true
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 {
const { data } = await api.post(`/acme/issue/${d.id}`)
data.success ? toast.success(`申请成功: ${data.message}`) : toast.error(`申请失败: ${data.message}`)
if (isEdit.value) {
await updateDomain(editId.value, domainForm.value)
} else {
await createDomain(domainForm.value)
}
ElMessage.success('保存成功')
formDialogVisible.value = false
await load()
} catch (e) {
toast.error('操作失败: ' + (e.response?.data?.detail || e.message))
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
d._issuing = false
saving.value = 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
const safeName = d.domain.replace(/^\*\./, '') // 泛域名去掉 *.
const encodedDomain = encodeURIComponent(d.domain)
let cmd
if (d.platform === 'windows') {
cmd = `Invoke-WebRequest "${baseUrl}/api/script?domain=${encodedDomain}&server=${encodeURIComponent(d.server_name)}" -OutFile C:\\scripts\\deploy-cert-${safeName}.ps1`
} else {
cmd = `curl -fsSL "${baseUrl}/api/script?domain=${encodedDomain}&server=${encodeURIComponent(d.server_name)}" -o /usr/local/bin/deploy-cert-${safeName}.sh && chmod +x /usr/local/bin/deploy-cert-${safeName}.sh`
// ---- 脚本弹窗 ----
const openScript = async (row) => {
if (!row.server_name) {
ElMessage.warning('该域名未关联服务器,请先编辑关联服务器')
return
}
await navigator.clipboard.writeText(cmd)
showCopied.value = true
setTimeout(() => { showCopied.value = false }, 2000)
scriptDomain.value = row
scriptOs.value = 'linux'
scriptContent.value = ''
scriptDialogVisible.value = true
await loadScript()
}
const downloadCert = (d, fileType) => {
const token = localStorage.getItem('admin_token')
const url = `/admin/api/cert-download/${d.id}/${fileType}`
// 用 fetch 下载,带上 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) return res.json().then(e => { throw new Error(e.detail || '下载失败') })
return res.blob()
const loadScript = async () => {
try {
const resp = await api.get('/script', {
params: {
domain: scriptDomain.value.domain,
server_name: scriptDomain.value.server_name,
os: scriptOs.value,
},
})
.then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = fileType === 'fullchain' ? 'fullchain.pem' : 'private.key'
a.click()
URL.revokeObjectURL(a.href)
})
.catch(e => toast.error(e.message))
scriptContent.value = resp.data.script
} catch (e) {
scriptContent.value = '# 加载失败: ' + (e.response?.data?.detail || e.message)
}
}
onMounted(load)
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 handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确认删除域名 "${row.domain}"`, '确认删除', { type: 'warning' })
await deleteDomain(row.id)
await load()
} catch {}
}
onMounted(() => {
load()
loadServers()
loadAcmeConfigs()
})
</script>
+22 -34
View File
@@ -1,43 +1,34 @@
<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 style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f7fa;">
<el-card style="width: 400px;" shadow="always">
<template #header>
<div style="text-align: center;">
<h2 style="margin: 0 0 4px 0; font-size: 22px;">CertCenter</h2>
<span style="color: #909399; font-size: 13px;">证书管理中心</span>
</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>
</template>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="username" placeholder="用户名" :prefix-icon="User" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="password" placeholder="密码" type="password" :prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" style="margin-bottom: 16px;" />
<el-button type="primary" round style="width: 100%;" size="large" :loading="loading" native-type="submit">
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { User, Lock } from '@element-plus/icons-vue'
import api from '../api'
const emit = defineEmits(['login-success'])
const username = ref('')
const password = ref('')
const error = ref('')
@@ -47,10 +38,7 @@ const handleLogin = async () => {
error.value = ''
loading.value = true
try {
const { data } = await api.post('/login', {
username: username.value,
password: password.value,
})
const { data } = await api.post('/login', { username: username.value, password: password.value })
if (data.ok) {
localStorage.setItem('admin_token', data.token)
emit('login-success')
+56 -43
View File
@@ -1,45 +1,51 @@
<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 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-select v-model="statusFilter" style="width: 160px;" @change="load">
<el-option label="全部状态" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="跳过" value="skipped" />
</el-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>
<el-card>
<el-table :data="logs" stripe style="width: 100%;">
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="server" label="服务器" width="140" />
<el-table-column prop="domain" label="域名" width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" show-overflow-tooltip />
<el-table-column label="详情" width="80" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showDialog" title="日志详情" width="520px">
<template v-if="detailLog">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="时间">{{ formatTime(detailLog.created_at) }}</el-descriptions-item>
<el-descriptions-item label="服务器">{{ detailLog.server || '-' }}</el-descriptions-item>
<el-descriptions-item label="域名">{{ detailLog.domain || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(detailLog.status)" size="small">{{ detailLog.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="消息">
<span style="white-space: pre-wrap; word-break: break-all;">{{ detailLog.message || '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
</div>
</template>
@@ -49,22 +55,29 @@ import { getLogs } from '../api'
const logs = ref([])
const statusFilter = ref('')
const detailLog = ref(null)
const showDialog = ref(false)
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 statusType = (status) => ({
success: 'success',
failed: 'danger',
skipped: 'info',
}[status] || 'info')
const load = async () => {
const { data } = await getLogs(statusFilter.value || undefined)
logs.value = data
}
const showDetail = (row) => {
detailLog.value = row
showDialog.value = true
}
onMounted(load)
</script>
-90
View File
@@ -1,90 +0,0 @@
<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 />
&nbsp;&nbsp;"{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" \<br />
&nbsp;&nbsp;-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 />
&nbsp;&nbsp;-Uri "{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" ` <br />
&nbsp;&nbsp;-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>
-93
View File
@@ -1,93 +0,0 @@
<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>
+109 -45
View File
@@ -1,66 +1,130 @@
<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 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>
<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>
<el-card>
<el-table :data="servers" stripe style="width: 100%;">
<el-table-column prop="name" label="名称" width="160">
<template #default="{ row }">
<span style="font-weight: 600;">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="{ row }">
<el-tag :type="row.platform === 'linux' ? 'warning' : ''" size="small">{{ row.platform }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="140">
<template #default="{ row }">{{ row.ip || '-' }}</template>
</el-table-column>
<el-table-column prop="domain_count" label="域名数" width="80" />
<el-table-column label="Token" width="180">
<template #default="{ row }">
<code style="background: #f5f7fa; padding: 2px 8px; border-radius: 4px; font-size: 12px;">{{ row.token.substring(0, 12) }}...</code>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑服务器弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑服务器' : '新增服务器'"
width="500px"
destroy-on-close
>
<el-form :model="form" label-width="100px">
<el-form-item label="服务器名称">
<el-input v-model="form.name" placeholder="web-01" />
</el-form-item>
<el-form-item label="平台">
<el-select v-model="form.platform" style="width: 100%;">
<el-option label="linux" value="linux" />
<el-option label="windows" value="windows" />
</el-select>
</el-form-item>
<el-form-item label="IP 地址">
<el-input v-model="form.ip" placeholder="192.168.1.100" />
</el-form-item>
</el-form>
<template #footer>
<el-button round @click="dialogVisible = false">取消</el-button>
<el-button type="primary" round :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getServers, deleteServer } from '../api'
import { Plus } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getServers, getServer, createServer, updateServer, deleteServer } from '../api'
const servers = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const editId = ref(null)
const form = ref({ name: '', platform: 'linux', ip: '' })
const load = async () => {
const { data } = await getServers()
servers.value = data
}
const openCreate = () => {
isEdit.value = false
editId.value = null
form.value = { name: '', platform: 'linux', ip: '' }
dialogVisible.value = true
}
const openEdit = async (server) => {
isEdit.value = true
editId.value = server.id
const { data } = await getServer(server.id)
form.value = { name: data.name, platform: data.platform, ip: data.ip || '' }
dialogVisible.value = true
}
const save = async () => {
saving.value = true
try {
if (isEdit.value) {
await updateServer(editId.value, form.value)
} else {
await createServer(form.value)
}
ElMessage.success('保存成功')
dialogVisible.value = false
await load()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
const handleDelete = async (server) => {
if (!confirm(`确认删除服务器 "${server.name}"\n\n该服务器下的所有域名配置也会被删除。`)) return
await deleteServer(server.id)
await load()
try {
await ElMessageBox.confirm(
`确认删除服务器 "${server.name}"\n该服务器下的所有域名配置也会被删除。`,
'确认删除',
{ type: 'warning' }
)
await deleteServer(server.id)
await load()
} catch {}
}
onMounted(load)
-11
View File
@@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ElementPlus from 'unplugin-element-plus/vite'
export default defineConfig({
plugins: [vue()],
plugins: [vue(), ElementPlus()],
server: {
port: 5173,
proxy: {