改用element
增加acme多配置
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 操作日志"""
|
||||
|
||||
+169
-50
@@ -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:
|
||||
# 返回默认配置
|
||||
def _serialize_acme_config(c: AcmeConfig) -> dict:
|
||||
return {
|
||||
"acme_server": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"email": "",
|
||||
"dns_provider": "aliyun",
|
||||
"dns_credentials": "{}",
|
||||
"renew_days": 30,
|
||||
"has_account_key": False,
|
||||
}
|
||||
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))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
config = 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:
|
||||
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
@@ -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
|
||||
|
||||
Generated
+531
-926
File diff suppressed because it is too large
Load Diff
@@ -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,6 +1,3 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {},
|
||||
}
|
||||
|
||||
+40
-42
@@ -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 v-if="!isLoggedIn">
|
||||
<Login @login-success="checkAuth" />
|
||||
</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'"
|
||||
<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;"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="p-8">
|
||||
</el-aside>
|
||||
<el-main style="background: #f5f7fa; padding: 24px; overflow-y: auto;">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</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 () => {
|
||||
|
||||
@@ -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)"
|
||||
>×</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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Global styles */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<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 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>
|
||||
</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)
|
||||
|
||||
+180
-132
@@ -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 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 续签结果 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<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 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 load = async () => {
|
||||
const { data } = await api.get('/acme/configs')
|
||||
configs.value = data
|
||||
}
|
||||
|
||||
const saveCredentials = async () => {
|
||||
const openCreate = () => {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
form.value = defaultForm()
|
||||
dnsCreds.value = { access_key: '', access_secret: '', api_token: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
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 || '{}')
|
||||
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: '' }
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 构建凭据
|
||||
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,
|
||||
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: creds.value.api_token === '********' ? undefined : creds.value.api_token,
|
||||
api_token: dnsCreds.value.api_token === '********' ? undefined : dnsCreds.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 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 () => {
|
||||
renewing.value = true
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">服务器</p>
|
||||
<p class="text-2xl font-bold">{{ stats.server_count }}</p>
|
||||
<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>
|
||||
<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>
|
||||
</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 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>
|
||||
</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>
|
||||
</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()
|
||||
|
||||
@@ -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>
|
||||
+242
-112
@@ -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) }}
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
已复制到剪贴板
|
||||
<!-- 新增/编辑域名弹窗 -->
|
||||
<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)
|
||||
// ---- 脚本弹窗 ----
|
||||
const openScript = async (row) => {
|
||||
if (!row.server_name) {
|
||||
ElMessage.warning('该域名未关联服务器,请先编辑关联服务器')
|
||||
return
|
||||
}
|
||||
scriptDomain.value = row
|
||||
scriptOs.value = 'linux'
|
||||
scriptContent.value = ''
|
||||
scriptDialogVisible.value = true
|
||||
await loadScript()
|
||||
}
|
||||
|
||||
const loadScript = async () => {
|
||||
try {
|
||||
const resp = await api.get('/script', {
|
||||
params: {
|
||||
domain: scriptDomain.value.domain,
|
||||
server_name: scriptDomain.value.server_name,
|
||||
os: scriptOs.value,
|
||||
},
|
||||
})
|
||||
scriptContent.value = resp.data.script
|
||||
} catch (e) {
|
||||
scriptContent.value = '# 加载失败: ' + (e.response?.data?.detail || e.message)
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
showCopied.value = true
|
||||
setTimeout(() => { showCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
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()
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadServers()
|
||||
loadAcmeConfigs()
|
||||
})
|
||||
.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))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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 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>
|
||||
</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
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
"{{ 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>
|
||||
@@ -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>
|
||||
+107
-43
@@ -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
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除服务器 "${server.name}"?\n该服务器下的所有域名配置也会被删除。`,
|
||||
'确认删除',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await deleteServer(server.id)
|
||||
await load()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user