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

This commit is contained in:
gouki
2026-08-01 23:10:31 +00:00
parent 1dac5d8735
commit e672086b67
31 changed files with 1792 additions and 421 deletions
+5
View File
@@ -17,7 +17,12 @@ class Settings:
"""应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
API_KEY: str = os.getenv("API_KEY", "")
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
# 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
MASTER_KEY: str = os.getenv("MASTER_KEY", "")
settings = Settings()
+40
View File
@@ -0,0 +1,40 @@
"""凭证加密模块(Fernet 对称加密)
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
MASTER_KEY 从 .env 读取,不入库。
"""
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def _get_fernet() -> Fernet:
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
key = settings.MASTER_KEY
if not key:
raise RuntimeError(
"MASTER_KEY 未配置,无法加解密凭证。请在 .env 设置 MASTER_KEY"
"生成命令:python -c 'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())'"
)
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(plain: Optional[str]) -> Optional[str]:
"""加密明文,返回 token 字符串;空值原样返回 None"""
if plain is None or plain == "":
return None
return _get_fernet().encrypt(plain.encode()).decode()
def decrypt(token: Optional[str]) -> Optional[str]:
"""解密 token,返回明文;空值或解密失败返回 None"""
if not token:
return None
try:
return _get_fernet().decrypt(token.encode()).decode()
except (InvalidToken, ValueError):
return None
+13
View File
@@ -23,3 +23,16 @@ async def require_api_key(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失",
)
async def require_agent_key(
x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"),
) -> None:
"""校验 Agent 上报 Key(可选启用)"""
if not settings.AGENT_KEY:
return
if x_agent_key != settings.AGENT_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent Key 无效或缺失",
)
+66
View File
@@ -0,0 +1,66 @@
"""平台预设种子数据
首次部署或调用 POST /api/providers/seed 时初始化常见平台。
每个平台有唯一 slug,供未来按 sdk_type 对接自动同步扩展。
"""
from sqlmodel import Session, select
from app.models.provider import Provider
PRESET_PROVIDERS = [
# ---- VPS / 云服务商 ----
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
"website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
"website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"},
{"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps",
"website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"},
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
# ---- 域名注册商 ----
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
# ---- AI 服务商 ----
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
"website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
{"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent",
"website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
{"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent",
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
"website": None, "console_url": None, "sdk_type": None},
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
# ---- Cloudflare ----
{"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare",
"website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
]
def seed_providers(session: Session) -> int:
"""初始化预设平台(已存在的 slug 跳过),返回新增数量"""
added = 0
for data in PRESET_PROVIDERS:
existing = session.exec(
select(Provider).where(Provider.slug == data["slug"])
).first()
if not existing:
session.add(Provider(**data))
added += 1
session.commit()
return added
+41 -8
View File
@@ -1,4 +1,8 @@
"""SQLite 数据库连接与初始化"""
"""SQLite 数据库连接与初始化(分库)
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount
- metrics.dbMetricPoint / ServerInfo / SecurityCheck / EventLog
"""
from pathlib import Path
from typing import Generator
@@ -9,20 +13,49 @@ BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_URL = f"sqlite:///{DATA_DIR / 'vps_manager.db'}"
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
engine = create_engine(DB_URL, echo=False, connect_args={"check_same_thread": False})
assets_engine = create_engine(
ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
)
metrics_engine = create_engine(
METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
)
# 兼容旧代码:默认 engine 指向资产库
engine = assets_engine
def init_db() -> None:
"""创建所有表(幂等,可重复调用)"""
# 显式导入模型,确保其注册到 SQLModel.metadata
"""分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401
from app.models.monitor import ( # noqa: F401
EventLog,
MetricPoint,
SecurityCheck,
ServerInfo,
)
from app.models.provider import Provider # noqa: F401
SQLModel.metadata.create_all(engine)
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all(
assets_engine, tables=[m.__table__ for m in asset_models]
)
SQLModel.metadata.create_all(
metrics_engine, tables=[m.__table__ for m in metric_models]
)
def get_session() -> Generator[Session, None, None]:
"""FastAPI 依赖:提供数据库会话(请求结束后自动关闭"""
with Session(engine) as session:
"""资产库会话(默认"""
with Session(assets_engine) as session:
yield session
def get_metrics_session() -> Generator[Session, None, None]:
"""监控 / 日志库会话"""
with Session(metrics_engine) as session:
yield session
+10 -3
View File
@@ -14,10 +14,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from sqlmodel import Session
from app.core.config import settings
from app.database import init_db
from app.routers import assets, stats
from app.core.seed import seed_providers
from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, providers, stats
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
@@ -30,8 +32,10 @@ jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
@asynccontextmanager
async def lifespan(_: FastAPI):
"""应用启动时自动建表"""
"""应用启动时自动建表并初始化预设平台"""
init_db()
with Session(assets_engine) as session:
seed_providers(session)
yield
@@ -45,7 +49,10 @@ app.add_middleware(
)
app.include_router(assets.router)
app.include_router(providers.router)
app.include_router(stats.router)
app.include_router(agent.router)
app.include_router(monitor.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+17 -1
View File
@@ -42,7 +42,13 @@ class Asset(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
asset_type: AssetType = Field(index=True, description="资产类型")
provider: str = Field(index=True, description="服务商,如 aliyun/vultr/namesilo/openai")
provider: str = Field(index=True, description="服务商 slug,如 aliyun/vultr/namesilo/openai")
provider_id: Optional[int] = Field(
default=None, foreign_key="providers.id", index=True, description="关联平台 ID"
)
renewal_cycle: Optional[str] = Field(
default=None, description="续费周期:monthly/quarterly/yearly"
)
account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
)
@@ -84,6 +90,13 @@ class VPSDetail(SQLModel, table=True):
bandwidth_gb: Optional[int] = Field(default=None, description="月流量配额(GB")
ssh_port: int = Field(default=22, description="SSH 端口")
panel_url: Optional[str] = Field(default=None, description="服务商管理面板 URL")
ssh_user: Optional[str] = Field(default=None, description="SSH 登录用户,如 root")
login_method: Optional[str] = Field(
default="key", description="登录方式:key/password/other"
)
ssh_key_encrypted: Optional[str] = Field(default=None, description="加密的 SSH 私钥")
password_encrypted: Optional[str] = Field(default=None, description="加密的登录密码")
purpose: Optional[str] = Field(default=None, description="用途 / 运行的项目")
class DomainDetail(SQLModel, table=True):
@@ -109,6 +122,9 @@ class DomainDetail(SQLModel, table=True):
redirect_target: Optional[str] = Field(
default=None, description="若为跳转用途域名,填写目标地址"
)
bind_asset_id: Optional[int] = Field(
default=None, index=True, description="绑定的资产 ID(如关联的 VPS/项目)"
)
class AIAccount(SQLModel, table=True):
+76
View File
@@ -0,0 +1,76 @@
"""监控与日志模型(存储于 metrics.db)
- MetricPoint: 资源监控时序数据(Agent 上报)
- ServerInfo: 服务器基础信息快照(Agent 上报)
- SecurityCheck: 安全检查项结果
- EventLog: 系统事件日志
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class MetricPoint(SQLModel, table=True):
"""资源监控时序数据"""
__tablename__ = "metric_points"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True, description="关联资产 ID")
ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间")
cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %")
net_in_mb: Optional[float] = Field(default=None, description="网络入流量 MB(采样间隔)")
net_out_mb: Optional[float] = Field(default=None, description="网络出流量 MB(采样间隔)")
load_1m: Optional[float] = Field(default=None, description="1 分钟负载")
uptime_sec: Optional[int] = Field(default=None, description="运行时长(秒)")
class ServerInfo(SQLModel, table=True):
"""服务器基础信息快照(每个资产一条,覆盖更新)"""
__tablename__ = "server_info"
id: Optional[int] = Field(default=None, primary_key=True)
# 逻辑关联 assets.id(跨库不设物理外键)
asset_id: int = Field(unique=True, index=True)
hostname: Optional[str] = Field(default=None)
os: Optional[str] = Field(default=None, description="操作系统发行版")
kernel: Optional[str] = Field(default=None, description="内核版本")
cpu_cores: Optional[int] = Field(default=None)
mem_total_gb: Optional[float] = Field(default=None)
disk_total_gb: Optional[float] = Field(default=None)
public_ip: Optional[str] = Field(default=None)
tailscale_ip: Optional[str] = Field(default=None)
status: Optional[str] = Field(default="online", description="online/offline")
last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间")
class SecurityCheck(SQLModel, table=True):
"""安全检查项结果"""
__tablename__ = "security_checks"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True)
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
status: str = Field(default="unknown", description="pass/warn/fail/unknown")
detail: Optional[str] = Field(default=None, description="检查详情")
suggestion: Optional[str] = Field(default=None, description="加固建议")
class EventLog(SQLModel, table=True):
"""系统事件日志"""
__tablename__ = "event_logs"
id: Optional[int] = Field(default=None, primary_key=True)
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
level: str = Field(default="info", index=True, description="info/warning/error")
source: str = Field(default="system", description="来源,如 agent/api/backup")
asset_id: Optional[int] = Field(default=None, index=True)
message: str = Field(description="事件内容")
+44
View File
@@ -0,0 +1,44 @@
"""平台/服务商注册表模型
Provider 是资产的归属平台预设字典,提供唯一标识(slug),
为未来按平台对接 SDK(自动同步 VPS/域名/AI 状态)打基础。
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
class ProviderCategory(str, Enum):
"""平台分类"""
VPS = "vps" # 云服务器
DOMAIN = "domain" # 域名注册商
AI_AGENT = "ai_agent" # AI 服务商
CLOUDFLARE = "cloudflare" # CloudflareDNS/Workers/R2/Tunnel/Mail
OTHER = "other" # 其他
class Provider(SQLModel, table=True):
"""平台/服务商(预设 + 自定义)"""
__tablename__ = "providers"
id: Optional[int] = Field(default=None, primary_key=True)
slug: str = Field(index=True, unique=True, description="唯一标识,如 aliyun/vultr/openai")
name: str = Field(index=True, description="显示名,如 阿里云")
name_en: Optional[str] = Field(default=None, description="英文名")
category: ProviderCategory = Field(index=True, description="平台分类")
website: Optional[str] = Field(default=None, description="官网")
console_url: Optional[str] = Field(default=None, description="管理面板 URL")
sdk_type: Optional[str] = Field(
default=None, description="SDK 类型标识,如 aliyun-sdk/vultr-api(供扩展用)"
)
api_config_encrypted: Optional[str] = Field(
default=None, description="加密的 API 配置 JSONaccess_key/secret 等)"
)
enabled: bool = Field(default=True, description="是否启用")
remark: Optional[str] = Field(default=None, description="备注")
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
+55
View File
@@ -0,0 +1,55 @@
"""Agent 上报接收路由(数据写入 metrics.db)"""
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.security import require_agent_key
from app.database import get_metrics_session
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
from app.schemas.agent import AgentReport
router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.post(
"/report",
summary="Agent 上报资源/信息/安全状态",
dependencies=[Depends(require_agent_key)],
)
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
# 资源监控时序
if data.metrics:
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
# 服务器信息快照(每资产一条,覆盖更新)
if data.server_info:
info = data.server_info.model_dump()
info["last_seen"] = datetime.utcnow()
existing = session.exec(
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
).first()
if existing:
for key, value in info.items():
setattr(existing, key, value)
session.add(existing)
else:
session.add(ServerInfo(asset_id=data.asset_id, **info))
# 安全检查项
if data.security:
for check in data.security:
session.add(SecurityCheck(asset_id=data.asset_id, **check.model_dump()))
# 事件日志
session.add(
EventLog(
level="info",
source="agent",
asset_id=data.asset_id,
message=f"Agent 上报 asset_id={data.asset_id}",
)
)
session.commit()
return {"status": "ok", "asset_id": data.asset_id}
+49
View File
@@ -0,0 +1,49 @@
"""监控数据查询路由(读取 metrics.db)"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.database import get_metrics_session
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
def metrics(
asset_id: int,
limit: int = Query(default=50, ge=1, le=500),
session: Session = Depends(get_metrics_session),
):
stmt = (
select(MetricPoint)
.where(MetricPoint.asset_id == asset_id)
.order_by(MetricPoint.ts.desc())
.limit(limit)
)
return session.exec(stmt).all()
@router.get("/{asset_id}/info", summary="服务器信息快照")
def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Optional[ServerInfo]:
return session.exec(
select(ServerInfo).where(ServerInfo.asset_id == asset_id)
).first()
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
stmt = (
select(SecurityCheck)
.where(SecurityCheck.asset_id == asset_id)
.order_by(SecurityCheck.ts.desc())
.limit(50)
)
checks = session.exec(stmt).all()
latest_by_item = {}
for check in checks:
if check.check_item not in latest_by_item:
latest_by_item[check.check_item] = check
return list(latest_by_item.values())
+72
View File
@@ -0,0 +1,72 @@
"""平台/服务商路由"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session
from app.core.security import require_api_key
from app.core.seed import seed_providers
from app.database import get_session
from app.models.provider import ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
from app.services import provider_service
router = APIRouter(prefix="/api/providers", tags=["providers"])
@router.post(
"/seed",
summary="初始化预设平台",
dependencies=[Depends(require_api_key)],
)
def seed(session: Session = Depends(get_session)) -> dict:
added = seed_providers(session)
return {"added": added}
@router.get("", response_model=List[ProviderRead], summary="平台列表")
def list_providers(
category: Optional[ProviderCategory] = Query(default=None),
enabled: Optional[bool] = Query(default=None),
session: Session = Depends(get_session),
):
return provider_service.list_providers(session, category, enabled)
@router.get("/{provider_id}", response_model=ProviderRead, summary="平台详情")
def get_provider(provider_id: int, session: Session = Depends(get_session)):
return provider_service.get_provider(session, provider_id)
@router.post(
"",
response_model=ProviderRead,
status_code=201,
summary="创建平台",
dependencies=[Depends(require_api_key)],
)
def create_provider(data: ProviderCreate, session: Session = Depends(get_session)):
return provider_service.create_provider(session, data)
@router.put(
"/{provider_id}",
response_model=ProviderRead,
summary="更新平台",
dependencies=[Depends(require_api_key)],
)
def update_provider(
provider_id: int, data: ProviderUpdate, session: Session = Depends(get_session)
):
return provider_service.update_provider(session, provider_id, data)
@router.delete(
"/{provider_id}",
status_code=204,
summary="删除平台",
dependencies=[Depends(require_api_key)],
)
def delete_provider(provider_id: int, session: Session = Depends(get_session)):
provider_service.delete_provider(session, provider_id)
+43
View File
@@ -0,0 +1,43 @@
"""Agent 上报数据 Schema"""
from typing import List, Optional
from sqlmodel import SQLModel
class MetricsIn(SQLModel):
cpu_pct: Optional[float] = None
mem_pct: Optional[float] = None
disk_pct: Optional[float] = None
net_in_mb: Optional[float] = None
net_out_mb: Optional[float] = None
load_1m: Optional[float] = None
uptime_sec: Optional[int] = None
class ServerInfoIn(SQLModel):
hostname: Optional[str] = None
os: Optional[str] = None
kernel: Optional[str] = None
cpu_cores: Optional[int] = None
mem_total_gb: Optional[float] = None
disk_total_gb: Optional[float] = None
public_ip: Optional[str] = None
tailscale_ip: Optional[str] = None
status: Optional[str] = "online"
class SecurityCheckIn(SQLModel):
check_item: str
status: str = "unknown" # pass/warn/fail/unknown
detail: Optional[str] = None
suggestion: Optional[str] = None
class AgentReport(SQLModel):
"""Agent 单次上报的完整数据包"""
asset_id: int
metrics: Optional[MetricsIn] = None
server_info: Optional[ServerInfoIn] = None
security: Optional[List[SecurityCheckIn]] = None
+29 -1
View File
@@ -30,6 +30,11 @@ class VPSDetailIn(SQLModel):
bandwidth_gb: Optional[int] = None
ssh_port: int = 22
panel_url: Optional[str] = None
ssh_user: Optional[str] = None
login_method: Optional[str] = "key"
ssh_key: Optional[str] = None # 明文输入,服务端加密存储
password: Optional[str] = None # 明文输入,服务端加密存储
purpose: Optional[str] = None
class DomainDetailIn(SQLModel):
@@ -41,6 +46,7 @@ class DomainDetailIn(SQLModel):
cloudflare_account: Optional[str] = None
is_using: bool = True
redirect_target: Optional[str] = None
bind_asset_id: Optional[int] = None
class AIAccountIn(SQLModel):
@@ -64,6 +70,8 @@ class AssetBase(SQLModel):
name: str
asset_type: AssetType
provider: str
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: bool = False
@@ -88,6 +96,8 @@ class AssetUpdate(SQLModel):
name: Optional[str] = None
asset_type: Optional[AssetType] = None
provider: Optional[str] = None
provider_id: Optional[int] = None
renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None
@@ -104,9 +114,26 @@ class AssetUpdate(SQLModel):
# --------------------------------------------------------------------------- #
# 输出模型
# --------------------------------------------------------------------------- #
class VPSDetailRead(VPSDetailIn):
class VPSDetailRead(SQLModel):
"""VPS 详情输出(不含敏感明文,仅返回凭证存在标记)"""
id: int
asset_id: int
ip_address: str
tailscale_ip: Optional[str] = None
region: Optional[str] = None
os: Optional[str] = None
cpu_cores: int = 1
memory_gb: float = 1
disk_gb: int = 20
bandwidth_gb: Optional[int] = None
ssh_port: int = 22
panel_url: Optional[str] = None
ssh_user: Optional[str] = None
login_method: Optional[str] = "key"
purpose: Optional[str] = None
has_ssh_key: bool = False
has_password: bool = False
class DomainDetailRead(DomainDetailIn):
@@ -127,6 +154,7 @@ class AssetRead(AssetBase):
created_at: datetime
updated_at: datetime
days_to_expiry: Optional[int] = None
provider_name: Optional[str] = None
vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None
+45
View File
@@ -0,0 +1,45 @@
"""平台/服务商 Schema"""
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel
from app.models.provider import ProviderCategory
class ProviderBase(SQLModel):
slug: str
name: str
name_en: Optional[str] = None
category: ProviderCategory
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
enabled: bool = True
remark: Optional[str] = None
class ProviderCreate(ProviderBase):
"""创建平台;api_config 为明文 JSON 字符串,服务端加密存储"""
api_config: Optional[str] = None
class ProviderUpdate(SQLModel):
slug: Optional[str] = None
name: Optional[str] = None
name_en: Optional[str] = None
category: Optional[ProviderCategory] = None
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
enabled: Optional[bool] = None
remark: Optional[str] = None
api_config: Optional[str] = None
class ProviderRead(ProviderBase):
id: int
created_at: datetime
has_api_config: bool = False
+64 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import (
AIAccount,
Asset,
@@ -17,6 +18,7 @@ from app.models.asset import (
DomainDetail,
VPSDetail,
)
from app.models.provider import Provider
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
@@ -56,19 +58,69 @@ def _get_detail(session: Session, asset: Asset):
return session.exec(select(model).where(model.asset_id == asset.id)).first()
def _to_read(asset: Asset, detail) -> AssetRead:
def _detail_to_read(detail):
"""详情模型转输出模型(VPS 计算凭证存在标记,不输出敏感明文)"""
if isinstance(detail, VPSDetail):
read = VPSDetailRead.model_validate(detail)
read.has_ssh_key = bool(detail.ssh_key_encrypted)
read.has_password = bool(detail.password_encrypted)
return read
if isinstance(detail, DomainDetail):
return DomainDetailRead.model_validate(detail)
if isinstance(detail, AIAccount):
return AIAccountRead.model_validate(detail)
return None
def _provider_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联平台显示名"""
if asset.provider_id:
provider = session.get(Provider, asset.provider_id)
if provider:
return provider.name
return None
def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = _provider_name(session, asset)
if isinstance(detail, VPSDetail):
read.vps_detail = VPSDetailRead.model_validate(detail)
read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail):
read.domain_detail = DomainDetailRead.model_validate(detail)
read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount):
read.ai_detail = AIAccountRead.model_validate(detail)
read.ai_detail = _detail_to_read(detail)
return read
def _build_detail(asset_type: AssetType, asset_id: int, detail_in):
"""构建详情模型(VPS 加密凭证)"""
_, model = DETAIL_MAP[asset_type]
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
data["ssh_key_encrypted"] = crypto.encrypt(detail_in.ssh_key)
data["password_encrypted"] = crypto.encrypt(detail_in.password)
return model(asset_id=asset_id, **data)
return model(asset_id=asset_id, **detail_in.model_dump())
def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
"""更新详情字段(VPS 加密凭证;凭证为 None 时保留原值)"""
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
for key, value in data.items():
setattr(existing, key, value)
if detail_in.ssh_key is not None:
existing.ssh_key_encrypted = crypto.encrypt(detail_in.ssh_key)
if detail_in.password is not None:
existing.password_encrypted = crypto.encrypt(detail_in.password)
else:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@@ -96,12 +148,12 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
detail = None
if model is not None and detail_in is not None:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
session.commit()
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def get_asset(session: Session, asset_id: int) -> AssetRead:
@@ -109,7 +161,7 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
return _to_read(asset, _get_detail(session, asset))
return _to_read(session, asset, _get_detail(session, asset))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
@@ -136,12 +188,11 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
).first()
if detail_in is not None:
if existing:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
_apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
detail = existing
else:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
else:
detail = existing
@@ -150,7 +201,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
session.refresh(asset)
if detail is not None:
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def delete_asset(session: Session, asset_id: int) -> None:
@@ -190,7 +241,7 @@ def list_assets(
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all()
return [_to_read(a, _get_detail(session, a)) for a in assets]
return [_to_read(session, a, _get_detail(session, a)) for a in assets]
# --------------------------------------------------------------------------- #
@@ -241,6 +292,6 @@ def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
result.append(_to_read(a, _get_detail(session, a)))
result.append(_to_read(session, a, _get_detail(session, a)))
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result
+85
View File
@@ -0,0 +1,85 @@
"""平台/服务商业务逻辑"""
from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.provider import Provider, ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
def _to_read(provider: Provider) -> ProviderRead:
read = ProviderRead.model_validate(provider)
read.has_api_config = bool(provider.api_config_encrypted)
return read
def list_providers(
session: Session,
category: Optional[ProviderCategory] = None,
enabled: Optional[bool] = None,
) -> List[ProviderRead]:
stmt = select(Provider)
if category is not None:
stmt = stmt.where(Provider.category == category)
if enabled is not None:
stmt = stmt.where(Provider.enabled == enabled)
stmt = stmt.order_by(Provider.category.asc(), Provider.slug.asc())
return [_to_read(p) for p in session.exec(stmt).all()]
def get_provider(session: Session, provider_id: int) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return _to_read(provider)
def create_provider(session: Session, data: ProviderCreate) -> ProviderRead:
exists = session.exec(select(Provider).where(Provider.slug == data.slug)).first()
if exists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"slug 已存在:{data.slug}"
)
provider_data = data.model_dump(exclude={"api_config"})
provider = Provider(**provider_data)
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def update_provider(
session: Session, provider_id: int, data: ProviderUpdate
) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
fields = data.model_dump(exclude_unset=True, exclude={"api_config"})
for key, value in fields.items():
setattr(provider, key, value)
if data.api_config is not None:
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def delete_provider(session: Session, provider_id: int) -> None:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
session.delete(provider)
session.commit()
def get_api_config_plain(session: Session, provider_id: int) -> Optional[str]:
"""获取平台 API 配置明文(受保护接口使用)"""
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return crypto.decrypt(provider.api_config_encrypted)
+12 -252
View File
@@ -2,262 +2,22 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ app_name }}</title>
<script src="/static/js/tailwind.js"></script>
<script>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
<style>
html { -webkit-tap-highlight-color: transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(100,116,139,.3); border-radius: 4px; }
[v-cloak] { display: none; }
</style>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen">
<div id="app" class="max-w-7xl mx-auto px-4 py-6">
{% raw %}
<!-- 顶部栏 -->
<header class="flex flex-wrap items-center justify-between gap-3 mb-6">
<h1 class="text-2xl font-bold text-slate-900">📦 {{ appName }}</h1>
<div class="flex items-center gap-3">
<input v-model="search" @input="loadAssets" placeholder="搜索名称 / 服务商…"
class="px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm w-56">
<button @click="openCreate"
class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition">+ 新增资产</button>
</div>
</header>
<!-- 全局错误提示 -->
<div v-if="error" class="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm flex justify-between">
<span>⚠️ {{ error }}</span>
<button @click="error = ''" class="font-bold"></button>
</div>
<!-- Dashboard 卡片 -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="text-sm text-slate-500">总资产</div>
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.total || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-red-500">
<div class="text-sm text-slate-500">30 天内到期</div>
<div class="text-3xl font-bold text-red-600 mt-1">{{ overview.expiring_30 || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="text-sm text-slate-500">本年支出</div>
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.year_cost || 0 }}</div>
</div>
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-amber-500">
<div class="text-sm text-slate-500">异常状态</div>
<div class="text-3xl font-bold text-amber-600 mt-1">{{ overview.abnormal_count || 0 }}</div>
</div>
</section>
<!-- 续费倒计时 -->
<section v-if="expiring.length" class="bg-white rounded-xl p-4 shadow-sm mb-6">
<h2 class="font-semibold text-slate-900 mb-3">⏰ 续费倒计时(30 天内)</h2>
<div class="space-y-2">
<div v-for="a in expiring" :key="a.id"
class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50">
<span class="text-sm">
<span class="font-medium">{{ a.name }}</span>
<span class="text-slate-400"> · {{ a.provider }}</span>
</span>
<span class="text-sm px-2 py-0.5 rounded-full" :class="expiryBadge(a.days_to_expiry)">
{{ a.days_to_expiry }} 天
</span>
</div>
</div>
</section>
<!-- 筛选 + 资产表格 -->
<section class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="p-4 flex flex-wrap gap-2 border-b border-slate-100">
<select v-model="filterType" @change="loadAssets"
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
<option value="">全部类型</option>
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
</select>
<select v-model="filterStatus" @change="loadAssets"
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
<option value="">全部状态</option>
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
</select>
<span class="ml-auto text-sm text-slate-400 self-center">共 {{ assets.length }} 条</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-left">
<tr>
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">服务商</th>
<th class="px-4 py-3 font-medium">到期时间</th>
<th class="px-4 py-3 font-medium">剩余</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="!assets.length">
<td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产,点击右上角「新增资产」开始录入</td>
</tr>
<tr v-for="a in assets" :key="a.id" class="hover:bg-slate-50" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-3">
<span class="font-medium text-slate-900">{{ a.name }}</span>
<span v-if="a.is_archived" class="ml-1 text-xs text-slate-400">(已归档)</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs" :class="typeBadge(a.asset_type)">{{ typeLabels[a.asset_type] }}</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ a.provider }}</td>
<td class="px-4 py-3 text-slate-600">{{ a.expiry_date || '—' }}</td>
<td class="px-4 py-3" :class="expiryText(a.days_to_expiry)">
{{ a.days_to_expiry === null || a.days_to_expiry === undefined ? '—' : a.days_to_expiry + '天' }}
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs" :class="statusBadge(a.status)">{{ statusLabels[a.status] }}</span>
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button @click="openEdit(a)" class="text-blue-600 hover:underline mr-3">编辑</button>
<button @click="deleteAsset(a)" class="text-red-600 hover:underline">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 新增 / 编辑模态框 -->
<div v-if="showModal" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-xl">
<div class="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 class="font-semibold text-slate-900">{{ editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600"></button>
</div>
<form @submit.prevent="saveAsset" class="px-6 py-4 space-y-4">
<!-- 主表字段 -->
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block">
<span class="text-sm text-slate-600">名称 *</span>
<input v-model="form.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">类型 *</span>
<select v-model="form.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-600">服务商 *</span>
<input v-model="form.provider" required placeholder="aliyun / vultr / namesilo" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">所属账号</span>
<input v-model="form.account" placeholder="如 Cloudflare 邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">到期时间</span>
<input type="date" v-model="form.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">续费费用</span>
<input type="number" step="0.01" v-model.number="form.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
</label>
<label class="block">
<span class="text-sm text-slate-600">币种</span>
<select v-model="form.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-600">状态</span>
<select v-model="form.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
</select>
</label>
<div class="col-span-2 flex gap-6 text-sm text-slate-600">
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.is_archived"> 已归档(不再使用)</label>
</div>
<label class="col-span-2 block">
<span class="text-sm text-slate-600">备注</span>
<textarea v-model="form.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm"></textarea>
</label>
</div>
<!-- VPS 详情 -->
<fieldset v-if="form.asset_type === 'vps'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">公网 IP *</span>
<input v-model="form.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">Tailscale IP</span>
<input v-model="form.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">区域</span>
<input v-model="form.vps_detail.region" placeholder="Japan / US West" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">操作系统</span>
<input v-model="form.vps_detail.os" placeholder="Debian 12" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">CPU 核数</span>
<input type="number" v-model.number="form.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">内存 (GB)</span>
<input type="number" step="0.5" v-model.number="form.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">磁盘 (GB)</span>
<input type="number" v-model.number="form.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">月流量 (GB)</span>
<input type="number" v-model.number="form.vps_detail.bandwidth_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">SSH 端口</span>
<input type="number" v-model.number="form.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">面板 URL</span>
<input v-model="form.vps_detail.panel_url" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
</div>
</fieldset>
<!-- 域名详情 -->
<fieldset v-if="form.asset_type === 'domain'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="col-span-2 block"><span class="text-xs text-slate-500">域名 *</span>
<input v-model="form.domain_detail.domain_name" required placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">注册商</span>
<input v-model="form.domain_detail.registrar" placeholder="namesilo / 新网" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">DNS 托管</span>
<input v-model="form.domain_detail.dns_provider" placeholder="cloudflare" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">Cloudflare 账号</span>
<input v-model="form.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">跳转目标</span>
<input v-model="form.domain_detail.redirect_target" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" v-model="form.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<!-- AI 账号详情 -->
<fieldset v-if="form.asset_type === 'ai_agent'" class="border border-slate-200 rounded-lg p-3">
<legend class="text-sm font-medium text-slate-700 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">服务商 *</span>
<input v-model="form.ai_detail.provider" required placeholder="openai / kimi / deepseek" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">订阅计划</span>
<input v-model="form.ai_detail.plan" placeholder="ChatGPT Plus" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">API Key</span>
<input v-model="form.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">当前余额</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">币种</span>
<input v-model="form.ai_detail.currency" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">本月用量</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_usage" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">月度限额</span>
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
</div>
</fieldset>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="showModal = false" class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-600 hover:bg-slate-50">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">{{ editing ? '保存修改' : '创建资产' }}</button>
</div>
</form>
</div>
</div>
{% endraw %}
</div>
<script src="/static/js/app.js"></script>
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
<div id="app"></div>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>