feat: 平台预设字典+凭证加密+分库备份+Agent采集+移动端优先前端重做
This commit is contained in:
@@ -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()]
|
||||||
@@ -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())
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -17,7 +17,12 @@ class Settings:
|
|||||||
"""应用配置项"""
|
"""应用配置项"""
|
||||||
|
|
||||||
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
|
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
|
||||||
|
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
|
||||||
API_KEY: str = os.getenv("API_KEY", "")
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,3 +23,16 @@ async def require_api_key(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="API Key 无效或缺失",
|
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 无效或缺失",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,8 @@
|
|||||||
"""SQLite 数据库连接与初始化"""
|
"""SQLite 数据库连接与初始化(分库)
|
||||||
|
|
||||||
|
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount
|
||||||
|
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
||||||
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
@@ -9,20 +13,49 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
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:
|
def init_db() -> None:
|
||||||
"""创建所有表(幂等,可重复调用)"""
|
"""分库建表(幂等,可重复调用)"""
|
||||||
# 显式导入模型,确保其注册到 SQLModel.metadata
|
|
||||||
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401
|
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]:
|
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
|
yield session
|
||||||
|
|||||||
+10
-3
@@ -14,10 +14,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.database import init_db
|
from app.core.seed import seed_providers
|
||||||
from app.routers import assets, stats
|
from app.database import assets_engine, init_db
|
||||||
|
from app.routers import agent, assets, monitor, providers, stats
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
STATIC_DIR = BASE_DIR / "static"
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
@@ -30,8 +32,10 @@ jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
"""应用启动时自动建表"""
|
"""应用启动时自动建表并初始化预设平台"""
|
||||||
init_db()
|
init_db()
|
||||||
|
with Session(assets_engine) as session:
|
||||||
|
seed_providers(session)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +49,10 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(assets.router)
|
app.include_router(assets.router)
|
||||||
|
app.include_router(providers.router)
|
||||||
app.include_router(stats.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")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -42,7 +42,13 @@ class Asset(SQLModel, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
|
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
|
||||||
asset_type: AssetType = Field(index=True, description="资产类型")
|
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(
|
account: Optional[str] = Field(
|
||||||
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
|
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
|
||||||
)
|
)
|
||||||
@@ -84,6 +90,13 @@ class VPSDetail(SQLModel, table=True):
|
|||||||
bandwidth_gb: Optional[int] = Field(default=None, description="月流量配额(GB)")
|
bandwidth_gb: Optional[int] = Field(default=None, description="月流量配额(GB)")
|
||||||
ssh_port: int = Field(default=22, description="SSH 端口")
|
ssh_port: int = Field(default=22, description="SSH 端口")
|
||||||
panel_url: Optional[str] = Field(default=None, description="服务商管理面板 URL")
|
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):
|
class DomainDetail(SQLModel, table=True):
|
||||||
@@ -109,6 +122,9 @@ class DomainDetail(SQLModel, table=True):
|
|||||||
redirect_target: Optional[str] = Field(
|
redirect_target: Optional[str] = Field(
|
||||||
default=None, description="若为跳转用途域名,填写目标地址"
|
default=None, description="若为跳转用途域名,填写目标地址"
|
||||||
)
|
)
|
||||||
|
bind_asset_id: Optional[int] = Field(
|
||||||
|
default=None, index=True, description="绑定的资产 ID(如关联的 VPS/项目)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AIAccount(SQLModel, table=True):
|
class AIAccount(SQLModel, table=True):
|
||||||
|
|||||||
@@ -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="事件内容")
|
||||||
@@ -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" # Cloudflare(DNS/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 配置 JSON(access_key/secret 等)"
|
||||||
|
)
|
||||||
|
enabled: bool = Field(default=True, description="是否启用")
|
||||||
|
remark: Optional[str] = Field(default=None, description="备注")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||||
@@ -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}
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -30,6 +30,11 @@ class VPSDetailIn(SQLModel):
|
|||||||
bandwidth_gb: Optional[int] = None
|
bandwidth_gb: Optional[int] = None
|
||||||
ssh_port: int = 22
|
ssh_port: int = 22
|
||||||
panel_url: Optional[str] = None
|
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):
|
class DomainDetailIn(SQLModel):
|
||||||
@@ -41,6 +46,7 @@ class DomainDetailIn(SQLModel):
|
|||||||
cloudflare_account: Optional[str] = None
|
cloudflare_account: Optional[str] = None
|
||||||
is_using: bool = True
|
is_using: bool = True
|
||||||
redirect_target: Optional[str] = None
|
redirect_target: Optional[str] = None
|
||||||
|
bind_asset_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class AIAccountIn(SQLModel):
|
class AIAccountIn(SQLModel):
|
||||||
@@ -64,6 +70,8 @@ class AssetBase(SQLModel):
|
|||||||
name: str
|
name: str
|
||||||
asset_type: AssetType
|
asset_type: AssetType
|
||||||
provider: str
|
provider: str
|
||||||
|
provider_id: Optional[int] = None
|
||||||
|
renewal_cycle: Optional[str] = None
|
||||||
account: Optional[str] = None
|
account: Optional[str] = None
|
||||||
expiry_date: Optional[date] = None
|
expiry_date: Optional[date] = None
|
||||||
auto_renew: bool = False
|
auto_renew: bool = False
|
||||||
@@ -88,6 +96,8 @@ class AssetUpdate(SQLModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
asset_type: Optional[AssetType] = None
|
asset_type: Optional[AssetType] = None
|
||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
|
provider_id: Optional[int] = None
|
||||||
|
renewal_cycle: Optional[str] = None
|
||||||
account: Optional[str] = None
|
account: Optional[str] = None
|
||||||
expiry_date: Optional[date] = None
|
expiry_date: Optional[date] = None
|
||||||
auto_renew: Optional[bool] = None
|
auto_renew: Optional[bool] = None
|
||||||
@@ -104,9 +114,26 @@ class AssetUpdate(SQLModel):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 输出模型
|
# 输出模型
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class VPSDetailRead(VPSDetailIn):
|
class VPSDetailRead(SQLModel):
|
||||||
|
"""VPS 详情输出(不含敏感明文,仅返回凭证存在标记)"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
asset_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):
|
class DomainDetailRead(DomainDetailIn):
|
||||||
@@ -127,6 +154,7 @@ class AssetRead(AssetBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
days_to_expiry: Optional[int] = None
|
days_to_expiry: Optional[int] = None
|
||||||
|
provider_name: Optional[str] = None
|
||||||
vps_detail: Optional[VPSDetailRead] = None
|
vps_detail: Optional[VPSDetailRead] = None
|
||||||
domain_detail: Optional[DomainDetailRead] = None
|
domain_detail: Optional[DomainDetailRead] = None
|
||||||
ai_detail: Optional[AIAccountRead] = None
|
ai_detail: Optional[AIAccountRead] = None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ from typing import List, Optional
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core import crypto
|
||||||
from app.models.asset import (
|
from app.models.asset import (
|
||||||
AIAccount,
|
AIAccount,
|
||||||
Asset,
|
Asset,
|
||||||
@@ -17,6 +18,7 @@ from app.models.asset import (
|
|||||||
DomainDetail,
|
DomainDetail,
|
||||||
VPSDetail,
|
VPSDetail,
|
||||||
)
|
)
|
||||||
|
from app.models.provider import Provider
|
||||||
from app.schemas.asset import (
|
from app.schemas.asset import (
|
||||||
AIAccountRead,
|
AIAccountRead,
|
||||||
AssetCreate,
|
AssetCreate,
|
||||||
@@ -56,19 +58,69 @@ def _get_detail(session: Session, asset: Asset):
|
|||||||
return session.exec(select(model).where(model.asset_id == asset.id)).first()
|
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 输出(主表 + 详情 + 计算字段)"""
|
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
|
||||||
read = AssetRead.model_validate(asset)
|
read = AssetRead.model_validate(asset)
|
||||||
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
||||||
|
read.provider_name = _provider_name(session, asset)
|
||||||
if isinstance(detail, VPSDetail):
|
if isinstance(detail, VPSDetail):
|
||||||
read.vps_detail = VPSDetailRead.model_validate(detail)
|
read.vps_detail = _detail_to_read(detail)
|
||||||
elif isinstance(detail, DomainDetail):
|
elif isinstance(detail, DomainDetail):
|
||||||
read.domain_detail = DomainDetailRead.model_validate(detail)
|
read.domain_detail = _detail_to_read(detail)
|
||||||
elif isinstance(detail, AIAccount):
|
elif isinstance(detail, AIAccount):
|
||||||
read.ai_detail = AIAccountRead.model_validate(detail)
|
read.ai_detail = _detail_to_read(detail)
|
||||||
return read
|
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
|
# CRUD
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -96,12 +148,12 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
|||||||
|
|
||||||
detail = None
|
detail = None
|
||||||
if model is not None and detail_in is not 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.add(detail)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(detail)
|
session.refresh(detail)
|
||||||
|
|
||||||
return _to_read(asset, detail)
|
return _to_read(session, asset, detail)
|
||||||
|
|
||||||
|
|
||||||
def get_asset(session: Session, asset_id: int) -> AssetRead:
|
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)
|
asset = session.get(Asset, asset_id)
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
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:
|
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()
|
).first()
|
||||||
if detail_in is not None:
|
if detail_in is not None:
|
||||||
if existing:
|
if existing:
|
||||||
for key, value in detail_in.model_dump().items():
|
_apply_detail_update(asset.asset_type, existing, detail_in)
|
||||||
setattr(existing, key, value)
|
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
detail = existing
|
detail = existing
|
||||||
else:
|
else:
|
||||||
detail = model(asset_id=asset.id, **detail_in.model_dump())
|
detail = _build_detail(asset.asset_type, asset.id, detail_in)
|
||||||
session.add(detail)
|
session.add(detail)
|
||||||
else:
|
else:
|
||||||
detail = existing
|
detail = existing
|
||||||
@@ -150,7 +201,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
|||||||
session.refresh(asset)
|
session.refresh(asset)
|
||||||
if detail is not None:
|
if detail is not None:
|
||||||
session.refresh(detail)
|
session.refresh(detail)
|
||||||
return _to_read(asset, detail)
|
return _to_read(session, asset, detail)
|
||||||
|
|
||||||
|
|
||||||
def delete_asset(session: Session, asset_id: int) -> None:
|
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())
|
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
|
||||||
|
|
||||||
assets = session.exec(stmt).all()
|
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:
|
for a in assets:
|
||||||
delta = (a.expiry_date - today).days
|
delta = (a.expiry_date - today).days
|
||||||
if 0 <= delta <= 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)
|
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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
@@ -2,262 +2,22 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<title>{{ app_name }}</title>
|
||||||
<script src="/static/js/tailwind.js"></script>
|
<script src="/static/js/tailwind.js"></script>
|
||||||
|
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||||
<script src="/static/js/vue.global.prod.js"></script>
|
<script src="/static/js/vue.global.prod.js"></script>
|
||||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</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>
|
</head>
|
||||||
<body class="bg-slate-100 text-slate-800 min-h-screen">
|
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
|
||||||
<div id="app" class="max-w-7xl mx-auto px-4 py-6">
|
<div id="app"></div>
|
||||||
{% raw %}
|
<script src="/static/js/api.js"></script>
|
||||||
<!-- 顶部栏 -->
|
<script src="/static/js/app.js"></script>
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Executable
+56
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -4,3 +4,6 @@ sqlmodel>=0.0.22
|
|||||||
pydantic>=2.7
|
pydantic>=2.7
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
jinja2>=3.1
|
jinja2>=3.1
|
||||||
|
cryptography>=42
|
||||||
|
psutil>=5.9
|
||||||
|
httpx>=0.27
|
||||||
|
|||||||
@@ -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
@@ -1,157 +1,651 @@
|
|||||||
/* vps-manager 前端逻辑(Vue 3 全局构建) */
|
/* vps-manager 前端应用(Vue 3 组件化) */
|
||||||
const { createApp, ref, reactive, onMounted } = Vue;
|
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 store = reactive({
|
||||||
const API_KEY = '';
|
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: '其他' };
|
function applyDark() {
|
||||||
const STATUS_LABELS = { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' };
|
document.documentElement.classList.toggle('dark', store.dark);
|
||||||
|
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
|
||||||
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 emptyForm() {
|
/* ---------------- 表单工厂 ---------------- */
|
||||||
|
function emptyAssetForm() {
|
||||||
return {
|
return {
|
||||||
name: '', asset_type: 'vps', provider: '', account: '', expiry_date: '',
|
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly',
|
||||||
auto_renew: false, cost: 0, currency: 'USD', status: 'active', is_archived: false, remark: '',
|
account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
|
||||||
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: '' },
|
status: 'active', is_archived: false, remark: '',
|
||||||
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '' },
|
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 },
|
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空字符串转 null(可选字段),保留数字与布尔
|
function buildAssetPayload(f) {
|
||||||
function cleanDetail(obj) {
|
const p = {
|
||||||
const out = {};
|
name: f.name, asset_type: f.asset_type, provider: f.provider,
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
|
||||||
out[k] = (v === '' ? null : v);
|
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() {
|
setup() {
|
||||||
const appName = CFG.appName;
|
return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer };
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}).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 ? '留空不修改' : '{"access_key":"","secret":""}'" 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');
|
||||||
|
|||||||
Reference in New Issue
Block a user