feat: 平台预设字典+凭证加密+分库备份+Agent采集+移动端优先前端重做

This commit is contained in:
gouki
2026-08-01 23:10:31 +00:00
parent 1dac5d8735
commit e672086b67
31 changed files with 1792 additions and 421 deletions
+144
View File
@@ -0,0 +1,144 @@
"""VPS Agent 采集模块
采集本机资源、服务器信息与基础安全状态。
依赖 psutil + 少量系统命令(ufw/iptables/sshd_config)。
"""
import platform
import socket
import subprocess
import time
import psutil
def collect_metrics() -> dict:
"""资源使用率(CPU/内存/磁盘/网络/负载/运行时长)"""
cpu = psutil.cpu_percent(interval=1)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
net = psutil.net_io_counters()
try:
load_1m = psutil.getloadavg()[0]
except (AttributeError, OSError):
load_1m = None
return {
"cpu_pct": round(cpu, 1),
"mem_pct": round(mem.percent, 1),
"disk_pct": round(disk.percent, 1),
"net_in_mb": round(net.bytes_recv / 1024 / 1024, 2),
"net_out_mb": round(net.bytes_sent / 1024 / 1024, 2),
"load_1m": round(load_1m, 2) if load_1m is not None else None,
"uptime_sec": int(time.time() - psutil.boot_time()),
}
def _get_ip() -> str:
"""获取本机出口 IP(不实际发包)"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return ""
def collect_server_info() -> dict:
"""服务器基础信息"""
os_name = platform.system()
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip().strip('"')
break
except Exception:
pass
return {
"hostname": socket.gethostname(),
"os": os_name,
"kernel": platform.release(),
"cpu_cores": psutil.cpu_count(),
"mem_total_gb": round(psutil.virtual_memory().total / 1024**3, 1),
"disk_total_gb": round(psutil.disk_usage("/").total / 1024**3, 1),
"public_ip": _get_ip(),
"status": "online",
}
def _check_ssh_config() -> dict:
"""检查 SSH 配置(root 登录 / 密码登录)"""
path = "/etc/ssh/sshd_config"
permit_root = "unknown"
password_auth = "unknown"
try:
with open(path) as f:
for line in f:
low = line.strip().lower()
if low.startswith("permitrootlogin"):
parts = low.split()
permit_root = parts[1] if len(parts) > 1 else "unknown"
elif low.startswith("passwordauthentication"):
parts = low.split()
password_auth = parts[1] if len(parts) > 1 else "unknown"
except Exception:
return {"check_item": "ssh_config", "status": "unknown",
"detail": "无法读取 sshd_config", "suggestion": None}
status = "pass"
suggestions = []
if permit_root == "yes":
status = "warn"
suggestions.append("设置 PermitRootLogin no 或 prohibit-password")
if password_auth == "yes":
status = "warn"
suggestions.append("关闭密码登录 PasswordAuthentication no,改用密钥")
return {
"check_item": "ssh_config",
"status": status,
"detail": f"PermitRootLogin={permit_root}, PasswordAuthentication={password_auth}",
"suggestion": "".join(suggestions) if suggestions else None,
}
def _check_firewall() -> dict:
"""检查防火墙状态"""
try:
out = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
if "Status: active" in out.stdout:
return {"check_item": "firewall", "status": "pass",
"detail": "ufw 已启用", "suggestion": None}
return {"check_item": "firewall", "status": "warn",
"detail": "ufw 未启用", "suggestion": "启用防火墙 ufw enable"}
except Exception:
try:
out = subprocess.run(["iptables", "-L", "-n"], capture_output=True, text=True, timeout=5)
if out.stdout.strip():
return {"check_item": "firewall", "status": "pass",
"detail": "iptables 有规则", "suggestion": None}
except Exception:
pass
return {"check_item": "firewall", "status": "unknown",
"detail": "未检测到 ufw/iptables", "suggestion": None}
def _check_listening_ports() -> dict:
"""统计监听端口"""
try:
conns = psutil.net_connections(kind="inet")
listening = sorted({c.laddr.port for c in conns if c.status == "LISTEN"})
detail = f"开放端口 {len(listening)} 个: {','.join(map(str, listening[:15]))}"
status = "pass" if len(listening) < 10 else "warn"
suggestion = "端口偏多,建议关闭非必要端口" if status == "warn" else None
return {"check_item": "listening_ports", "status": status,
"detail": detail, "suggestion": suggestion}
except Exception as e: # noqa: BLE001
return {"check_item": "listening_ports", "status": "unknown",
"detail": str(e), "suggestion": None}
def collect_security() -> list:
"""基础安全检查项"""
return [_check_ssh_config(), _check_firewall(), _check_listening_ports()]
+47
View File
@@ -0,0 +1,47 @@
"""VPS Agent 主入口:采集本机信息并上报到中心服务器
环境变量(建议写入 /etc/vps-agent.env):
VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000
VPS_ASSET_ID 本机对应的资产 ID
VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填)
"""
import os
import sys
import httpx
from collector import collect_metrics, collect_security, collect_server_info
def main() -> int:
url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/")
asset_id = os.environ.get("VPS_ASSET_ID", "")
key = os.environ.get("VPS_AGENT_KEY", "")
if not url or not asset_id:
print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID")
return 1
payload = {
"asset_id": int(asset_id),
"metrics": collect_metrics(),
"server_info": collect_server_info(),
"security": collect_security(),
}
headers = {"X-Agent-Key": key} if key else {}
try:
resp = httpx.post(
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
)
print(f"上报完成 HTTP {resp.status_code}: {resp.text}")
resp.raise_for_status()
return 0
except Exception as e: # noqa: BLE001
print(f"上报失败: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+10
View File
@@ -0,0 +1,10 @@
# VPS Agent 环境变量示例(复制为 /etc/vps-agent.env
# 中心服务器地址(Tailscale 内网)
VPS_MANAGER_URL=http://100.89.0.11:8000
# 本机在资产管理系统中对应的资产 ID
VPS_ASSET_ID=1
# Agent Key(中心 .env 配置了 AGENT_KEY 时必填,需一致)
VPS_AGENT_KEY=
+13
View File
@@ -0,0 +1,13 @@
# VPS Agent 服务(oneshot,由 timer 触发)
# 部署位置:/etc/systemd/system/vps-agent.service
# 需先部署 collector.py + reporter.py 到 /opt/vps-agent/,并配置 /etc/vps-agent.env
[Unit]
Description=VPS Manager Agent (collect & report)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=-/etc/vps-agent.env
ExecStart=/usr/bin/python3 /opt/vps-agent/reporter.py
+13
View File
@@ -0,0 +1,13 @@
# VPS Agent 定时器(每 5 分钟采集上报一次)
# 部署位置:/etc/systemd/system/vps-agent.timer
[Unit]
Description=Run VPS Manager Agent every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Unit=vps-agent.service
[Install]
WantedBy=timers.target
+5
View File
@@ -17,7 +17,12 @@ class Settings:
"""应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
API_KEY: str = os.getenv("API_KEY", "")
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
# 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
MASTER_KEY: str = os.getenv("MASTER_KEY", "")
settings = Settings()
+40
View File
@@ -0,0 +1,40 @@
"""凭证加密模块(Fernet 对称加密)
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
MASTER_KEY 从 .env 读取,不入库。
"""
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def _get_fernet() -> Fernet:
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
key = settings.MASTER_KEY
if not key:
raise RuntimeError(
"MASTER_KEY 未配置,无法加解密凭证。请在 .env 设置 MASTER_KEY"
"生成命令:python -c 'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())'"
)
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(plain: Optional[str]) -> Optional[str]:
"""加密明文,返回 token 字符串;空值原样返回 None"""
if plain is None or plain == "":
return None
return _get_fernet().encrypt(plain.encode()).decode()
def decrypt(token: Optional[str]) -> Optional[str]:
"""解密 token,返回明文;空值或解密失败返回 None"""
if not token:
return None
try:
return _get_fernet().decrypt(token.encode()).decode()
except (InvalidToken, ValueError):
return None
+13
View File
@@ -23,3 +23,16 @@ async def require_api_key(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失",
)
async def require_agent_key(
x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"),
) -> None:
"""校验 Agent 上报 Key(可选启用)"""
if not settings.AGENT_KEY:
return
if x_agent_key != settings.AGENT_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent Key 无效或缺失",
)
+66
View File
@@ -0,0 +1,66 @@
"""平台预设种子数据
首次部署或调用 POST /api/providers/seed 时初始化常见平台。
每个平台有唯一 slug,供未来按 sdk_type 对接自动同步扩展。
"""
from sqlmodel import Session, select
from app.models.provider import Provider
PRESET_PROVIDERS = [
# ---- VPS / 云服务商 ----
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
"website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
"website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"},
{"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps",
"website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"},
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
# ---- 域名注册商 ----
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
# ---- AI 服务商 ----
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
"website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
{"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent",
"website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
{"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent",
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
"website": None, "console_url": None, "sdk_type": None},
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
# ---- Cloudflare ----
{"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare",
"website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
]
def seed_providers(session: Session) -> int:
"""初始化预设平台(已存在的 slug 跳过),返回新增数量"""
added = 0
for data in PRESET_PROVIDERS:
existing = session.exec(
select(Provider).where(Provider.slug == data["slug"])
).first()
if not existing:
session.add(Provider(**data))
added += 1
session.commit()
return added
+41 -8
View File
@@ -1,4 +1,8 @@
"""SQLite 数据库连接与初始化"""
"""SQLite 数据库连接与初始化(分库)
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount
- metrics.dbMetricPoint / ServerInfo / SecurityCheck / EventLog
"""
from pathlib import Path
from typing import Generator
@@ -9,20 +13,49 @@ BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_URL = f"sqlite:///{DATA_DIR / 'vps_manager.db'}"
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
engine = create_engine(DB_URL, echo=False, connect_args={"check_same_thread": False})
assets_engine = create_engine(
ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
)
metrics_engine = create_engine(
METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
)
# 兼容旧代码:默认 engine 指向资产库
engine = assets_engine
def init_db() -> None:
"""创建所有表(幂等,可重复调用)"""
# 显式导入模型,确保其注册到 SQLModel.metadata
"""分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401
from app.models.monitor import ( # noqa: F401
EventLog,
MetricPoint,
SecurityCheck,
ServerInfo,
)
from app.models.provider import Provider # noqa: F401
SQLModel.metadata.create_all(engine)
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all(
assets_engine, tables=[m.__table__ for m in asset_models]
)
SQLModel.metadata.create_all(
metrics_engine, tables=[m.__table__ for m in metric_models]
)
def get_session() -> Generator[Session, None, None]:
"""FastAPI 依赖:提供数据库会话(请求结束后自动关闭"""
with Session(engine) as session:
"""资产库会话(默认"""
with Session(assets_engine) as session:
yield session
def get_metrics_session() -> Generator[Session, None, None]:
"""监控 / 日志库会话"""
with Session(metrics_engine) as session:
yield session
+10 -3
View File
@@ -14,10 +14,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from sqlmodel import Session
from app.core.config import settings
from app.database import init_db
from app.routers import assets, stats
from app.core.seed import seed_providers
from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, providers, stats
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
@@ -30,8 +32,10 @@ jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
@asynccontextmanager
async def lifespan(_: FastAPI):
"""应用启动时自动建表"""
"""应用启动时自动建表并初始化预设平台"""
init_db()
with Session(assets_engine) as session:
seed_providers(session)
yield
@@ -45,7 +49,10 @@ app.add_middleware(
)
app.include_router(assets.router)
app.include_router(providers.router)
app.include_router(stats.router)
app.include_router(agent.router)
app.include_router(monitor.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+17 -1
View File
@@ -42,7 +42,13 @@ class Asset(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
asset_type: AssetType = Field(index=True, description="资产类型")
provider: str = Field(index=True, description="服务商,如 aliyun/vultr/namesilo/openai")
provider: str = Field(index=True, description="服务商 slug,如 aliyun/vultr/namesilo/openai")
provider_id: Optional[int] = Field(
default=None, foreign_key="providers.id", index=True, description="关联平台 ID"
)
renewal_cycle: Optional[str] = Field(
default=None, description="续费周期:monthly/quarterly/yearly"
)
account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
)
@@ -84,6 +90,13 @@ class VPSDetail(SQLModel, table=True):
bandwidth_gb: Optional[int] = Field(default=None, description="月流量配额(GB")
ssh_port: int = Field(default=22, description="SSH 端口")
panel_url: Optional[str] = Field(default=None, description="服务商管理面板 URL")
ssh_user: Optional[str] = Field(default=None, description="SSH 登录用户,如 root")
login_method: Optional[str] = Field(
default="key", description="登录方式:key/password/other"
)
ssh_key_encrypted: Optional[str] = Field(default=None, description="加密的 SSH 私钥")
password_encrypted: Optional[str] = Field(default=None, description="加密的登录密码")
purpose: Optional[str] = Field(default=None, description="用途 / 运行的项目")
class DomainDetail(SQLModel, table=True):
@@ -109,6 +122,9 @@ class DomainDetail(SQLModel, table=True):
redirect_target: Optional[str] = Field(
default=None, description="若为跳转用途域名,填写目标地址"
)
bind_asset_id: Optional[int] = Field(
default=None, index=True, description="绑定的资产 ID(如关联的 VPS/项目)"
)
class AIAccount(SQLModel, table=True):
+76
View File
@@ -0,0 +1,76 @@
"""监控与日志模型(存储于 metrics.db)
- MetricPoint: 资源监控时序数据(Agent 上报)
- ServerInfo: 服务器基础信息快照(Agent 上报)
- SecurityCheck: 安全检查项结果
- EventLog: 系统事件日志
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class MetricPoint(SQLModel, table=True):
"""资源监控时序数据"""
__tablename__ = "metric_points"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True, description="关联资产 ID")
ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间")
cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %")
net_in_mb: Optional[float] = Field(default=None, description="网络入流量 MB(采样间隔)")
net_out_mb: Optional[float] = Field(default=None, description="网络出流量 MB(采样间隔)")
load_1m: Optional[float] = Field(default=None, description="1 分钟负载")
uptime_sec: Optional[int] = Field(default=None, description="运行时长(秒)")
class ServerInfo(SQLModel, table=True):
"""服务器基础信息快照(每个资产一条,覆盖更新)"""
__tablename__ = "server_info"
id: Optional[int] = Field(default=None, primary_key=True)
# 逻辑关联 assets.id(跨库不设物理外键)
asset_id: int = Field(unique=True, index=True)
hostname: Optional[str] = Field(default=None)
os: Optional[str] = Field(default=None, description="操作系统发行版")
kernel: Optional[str] = Field(default=None, description="内核版本")
cpu_cores: Optional[int] = Field(default=None)
mem_total_gb: Optional[float] = Field(default=None)
disk_total_gb: Optional[float] = Field(default=None)
public_ip: Optional[str] = Field(default=None)
tailscale_ip: Optional[str] = Field(default=None)
status: Optional[str] = Field(default="online", description="online/offline")
last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间")
class SecurityCheck(SQLModel, table=True):
"""安全检查项结果"""
__tablename__ = "security_checks"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True)
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
status: str = Field(default="unknown", description="pass/warn/fail/unknown")
detail: Optional[str] = Field(default=None, description="检查详情")
suggestion: Optional[str] = Field(default=None, description="加固建议")
class EventLog(SQLModel, table=True):
"""系统事件日志"""
__tablename__ = "event_logs"
id: Optional[int] = Field(default=None, primary_key=True)
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
level: str = Field(default="info", index=True, description="info/warning/error")
source: str = Field(default="system", description="来源,如 agent/api/backup")
asset_id: Optional[int] = Field(default=None, index=True)
message: str = Field(description="事件内容")
+44
View File
@@ -0,0 +1,44 @@
"""平台/服务商注册表模型
Provider 是资产的归属平台预设字典,提供唯一标识(slug),
为未来按平台对接 SDK(自动同步 VPS/域名/AI 状态)打基础。
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
class ProviderCategory(str, Enum):
"""平台分类"""
VPS = "vps" # 云服务器
DOMAIN = "domain" # 域名注册商
AI_AGENT = "ai_agent" # AI 服务商
CLOUDFLARE = "cloudflare" # CloudflareDNS/Workers/R2/Tunnel/Mail
OTHER = "other" # 其他
class Provider(SQLModel, table=True):
"""平台/服务商(预设 + 自定义)"""
__tablename__ = "providers"
id: Optional[int] = Field(default=None, primary_key=True)
slug: str = Field(index=True, unique=True, description="唯一标识,如 aliyun/vultr/openai")
name: str = Field(index=True, description="显示名,如 阿里云")
name_en: Optional[str] = Field(default=None, description="英文名")
category: ProviderCategory = Field(index=True, description="平台分类")
website: Optional[str] = Field(default=None, description="官网")
console_url: Optional[str] = Field(default=None, description="管理面板 URL")
sdk_type: Optional[str] = Field(
default=None, description="SDK 类型标识,如 aliyun-sdk/vultr-api(供扩展用)"
)
api_config_encrypted: Optional[str] = Field(
default=None, description="加密的 API 配置 JSONaccess_key/secret 等)"
)
enabled: bool = Field(default=True, description="是否启用")
remark: Optional[str] = Field(default=None, description="备注")
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
+55
View File
@@ -0,0 +1,55 @@
"""Agent 上报接收路由(数据写入 metrics.db)"""
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.security import require_agent_key
from app.database import get_metrics_session
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
from app.schemas.agent import AgentReport
router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.post(
"/report",
summary="Agent 上报资源/信息/安全状态",
dependencies=[Depends(require_agent_key)],
)
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
# 资源监控时序
if data.metrics:
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
# 服务器信息快照(每资产一条,覆盖更新)
if data.server_info:
info = data.server_info.model_dump()
info["last_seen"] = datetime.utcnow()
existing = session.exec(
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
).first()
if existing:
for key, value in info.items():
setattr(existing, key, value)
session.add(existing)
else:
session.add(ServerInfo(asset_id=data.asset_id, **info))
# 安全检查项
if data.security:
for check in data.security:
session.add(SecurityCheck(asset_id=data.asset_id, **check.model_dump()))
# 事件日志
session.add(
EventLog(
level="info",
source="agent",
asset_id=data.asset_id,
message=f"Agent 上报 asset_id={data.asset_id}",
)
)
session.commit()
return {"status": "ok", "asset_id": data.asset_id}
+49
View File
@@ -0,0 +1,49 @@
"""监控数据查询路由(读取 metrics.db)"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.database import get_metrics_session
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
def metrics(
asset_id: int,
limit: int = Query(default=50, ge=1, le=500),
session: Session = Depends(get_metrics_session),
):
stmt = (
select(MetricPoint)
.where(MetricPoint.asset_id == asset_id)
.order_by(MetricPoint.ts.desc())
.limit(limit)
)
return session.exec(stmt).all()
@router.get("/{asset_id}/info", summary="服务器信息快照")
def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Optional[ServerInfo]:
return session.exec(
select(ServerInfo).where(ServerInfo.asset_id == asset_id)
).first()
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
stmt = (
select(SecurityCheck)
.where(SecurityCheck.asset_id == asset_id)
.order_by(SecurityCheck.ts.desc())
.limit(50)
)
checks = session.exec(stmt).all()
latest_by_item = {}
for check in checks:
if check.check_item not in latest_by_item:
latest_by_item[check.check_item] = check
return list(latest_by_item.values())
+72
View File
@@ -0,0 +1,72 @@
"""平台/服务商路由"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session
from app.core.security import require_api_key
from app.core.seed import seed_providers
from app.database import get_session
from app.models.provider import ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
from app.services import provider_service
router = APIRouter(prefix="/api/providers", tags=["providers"])
@router.post(
"/seed",
summary="初始化预设平台",
dependencies=[Depends(require_api_key)],
)
def seed(session: Session = Depends(get_session)) -> dict:
added = seed_providers(session)
return {"added": added}
@router.get("", response_model=List[ProviderRead], summary="平台列表")
def list_providers(
category: Optional[ProviderCategory] = Query(default=None),
enabled: Optional[bool] = Query(default=None),
session: Session = Depends(get_session),
):
return provider_service.list_providers(session, category, enabled)
@router.get("/{provider_id}", response_model=ProviderRead, summary="平台详情")
def get_provider(provider_id: int, session: Session = Depends(get_session)):
return provider_service.get_provider(session, provider_id)
@router.post(
"",
response_model=ProviderRead,
status_code=201,
summary="创建平台",
dependencies=[Depends(require_api_key)],
)
def create_provider(data: ProviderCreate, session: Session = Depends(get_session)):
return provider_service.create_provider(session, data)
@router.put(
"/{provider_id}",
response_model=ProviderRead,
summary="更新平台",
dependencies=[Depends(require_api_key)],
)
def update_provider(
provider_id: int, data: ProviderUpdate, session: Session = Depends(get_session)
):
return provider_service.update_provider(session, provider_id, data)
@router.delete(
"/{provider_id}",
status_code=204,
summary="删除平台",
dependencies=[Depends(require_api_key)],
)
def delete_provider(provider_id: int, session: Session = Depends(get_session)):
provider_service.delete_provider(session, provider_id)
+43
View File
@@ -0,0 +1,43 @@
"""Agent 上报数据 Schema"""
from typing import List, Optional
from sqlmodel import SQLModel
class MetricsIn(SQLModel):
cpu_pct: Optional[float] = None
mem_pct: Optional[float] = None
disk_pct: Optional[float] = None
net_in_mb: Optional[float] = None
net_out_mb: Optional[float] = None
load_1m: Optional[float] = None
uptime_sec: Optional[int] = None
class ServerInfoIn(SQLModel):
hostname: Optional[str] = None
os: Optional[str] = None
kernel: Optional[str] = None
cpu_cores: Optional[int] = None
mem_total_gb: Optional[float] = None
disk_total_gb: Optional[float] = None
public_ip: Optional[str] = None
tailscale_ip: Optional[str] = None
status: Optional[str] = "online"
class SecurityCheckIn(SQLModel):
check_item: str
status: str = "unknown" # pass/warn/fail/unknown
detail: Optional[str] = None
suggestion: Optional[str] = None
class AgentReport(SQLModel):
"""Agent 单次上报的完整数据包"""
asset_id: int
metrics: Optional[MetricsIn] = None
server_info: Optional[ServerInfoIn] = None
security: Optional[List[SecurityCheckIn]] = None
+29 -1
View File
@@ -30,6 +30,11 @@ class VPSDetailIn(SQLModel):
bandwidth_gb: Optional[int] = None
ssh_port: int = 22
panel_url: Optional[str] = None
ssh_user: Optional[str] = None
login_method: Optional[str] = "key"
ssh_key: Optional[str] = None # 明文输入,服务端加密存储
password: Optional[str] = None # 明文输入,服务端加密存储
purpose: Optional[str] = None
class DomainDetailIn(SQLModel):
@@ -41,6 +46,7 @@ class DomainDetailIn(SQLModel):
cloudflare_account: Optional[str] = None
is_using: bool = True
redirect_target: Optional[str] = None
bind_asset_id: Optional[int] = None
class AIAccountIn(SQLModel):
@@ -64,6 +70,8 @@ class AssetBase(SQLModel):
name: str
asset_type: AssetType
provider: str
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: bool = False
@@ -88,6 +96,8 @@ class AssetUpdate(SQLModel):
name: Optional[str] = None
asset_type: Optional[AssetType] = None
provider: Optional[str] = None
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None
@@ -104,9 +114,26 @@ class AssetUpdate(SQLModel):
# --------------------------------------------------------------------------- #
# 输出模型
# --------------------------------------------------------------------------- #
class VPSDetailRead(VPSDetailIn):
class VPSDetailRead(SQLModel):
"""VPS 详情输出(不含敏感明文,仅返回凭证存在标记)"""
id: int
asset_id: int
ip_address: str
tailscale_ip: Optional[str] = None
region: Optional[str] = None
os: Optional[str] = None
cpu_cores: int = 1
memory_gb: float = 1
disk_gb: int = 20
bandwidth_gb: Optional[int] = None
ssh_port: int = 22
panel_url: Optional[str] = None
ssh_user: Optional[str] = None
login_method: Optional[str] = "key"
purpose: Optional[str] = None
has_ssh_key: bool = False
has_password: bool = False
class DomainDetailRead(DomainDetailIn):
@@ -127,6 +154,7 @@ class AssetRead(AssetBase):
created_at: datetime
updated_at: datetime
days_to_expiry: Optional[int] = None
provider_name: Optional[str] = None
vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None
+45
View File
@@ -0,0 +1,45 @@
"""平台/服务商 Schema"""
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel
from app.models.provider import ProviderCategory
class ProviderBase(SQLModel):
slug: str
name: str
name_en: Optional[str] = None
category: ProviderCategory
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
enabled: bool = True
remark: Optional[str] = None
class ProviderCreate(ProviderBase):
"""创建平台;api_config 为明文 JSON 字符串,服务端加密存储"""
api_config: Optional[str] = None
class ProviderUpdate(SQLModel):
slug: Optional[str] = None
name: Optional[str] = None
name_en: Optional[str] = None
category: Optional[ProviderCategory] = None
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
enabled: Optional[bool] = None
remark: Optional[str] = None
api_config: Optional[str] = None
class ProviderRead(ProviderBase):
id: int
created_at: datetime
has_api_config: bool = False
+64 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import (
AIAccount,
Asset,
@@ -17,6 +18,7 @@ from app.models.asset import (
DomainDetail,
VPSDetail,
)
from app.models.provider import Provider
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
@@ -56,19 +58,69 @@ def _get_detail(session: Session, asset: Asset):
return session.exec(select(model).where(model.asset_id == asset.id)).first()
def _to_read(asset: Asset, detail) -> AssetRead:
def _detail_to_read(detail):
"""详情模型转输出模型(VPS 计算凭证存在标记,不输出敏感明文)"""
if isinstance(detail, VPSDetail):
read = VPSDetailRead.model_validate(detail)
read.has_ssh_key = bool(detail.ssh_key_encrypted)
read.has_password = bool(detail.password_encrypted)
return read
if isinstance(detail, DomainDetail):
return DomainDetailRead.model_validate(detail)
if isinstance(detail, AIAccount):
return AIAccountRead.model_validate(detail)
return None
def _provider_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联平台显示名"""
if asset.provider_id:
provider = session.get(Provider, asset.provider_id)
if provider:
return provider.name
return None
def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = _provider_name(session, asset)
if isinstance(detail, VPSDetail):
read.vps_detail = VPSDetailRead.model_validate(detail)
read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail):
read.domain_detail = DomainDetailRead.model_validate(detail)
read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount):
read.ai_detail = AIAccountRead.model_validate(detail)
read.ai_detail = _detail_to_read(detail)
return read
def _build_detail(asset_type: AssetType, asset_id: int, detail_in):
"""构建详情模型(VPS 加密凭证)"""
_, model = DETAIL_MAP[asset_type]
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
data["ssh_key_encrypted"] = crypto.encrypt(detail_in.ssh_key)
data["password_encrypted"] = crypto.encrypt(detail_in.password)
return model(asset_id=asset_id, **data)
return model(asset_id=asset_id, **detail_in.model_dump())
def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
"""更新详情字段(VPS 加密凭证;凭证为 None 时保留原值)"""
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
for key, value in data.items():
setattr(existing, key, value)
if detail_in.ssh_key is not None:
existing.ssh_key_encrypted = crypto.encrypt(detail_in.ssh_key)
if detail_in.password is not None:
existing.password_encrypted = crypto.encrypt(detail_in.password)
else:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@@ -96,12 +148,12 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
detail = None
if model is not None and detail_in is not None:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
session.commit()
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def get_asset(session: Session, asset_id: int) -> AssetRead:
@@ -109,7 +161,7 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
return _to_read(asset, _get_detail(session, asset))
return _to_read(session, asset, _get_detail(session, asset))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
@@ -136,12 +188,11 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
).first()
if detail_in is not None:
if existing:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
_apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
detail = existing
else:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
else:
detail = existing
@@ -150,7 +201,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
session.refresh(asset)
if detail is not None:
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def delete_asset(session: Session, asset_id: int) -> None:
@@ -190,7 +241,7 @@ def list_assets(
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all()
return [_to_read(a, _get_detail(session, a)) for a in assets]
return [_to_read(session, a, _get_detail(session, a)) for a in assets]
# --------------------------------------------------------------------------- #
@@ -241,6 +292,6 @@ def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
result.append(_to_read(a, _get_detail(session, a)))
result.append(_to_read(session, a, _get_detail(session, a)))
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result
+85
View File
@@ -0,0 +1,85 @@
"""平台/服务商业务逻辑"""
from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.provider import Provider, ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
def _to_read(provider: Provider) -> ProviderRead:
read = ProviderRead.model_validate(provider)
read.has_api_config = bool(provider.api_config_encrypted)
return read
def list_providers(
session: Session,
category: Optional[ProviderCategory] = None,
enabled: Optional[bool] = None,
) -> List[ProviderRead]:
stmt = select(Provider)
if category is not None:
stmt = stmt.where(Provider.category == category)
if enabled is not None:
stmt = stmt.where(Provider.enabled == enabled)
stmt = stmt.order_by(Provider.category.asc(), Provider.slug.asc())
return [_to_read(p) for p in session.exec(stmt).all()]
def get_provider(session: Session, provider_id: int) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return _to_read(provider)
def create_provider(session: Session, data: ProviderCreate) -> ProviderRead:
exists = session.exec(select(Provider).where(Provider.slug == data.slug)).first()
if exists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"slug 已存在:{data.slug}"
)
provider_data = data.model_dump(exclude={"api_config"})
provider = Provider(**provider_data)
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def update_provider(
session: Session, provider_id: int, data: ProviderUpdate
) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
fields = data.model_dump(exclude_unset=True, exclude={"api_config"})
for key, value in fields.items():
setattr(provider, key, value)
if data.api_config is not None:
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def delete_provider(session: Session, provider_id: int) -> None:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
session.delete(provider)
session.commit()
def get_api_config_plain(session: Session, provider_id: int) -> Optional[str]:
"""获取平台 API 配置明文(受保护接口使用)"""
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return crypto.decrypt(provider.api_config_encrypted)
+12 -252
View File
@@ -2,262 +2,22 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ app_name }}</title>
<script src="/static/js/tailwind.js"></script>
<script>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
<style>
html { -webkit-tap-highlight-color: transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(100,116,139,.3); border-radius: 4px; }
[v-cloak] { display: none; }
</style>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen">
<div id="app" class="max-w-7xl mx-auto px-4 py-6">
{% raw %}
<!-- 顶部栏 -->
<header class="flex flex-wrap items-center justify-between gap-3 mb-6">
<h1 class="text-2xl font-bold text-slate-900">📦 {{ appName }}</h1>
<div class="flex items-center gap-3">
<input v-model="search" @input="loadAssets" placeholder="搜索名称 / 服务商…"
class="px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm w-56">
<button @click="openCreate"
class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition">+ 新增资产</button>
</div>
</header>
<!-- 全局错误提示 -->
<div v-if="error" class="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm flex justify-between">
<span>⚠️ {{ error }}</span>
<button @click="error = ''" class="font-bold"></button>
</div>
<!-- Dashboard 卡片 -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="text-sm text-slate-500">总资产</div>
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.total || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-red-500">
<div class="text-sm text-slate-500">30 天内到期</div>
<div class="text-3xl font-bold text-red-600 mt-1">{{ overview.expiring_30 || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="text-sm text-slate-500">本年支出</div>
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.year_cost || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-amber-500">
<div class="text-sm text-slate-500">异常状态</div>
<div class="text-3xl font-bold text-amber-600 mt-1">{{ overview.abnormal_count || 0 }}</div>
</div>
</section>
<!-- 续费倒计时 -->
<section v-if="expiring.length" class="bg-white rounded-xl p-4 shadow-sm mb-6">
<h2 class="font-semibold text-slate-900 mb-3">⏰ 续费倒计时(30 天内)</h2>
<div class="space-y-2">
<div v-for="a in expiring" :key="a.id"
class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50">
<span class="text-sm">
<span class="font-medium">{{ a.name }}</span>
<span class="text-slate-400"> · {{ a.provider }}</span>
</span>
<span class="text-sm px-2 py-0.5 rounded-full" :class="expiryBadge(a.days_to_expiry)">
{{ a.days_to_expiry }} 天
</span>
</div>
</div>
</section>
<!-- 筛选 + 资产表格 -->
<section class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="p-4 flex flex-wrap gap-2 border-b border-slate-100">
<select v-model="filterType" @change="loadAssets"
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
<option value="">全部类型</option>
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
</select>
<select v-model="filterStatus" @change="loadAssets"
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
<option value="">全部状态</option>
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
</select>
<span class="ml-auto text-sm text-slate-400 self-center">共 {{ assets.length }} 条</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-left">
<tr>
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">服务商</th>
<th class="px-4 py-3 font-medium">到期时间</th>
<th class="px-4 py-3 font-medium">剩余</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="!assets.length">
<td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产,点击右上角「新增资产」开始录入</td>
</tr>
<tr v-for="a in assets" :key="a.id" class="hover:bg-slate-50" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-3">
<span class="font-medium text-slate-900">{{ a.name }}</span>
<span v-if="a.is_archived" class="ml-1 text-xs text-slate-400">(已归档)</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs" :class="typeBadge(a.asset_type)">{{ typeLabels[a.asset_type] }}</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ a.provider }}</td>
<td class="px-4 py-3 text-slate-600">{{ a.expiry_date || '—' }}</td>
<td class="px-4 py-3" :class="expiryText(a.days_to_expiry)">
{{ a.days_to_expiry === null || a.days_to_expiry === undefined ? '—' : a.days_to_expiry + '天' }}
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs" :class="statusBadge(a.status)">{{ statusLabels[a.status] }}</span>
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button @click="openEdit(a)" class="text-blue-600 hover:underline mr-3">编辑</button>
<button @click="deleteAsset(a)" class="text-red-600 hover:underline">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 新增 / 编辑模态框 -->
<div v-if="showModal" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-xl">
<div class="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-900">{{ editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600"></button>
</div>
<form @submit.prevent="saveAsset" class="px-6 py-4 space-y-4">
<!-- 主表字段 -->
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block">
<span class="text-sm text-slate-600">名称 *</span>
<input v-model="form.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">类型 *</span>
<select v-model="form.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-600">服务商 *</span>
<input v-model="form.provider" required placeholder="aliyun / vultr / namesilo" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">所属账号</span>
<input v-model="form.account" placeholder="如 Cloudflare 邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">到期时间</span>
<input type="date" v-model="form.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">续费费用</span>
<input type="number" step="0.01" v-model.number="form.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">币种</span>
<select v-model="form.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-600">状态</span>
<select v-model="form.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<div class="col-span-2 flex gap-6 text-sm text-slate-600">
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.is_archived"> 已归档(不再使用)</label>
</div>
<label class="col-span-2 block">
<span class="text-sm text-slate-600">备注</span>
<textarea v-model="form.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm"></textarea>
</label>
</div>
<!-- VPS 详情 -->
<fieldset v-if="form.asset_type === 'vps'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">公网 IP *</span>
<input v-model="form.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">Tailscale IP</span>
<input v-model="form.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">区域</span>
<input v-model="form.vps_detail.region" placeholder="Japan / US West" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">操作系统</span>
<input v-model="form.vps_detail.os" placeholder="Debian 12" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">CPU 核数</span>
<input type="number" v-model.number="form.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">内存 (GB)</span>
<input type="number" step="0.5" v-model.number="form.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">磁盘 (GB)</span>
<input type="number" v-model.number="form.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">月流量 (GB)</span>
<input type="number" v-model.number="form.vps_detail.bandwidth_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">SSH 端口</span>
<input type="number" v-model.number="form.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">面板 URL</span>
<input v-model="form.vps_detail.panel_url" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
</div>
</fieldset>
<!-- 域名详情 -->
<fieldset v-if="form.asset_type === 'domain'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block"><span class="text-xs text-slate-500">域名 *</span>
<input v-model="form.domain_detail.domain_name" required placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">注册商</span>
<input v-model="form.domain_detail.registrar" placeholder="namesilo / 新网" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">DNS 托管</span>
<input v-model="form.domain_detail.dns_provider" placeholder="cloudflare" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">Cloudflare 账号</span>
<input v-model="form.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">跳转目标</span>
<input v-model="form.domain_detail.redirect_target" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" v-model="form.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<!-- AI 账号详情 -->
<fieldset v-if="form.asset_type === 'ai_agent'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">服务商 *</span>
<input v-model="form.ai_detail.provider" required placeholder="openai / kimi / deepseek" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">订阅计划</span>
<input v-model="form.ai_detail.plan" placeholder="ChatGPT Plus" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">API Key</span>
<input v-model="form.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">当前余额</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">币种</span>
<input v-model="form.ai_detail.currency" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">本月用量</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_usage" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">月度限额</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
</div>
</fieldset>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="showModal = false" class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-600 hover:bg-slate-50">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">{{ editing ? '保存修改' : '创建资产' }}</button>
</div>
</form>
</div>
</div>
{% endraw %}
</div>
<script src="/static/js/app.js"></script>
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
<div id="app"></div>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# 数据库分库备份脚本
# 用法:backup.sh [assets|metrics|all]
# assets -> 备份 assets.db,保留 14 份(建议每日)
# metrics -> 备份 metrics.db,保留 4 份(建议每周)
# all -> 两者都备份
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/vps-manager}"
cd "$APP_DIR"
TARGET="${1:-all}"
python3 - "$TARGET" <<'PYEOF'
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
target = sys.argv[1]
data_dir = Path("data")
backup_root = data_dir / "backups"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
def backup(db_name: str, keep: int) -> None:
src = data_dir / db_name
stem = db_name.replace(".db", "")
if not src.exists():
print(f"[skip] {db_name} 不存在")
return
out_dir = backup_root / stem
out_dir.mkdir(parents=True, exist_ok=True)
dst = out_dir / f"{stem}_{ts}.db"
con = sqlite3.connect(str(src))
bkp = sqlite3.connect(str(dst))
con.backup(bkp)
bkp.close()
con.close()
files = sorted(
out_dir.glob(f"{stem}_*.db"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for old in files[keep:]:
old.unlink()
print(f"[ok] {db_name} -> {dst}(保留 {keep} 份)")
if target in ("assets", "all"):
backup("assets.db", 14)
if target in ("metrics", "all"):
backup("metrics.db", 4)
PYEOF
+10
View File
@@ -0,0 +1,10 @@
# 资产库备份服务(oneshot,由 timer 触发)
# 部署位置:/etc/systemd/system/vps-backup-assets.service
[Unit]
Description=Backup vps-manager assets.db
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/vps-manager/deploy/backup.sh assets
+13
View File
@@ -0,0 +1,13 @@
# 资产库每日备份定时器
# 部署位置:/etc/systemd/system/vps-backup-assets.timer
[Unit]
Description=Daily backup of vps-manager assets.db
[Timer]
OnCalendar=daily
Persistent=true
Unit=vps-backup-assets.service
[Install]
WantedBy=timers.target
+10
View File
@@ -0,0 +1,10 @@
# 日志/监控库备份服务(oneshot,由 timer 触发)
# 部署位置:/etc/systemd/system/vps-backup-metrics.service
[Unit]
Description=Backup vps-manager metrics.db
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/vps-manager/deploy/backup.sh metrics
+13
View File
@@ -0,0 +1,13 @@
# 日志/监控库每周备份定时器
# 部署位置:/etc/systemd/system/vps-backup-metrics.timer
[Unit]
Description=Weekly backup of vps-manager metrics.db
[Timer]
OnCalendar=weekly
Persistent=true
Unit=vps-backup-metrics.service
[Install]
WantedBy=timers.target
+3
View File
@@ -4,3 +4,6 @@ sqlmodel>=0.0.22
pydantic>=2.7
python-dotenv>=1.0
jinja2>=3.1
cryptography>=42
psutil>=5.9
httpx>=0.27
+57
View File
@@ -0,0 +1,57 @@
/* API 客户端与格式化工具 */
window.VpsApi = (function () {
const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
function getApiKey() { return localStorage.getItem('vps_api_key') || ''; }
function setApiKey(k) { localStorage.setItem('vps_api_key', k || ''); }
async function request(path, options = {}) {
const headers = { 'Content-Type': 'application/json' };
const key = getApiKey();
if (key) headers['X-API-Key'] = key;
const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
return {
CFG, getApiKey, setApiKey,
get: (p) => request(p),
post: (p, body) => request(p, { method: 'POST', body: JSON.stringify(body) }),
put: (p, body) => request(p, { method: 'PUT', body: JSON.stringify(body) }),
del: (p) => request(p, { method: 'DELETE' }),
};
})();
window.VpsFmt = {
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
typeBadge(t) {
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
statusBadge(s) {
return { active: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', expired: 'bg-red-500/10 text-red-600 dark:text-red-400', stopped: 'bg-slate-500/10 text-slate-500', cancelled: 'bg-slate-500/10 text-slate-400', unknown: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[s] || 'bg-slate-500/10 text-slate-500';
},
expiryText(d) {
if (d === null || d === undefined) return 'text-slate-400';
if (d < 0) return 'text-red-600 dark:text-red-400 font-semibold';
if (d <= 7) return 'text-red-600 dark:text-red-400 font-semibold';
if (d <= 30) return 'text-amber-600 dark:text-amber-400';
return 'text-slate-500 dark:text-slate-400';
},
expiryBadge(d) {
if (d !== null && d !== undefined && d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
if (d !== null && d !== undefined && d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
return 'bg-slate-500/10 text-slate-500';
},
checkBadge(s) {
return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500';
},
};
+637 -143
View File
@@ -1,157 +1,651 @@
/* vps-manager 前端逻辑Vue 3 全局构建 */
const { createApp, ref, reactive, onMounted } = Vue;
/* vps-manager 前端应用Vue 3 组件化 */
const { createApp, reactive, ref, computed, onMounted, watch } = Vue;
const Api = window.VpsApi;
const Fmt = window.VpsFmt;
const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
// 若服务端 .env 配置了 API_KEY,请在此填写以通过写操作鉴权
const API_KEY = '';
/* ---------------- 全局状态 ---------------- */
const store = reactive({
view: 'dashboard',
assets: [],
providers: [],
overview: {},
expiring: [],
serverMetrics: {},
search: '',
filterType: '',
filterStatus: '',
dark: localStorage.getItem('vps_dark') === '1',
loading: false,
error: '',
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
});
const TYPE_LABELS = { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' };
const STATUS_LABELS = { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' };
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['X-API-Key'] = API_KEY;
const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
function applyDark() {
document.documentElement.classList.toggle('dark', store.dark);
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
}
function emptyForm() {
/* ---------------- 表单工厂 ---------------- */
function emptyAssetForm() {
return {
name: '', asset_type: 'vps', provider: '', account: '', expiry_date: '',
auto_renew: false, cost: 0, currency: 'USD', status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '' },
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly',
account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
};
}
// 空字符串转 null(可选字段),保留数字与布尔
function cleanDetail(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (v === '' ? null : v);
function buildAssetPayload(f) {
const p = {
name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
account: f.account || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
};
if (f.asset_type === 'vps') {
const v = f.vps_detail;
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
} else if (f.asset_type === 'domain') {
const d = f.domain_detail;
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
} else if (f.asset_type === 'ai_agent') {
const a = f.ai_detail;
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
}
return out;
return p;
}
createApp({
/* ---------------- 数据加载 ---------------- */
async function loadAssets() {
const params = new URLSearchParams();
if (store.filterType) params.set('asset_type', store.filterType);
if (store.filterStatus) params.set('status', store.filterStatus);
if (store.search) params.set('q', store.search);
params.set('sort', 'expiry_date'); params.set('order', 'asc');
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
/* ---------------- 资产操作 ---------------- */
function openAssetCreate(presetType) {
const form = emptyAssetForm();
if (presetType) form.asset_type = presetType;
store.assetModal = { show: true, editing: null, form };
}
function openAssetEdit(a) {
const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
store.assetModal = { show: true, editing: a.id, form };
}
async function saveAsset() {
store.error = '';
const f = store.assetModal.form;
try {
const payload = buildAssetPayload(f);
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
else await Api.post('/assets', payload);
store.assetModal.show = false;
await loadAll();
} catch (e) { store.error = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 平台操作 ---------------- */
function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } };
}
function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } };
}
async function saveProvider() {
store.error = '';
const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null };
try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload);
store.providerModal.show = false;
await loadProviders();
} catch (e) { store.error = e.message; }
}
async function seedProviders() {
store.error = '';
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
if (!confirm('确认删除平台「' + p.name + '」?')) return;
try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 服务器监控 ---------------- */
async function viewServer(a) {
store.view = 'servers';
try {
const [metrics, info, checks] = await Promise.all([
Api.get('/monitor/' + a.id + '/metrics'),
Api.get('/monitor/' + a.id + '/info'),
Api.get('/monitor/' + a.id + '/security'),
]);
store.serverMetrics = { asset: a, metrics, info, checks };
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [] }; }
}
/* ---------------- 设置 ---------------- */
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
/* ================= 视图组件 ================= */
const DashboardView = {
template: `
<div class="space-y-5">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">总资产</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50">
<div class="text-xs text-slate-500">异常状态</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ store.overview.abnormal_count || 0 }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
<h2 class="text-sm font-semibold mb-3">续费倒计时(30天内)</h2>
<div class="space-y-2">
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<div class="min-w-0">
<span class="font-medium text-sm truncate">{{ a.name }}</span>
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
<div class="flex flex-wrap gap-2">
<span v-for="(n, t) in store.overview.by_type" :key="t" class="text-xs px-3 py-1.5 rounded-lg" :class="Fmt.typeBadge(t)">{{ Fmt.TYPE_LABELS[t] }} {{ n }}</span>
<span v-if="!store.overview.by_type || !Object.keys(store.overview.by_type).length" class="text-xs text-slate-400">暂无数据</span>
</div>
</div>
</div>`,
setup() { return { store, Fmt }; },
};
const AssetsView = {
template: `
<div class="space-y-3">
<div class="flex flex-wrap gap-2 items-center">
<select v-model="store.filterType" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部类型</option>
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部状态</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<span class="ml-auto text-xs text-slate-400">共 {{ store.assets.length }} 条</span>
</div>
<!-- 桌面表格 -->
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">名称</th>
<th class="px-4 py-2.5 font-medium">类型</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium">状态</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!store.assets.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
<tr v-for="a in store.assets" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!store.assets.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无资产</div>
<div v-for="a in store.assets" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
<div class="flex justify-between items-start">
<div class="min-w-0">
<div class="font-medium text-sm truncate">{{ a.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ a.days_to_expiry }}天)</span></span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>`,
setup() {
const appName = CFG.appName;
const assets = ref([]);
const overview = ref({});
const expiring = ref([]);
const loading = ref(false);
const search = ref('');
const filterType = ref('');
const filterStatus = ref('');
const showModal = ref(false);
const editing = ref(null);
const form = reactive(emptyForm());
const error = ref('');
const typeLabels = TYPE_LABELS;
const statusLabels = STATUS_LABELS;
async function loadAssets() {
loading.value = true;
try {
const params = new URLSearchParams();
if (filterType.value) params.set('asset_type', filterType.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (search.value) params.set('q', search.value);
params.set('sort', 'expiry_date');
params.set('order', 'asc');
assets.value = await api('/assets?' + params.toString());
} catch (e) { error.value = e.message; } finally { loading.value = false; }
}
async function loadOverview() {
try { overview.value = await api('/stats/overview'); } catch (e) { error.value = e.message; }
}
async function loadExpiring() {
try { expiring.value = await api('/stats/expiring?days=30'); } catch (e) { error.value = e.message; }
}
async function loadAll() { await Promise.all([loadAssets(), loadOverview(), loadExpiring()]); }
function resetForm() { Object.assign(form, emptyForm()); }
function openCreate() { resetForm(); editing.value = null; showModal.value = true; }
function openEdit(a) {
resetForm();
editing.value = a.id;
['name', 'asset_type', 'provider', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark']
.forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
showModal.value = true;
}
function buildPayload() {
const p = {
name: form.name, asset_type: form.asset_type, provider: form.provider,
account: form.account || null, expiry_date: form.expiry_date || null,
auto_renew: !!form.auto_renew, cost: Number(form.cost) || 0, currency: form.currency,
status: form.status, is_archived: !!form.is_archived, remark: form.remark || null,
};
if (form.asset_type === 'vps') p.vps_detail = cleanDetail(form.vps_detail);
else if (form.asset_type === 'domain') p.domain_detail = cleanDetail(form.domain_detail);
else if (form.asset_type === 'ai_agent') p.ai_detail = cleanDetail(form.ai_detail);
return p;
}
async function saveAsset() {
error.value = '';
try {
const payload = buildPayload();
if (editing.value) {
await api('/assets/' + editing.value, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await api('/assets', { method: 'POST', body: JSON.stringify(payload) });
}
showModal.value = false;
await loadAll();
} catch (e) { error.value = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
error.value = '';
try { await api('/assets/' + a.id, { method: 'DELETE' }); await loadAll(); }
catch (e) { error.value = e.message; }
}
// 样式辅助
function typeBadge(t) {
return { vps: 'bg-blue-100 text-blue-700', domain: 'bg-green-100 text-green-700', ai_agent: 'bg-purple-100 text-purple-700', cloudflare: 'bg-orange-100 text-orange-700', other: 'bg-slate-100 text-slate-600' }[t] || 'bg-slate-100 text-slate-600';
}
function statusBadge(s) {
return { active: 'bg-green-100 text-green-700', expired: 'bg-red-100 text-red-700', stopped: 'bg-slate-200 text-slate-600', cancelled: 'bg-slate-200 text-slate-500', unknown: 'bg-yellow-100 text-yellow-700' }[s] || 'bg-slate-100 text-slate-600';
}
function expiryText(d) {
if (d === null || d === undefined) return 'text-slate-400';
if (d < 0) return 'text-red-600 font-bold';
if (d <= 7) return 'text-red-600 font-semibold';
if (d <= 30) return 'text-amber-600';
return 'text-slate-600';
}
function expiryBadge(d) {
if (d < 0) return 'bg-red-100 text-red-700';
if (d <= 7) return 'bg-red-100 text-red-700';
if (d <= 30) return 'bg-amber-100 text-amber-700';
return 'bg-slate-100 text-slate-600';
}
onMounted(loadAll);
return {
appName, assets, overview, expiring, loading, search, filterType, filterStatus,
showModal, editing, form, error, typeLabels, statusLabels,
loadAssets, openCreate, openEdit, saveAsset, deleteAsset,
typeBadge, statusBadge, expiryText, expiryBadge,
};
return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer };
},
}).mount('#app');
};
const ProvidersView = {
template: `
<div class="space-y-3">
<div class="flex gap-2">
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-start">
<div>
<div class="font-medium text-sm">{{ p.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
</div>
<div class="text-xs text-slate-500 mt-2 space-y-1">
<div v-if="p.sdk_type">SDK: {{ p.sdk_type }}</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
<div class="flex items-center gap-2 mt-1">
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div>
</div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>`,
setup() { return { store, Fmt, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider }; },
};
const ServersView = {
template: `
<div class="space-y-4">
<div v-if="!m.asset" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
请在「资产」页点击 VPS 的「监控」查看服务器资源与安全状态
</div>
<template v-else>
<div class="flex items-center gap-2">
<button @click="store.view='assets'" class="text-sm text-slate-500 hover:text-slate-700">← 返回</button>
<h2 class="font-semibold">{{ m.asset.name }}</h2>
</div>
<div v-if="m.info" class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">CPU</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.cpu_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.cpu_cores }} 核</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">内存</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.mem_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.mem_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">磁盘</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.disk_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.disk_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">负载/在线</div>
<div class="text-xl font-bold mt-1">{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.hostname }}</div>
</div>
</div>
<div v-if="m.info" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 text-sm">
<h3 class="font-semibold mb-2">服务器信息</h3>
<div class="grid grid-cols-2 gap-y-1 text-xs text-slate-500">
<div>系统:{{ m.info.os }}</div><div>内核:{{ m.info.kernel }}</div>
<div>公网 IP{{ m.info.public_ip || '—' }}</div><div>状态:{{ m.info.status }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold mb-2 text-sm">安全检查</h3>
<div class="space-y-2">
<div v-for="c in m.checks" :key="c.id" class="flex items-start gap-2 text-sm">
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.checkBadge(c.status)">{{ c.status }}</span>
<div class="min-w-0">
<div class="font-medium text-xs">{{ c.check_item }}</div>
<div class="text-xs text-slate-500">{{ c.detail }}</div>
<div v-if="c.suggestion" class="text-xs text-amber-600 dark:text-amber-400">建议:{{ c.suggestion }}</div>
</div>
</div>
<div v-if="!m.checks.length" class="text-xs text-slate-400">暂无安全检查数据(需 Agent 上报)</div>
</div>
</div>
</template>
</div>`,
setup() {
const m = computed(() => store.serverMetrics);
const latest = computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
return { store, Fmt, m, latest };
},
};
const DomainsView = {
template: `
<div class="space-y-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr><th class="px-4 py-2.5 font-medium">域名</th><th class="px-4 py-2.5 font-medium">注册商</th><th class="px-4 py-2.5 font-medium">DNS</th><th class="px-4 py-2.5 font-medium">到期</th><th class="px-4 py-2.5 font-medium">使用中</th><th class="px-4 py-2.5 font-medium text-right">操作</th></tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!domains.length"><td colspan="6" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
<tr v-for="a in domains" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
<td class="px-4 py-2.5 text-right"><button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button></td>
</tr>
</tbody>
</table>
</div>
</div>`,
setup() {
const domains = computed(() => store.assets.filter(a => a.asset_type === 'domain'));
return { store, Fmt, domains, edit: openAssetEdit };
},
};
const SettingsView = {
template: `
<div class="max-w-lg space-y-4">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-3">API Key(写操作鉴权)</h3>
<p class="text-xs text-slate-500 mb-2">若服务端 .env 配置了 API_KEY,请在此填写相同的值,前端写操作会自动携带。</p>
<div class="flex gap-2">
<input v-model="settings.apiKey" type="password" placeholder="留空表示服务端未启用鉴权" class="flex-1 text-sm px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">外观</h3>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
</label>
</div>
</div>`,
setup() { return { store, settings, save: saveSettings, applyDark }; },
};
/* ================= 资产编辑模态框 ================= */
const AssetModal = {
template: `
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="store.assetModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block"><span class="text-xs text-slate-500">名称 *</span>
<input v-model="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">类型 *</span>
<select v-model="f.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.provider_id" @change="onProviderChange" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option :value="null">未指定</option>
<option v-for="p in providersForType" :key="p.id" :value="p.id">{{ p.name }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">到期时间</span>
<input type="date" v-model="f.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">续费周期</span>
<select v-model="f.renewal_cycle" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option value="">未设定</option><option v-for="(l,k) in Fmt.CYCLE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">费用</span>
<input type="number" step="0.01" v-model.number="f.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">币种</span>
<select v-model="f.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">状态</span>
<select v-model="f.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
<input v-model="f.account" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
</div>
</div>
<fieldset v-if="f.asset_type==='vps'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">公网 IP *</span><input v-model="f.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">Tailscale IP</span><input v-model="f.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">区域</span><input v-model="f.vps_detail.region" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">系统</span><input v-model="f.vps_detail.os" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CPU核</span><input type="number" v-model.number="f.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">内存GB</span><input type="number" step="0.5" v-model.number="f.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">磁盘GB</span><input type="number" v-model.number="f.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH端口</span><input type="number" v-model.number="f.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH用户</span><input v-model="f.vps_detail.ssh_user" placeholder="root" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">登录方式</span><select v-model="f.vps_detail.login_method" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="key">密钥</option><option value="password">密码</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">SSH密钥</span><input v-model="f.vps_detail.ssh_key" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">密码</span><input v-model="f.vps_detail.password" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">用途/项目</span><input v-model="f.vps_detail.purpose" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='domain'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="col-span-2 block"><span class="text-xs text-slate-400">域名 *</span><input v-model="f.domain_detail.domain_name" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">注册商</span><input v-model="f.domain_detail.registrar" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">DNS托管</span><input v-model="f.domain_detail.dns_provider" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CF账号</span><input v-model="f.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">绑定资产ID</span><input type="number" v-model.number="f.domain_detail.bind_asset_id" placeholder="关联VPS的ID" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='ai_agent'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">服务商 *</span><input v-model="f.ai_detail.provider" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">计划</span><input v-model="f.ai_detail.plan" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">API Key</span><input v-model="f.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">余额</span><input type="number" step="0.01" v-model.number="f.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">月限额</span><input type="number" step="0.01" v-model.number="f.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<div class="flex justify-end gap-2 pt-1">
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = computed(() => store.assetModal.form);
const providersForType = computed(() => {
const t = f.value ? f.value.asset_type : null;
const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
const cat = map[t];
return cat ? store.providers.filter(p => p.category === cat || p.category === 'other') : store.providers;
});
function onProviderChange() {
const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug;
}
return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset };
},
};
const ProviderModal = {
template: `
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
<button @click="store.providerModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">标识 slug *</span><input v-model="f.slug" required placeholder="aliyun" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">名称 *</span><input v-model="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">分类</span><select v-model="f.category" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option v-for="(l,k) in Fmt.CATEGORY_LABELS" :key="k" :value="k">{{ l }}</option></select></label>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><input v-model="f.sdk_type" placeholder="aliyun-sdk" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">API配置(JSON)</span><textarea v-model="f.api_config" rows="2" :placeholder="store.providerModal.editing ? '留空不修改' : '{&quot;access_key&quot;:&quot;&quot;,&quot;secret&quot;:&quot;&quot;}'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
</div>
</form>
</div>
</div>`,
setup() { return { store, Fmt, f: computed(() => store.providerModal.form), save: saveProvider }; },
};
/* ================= 根组件 ================= */
const AppRoot = {
components: { DashboardView, AssetsView, ProvidersView, ServersView, DomainsView, SettingsView, AssetModal, ProviderModal },
template: `
<div class="min-h-screen md:flex">
<aside class="hidden md:flex flex-col w-52 shrink-0 border-r border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-3 sticky top-0 h-screen">
<div class="px-2 py-3 font-bold text-slate-900 dark:text-white">📦 {{ appName }}</div>
<nav class="space-y-1 mt-2">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition" :class="store.view===n.key ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400 font-medium' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'">
<span>{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
</div>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="sticky top-0 z-30 bg-white/80 dark:bg-slate-900/80 backdrop-blur border-b border-slate-200 dark:border-slate-800 px-4 py-3 flex items-center gap-3">
<h1 class="font-semibold text-slate-900 dark:text-white md:text-lg">{{ currentNav.label }}</h1>
<div class="ml-auto flex items-center gap-2">
<input v-if="store.view==='assets'" v-model="store.search" @input="reload" placeholder="搜索…" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-32 md:w-48">
<button @click="quickAdd" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap">+ 新增</button>
</div>
</header>
<div v-if="store.error" class="mx-4 mt-3 px-4 py-2.5 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900/50 text-red-700 dark:text-red-400 text-sm flex justify-between">
<span>⚠️ {{ store.error }}</span><button @click="store.error=''" class="font-bold">✕</button>
</div>
<main class="flex-1 p-4 pb-20 md:pb-6 overflow-y-auto">
<component :is="viewComponent"></component>
</main>
</div>
<nav class="md:hidden fixed bottom-0 inset-x-0 z-30 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 flex">
<button v-for="n in navs" :key="n.key" @click="go(n.key)" class="flex-1 py-2 flex flex-col items-center gap-0.5 text-xs" :class="store.view===n.key ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400'">
<span class="text-base">{{ n.icon }}</span>{{ n.label }}
</button>
</nav>
<asset-modal></asset-modal>
<provider-modal></provider-modal>
</div>`,
setup() {
const appName = Api.CFG.appName;
const navs = [
{ key: 'dashboard', label: '总览', icon: '📊' },
{ key: 'assets', label: '资产', icon: '📦' },
{ key: 'servers', label: '服务器', icon: '🖥️' },
{ key: 'domains', label: '域名', icon: '🌐' },
{ key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' },
];
const viewMap = { dashboard: 'dashboard-view', assets: 'assets-view', servers: 'servers-view', domains: 'domains-view', providers: 'providers-view', settings: 'settings-view' };
const currentNav = computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = computed(() => viewMap[store.view] || 'dashboard-view');
function go(key) { store.view = key; if (key !== 'servers') store.serverMetrics = {}; }
function quickAdd() {
const preset = store.view === 'domains' ? 'domain' : (store.view === 'providers' ? null : 'vps');
if (store.view === 'providers') openProviderCreate();
else openAssetCreate(preset);
}
function toggleDark() { store.dark = !store.dark; applyDark(); }
return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: loadAssets };
},
};
/* ================= 挂载 ================= */
const app = createApp(AppRoot);
app.component('dashboard-view', DashboardView);
app.component('assets-view', AssetsView);
app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView);
app.component('domains-view', DomainsView);
app.component('settings-view', SettingsView);
applyDark();
loadAll();
app.mount('#app');