feat: 资产 CRUD API + 统计接口 + Vue3 管理前端(可选 API Key 鉴权)

This commit is contained in:
gouki
2026-08-01 15:39:13 +00:00
parent e29154518b
commit 1dac5d8735
20 changed files with 1105 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
# vps-manager 配置示例(复制为 .env 后生效)
# 系统显示名称
APP_NAME=VPS 资产管理系统
# 写操作(POST/PUT/DELETE)鉴权密钥
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
API_KEY=
+1
View File
@@ -0,0 +1 @@
"""核心配置与安全模块"""
+23
View File
@@ -0,0 +1,23 @@
"""应用配置
从项目根目录的 .env 文件加载配置(若存在)。
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。
"""
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent.parent
load_dotenv(BASE_DIR / ".env")
class Settings:
"""应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
API_KEY: str = os.getenv("API_KEY", "")
settings = Settings()
+25
View File
@@ -0,0 +1,25 @@
"""API Key 认证依赖
用于保护写操作(POST/PUT/DELETE)。
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
"""
from typing import Optional
from fastapi import Header, HTTPException, status
from app.core.config import settings
async def require_api_key(
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None:
"""校验 API Key(可选启用)"""
if not settings.API_KEY:
return
if x_api_key != settings.API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失",
)
+8 -1
View File
@@ -1,8 +1,9 @@
"""SQLite 数据库连接与初始化"""
from pathlib import Path
from typing import Generator
from sqlmodel import SQLModel, create_engine
from sqlmodel import Session, SQLModel, create_engine
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
@@ -19,3 +20,9 @@ def init_db() -> None:
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
"""FastAPI 依赖:提供数据库会话(请求结束后自动关闭)"""
with Session(engine) as session:
yield session
+42 -2
View File
@@ -1,10 +1,31 @@
"""vps-manager 后端入口"""
"""vps-manager 后端入口
- 注册资产 CRUD 与统计路由
- 挂载本地静态资源(Vue/Tailwind/前端逻辑)
- 渲染前端 SPA 入口页面
- 启用 CORS(便于外部程序/其他 AI 跨域调用写入接口)
"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from app.core.config import settings
from app.database import init_db
from app.routers import assets, stats
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
@asynccontextmanager
@@ -14,7 +35,26 @@ async def lifespan(_: FastAPI):
yield
app = FastAPI(title="VPS 资产管理系统", version="0.1.2", lifespan=lifespan)
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(assets.router)
app.include_router(stats.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/", include_in_schema=False)
def index() -> HTMLResponse:
"""前端 SPA 入口"""
html = jinja_env.get_template("index.html").render(app_name=settings.APP_NAME)
return HTMLResponse(html)
@app.get("/health")
+1
View File
@@ -0,0 +1 @@
"""API 路由"""
+70
View File
@@ -0,0 +1,70 @@
"""资产 CRUD 路由
统一入口 /api/assets,写操作(POST/PUT/DELETE)受 API Key 保护。
"""
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.database import get_session
from app.models.asset import AssetStatus, AssetType
from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate
from app.services import asset_service
router = APIRouter(prefix="/api/assets", tags=["assets"])
@router.get("", response_model=List[AssetRead], summary="资产列表")
def list_assets(
asset_type: Optional[AssetType] = Query(default=None, description="按类型筛选"),
status: Optional[AssetStatus] = Query(default=None, description="按状态筛选"),
is_archived: Optional[bool] = Query(default=None, description="按归档标记筛选"),
q: Optional[str] = Query(default=None, description="搜索名称/服务商"),
sort: str = Query(default="expiry_date", description="排序字段"),
order: str = Query(default="asc", description="排序方向 asc/desc"),
session: Session = Depends(get_session),
):
return asset_service.list_assets(
session, asset_type, status, is_archived, q, sort, order
)
@router.get("/{asset_id}", response_model=AssetRead, summary="资产详情")
def get_asset(asset_id: int, session: Session = Depends(get_session)):
return asset_service.get_asset(session, asset_id)
@router.post(
"",
response_model=AssetRead,
status_code=201,
summary="创建资产",
dependencies=[Depends(require_api_key)],
)
def create_asset(data: AssetCreate, session: Session = Depends(get_session)):
return asset_service.create_asset(session, data)
@router.put(
"/{asset_id}",
response_model=AssetRead,
summary="更新资产",
dependencies=[Depends(require_api_key)],
)
def update_asset(
asset_id: int, data: AssetUpdate, session: Session = Depends(get_session)
):
return asset_service.update_asset(session, asset_id, data)
@router.delete(
"/{asset_id}",
status_code=204,
summary="删除资产",
dependencies=[Depends(require_api_key)],
)
def delete_asset(asset_id: int, session: Session = Depends(get_session)):
asset_service.delete_asset(session, asset_id)
+25
View File
@@ -0,0 +1,25 @@
"""统计路由:资产总览与续费倒计时"""
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session
from app.database import get_session
from app.schemas.asset import AssetRead
from app.services import asset_service
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("/overview", summary="资产总览")
def overview(session: Session = Depends(get_session)) -> dict:
return asset_service.get_overview(session)
@router.get("/expiring", response_model=List[AssetRead], summary="续费倒计时")
def expiring(
days: int = Query(default=30, ge=1, le=365, description="统计未来 N 天内到期"),
session: Session = Depends(get_session),
):
return asset_service.get_expiring(session, days)
+1
View File
@@ -0,0 +1 @@
"""Pydantic/SQLModel 数据传输模型"""
+132
View File
@@ -0,0 +1,132 @@
"""资产相关 Schema(请求/响应模型)
设计说明:
- *In 系列:详情表输入模型(不含 id/asset_id,由服务端填充)
- AssetCreate/AssetUpdate:主表字段 + 按类型嵌套的可选 detail
- AssetRead:完整输出,含计算字段 days_to_expiry(距到期天数)
"""
from datetime import date, datetime
from typing import Optional
from sqlmodel import SQLModel
from app.models.asset import AssetStatus, AssetType
# --------------------------------------------------------------------------- #
# 详情表输入模型
# --------------------------------------------------------------------------- #
class VPSDetailIn(SQLModel):
"""VPS 详情输入"""
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
class DomainDetailIn(SQLModel):
"""域名详情输入"""
domain_name: str
registrar: Optional[str] = None
dns_provider: Optional[str] = None
cloudflare_account: Optional[str] = None
is_using: bool = True
redirect_target: Optional[str] = None
class AIAccountIn(SQLModel):
"""AI 账号详情输入"""
provider: str
api_key: Optional[str] = None
plan: Optional[str] = None
balance: Optional[float] = None
currency: str = "USD"
monthly_usage: Optional[float] = None
monthly_limit: Optional[float] = None
# --------------------------------------------------------------------------- #
# 资产主表模型
# --------------------------------------------------------------------------- #
class AssetBase(SQLModel):
"""资产主表公共字段"""
name: str
asset_type: AssetType
provider: str
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: bool = False
cost: float = 0
currency: str = "USD"
status: AssetStatus = AssetStatus.ACTIVE
is_archived: bool = False
remark: Optional[str] = None
class AssetCreate(AssetBase):
"""创建资产:按 asset_type 携带对应 detail"""
vps_detail: Optional[VPSDetailIn] = None
domain_detail: Optional[DomainDetailIn] = None
ai_detail: Optional[AIAccountIn] = None
class AssetUpdate(SQLModel):
"""更新资产:所有字段可选,仅更新传入项"""
name: Optional[str] = None
asset_type: Optional[AssetType] = None
provider: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None
cost: Optional[float] = None
currency: Optional[str] = None
status: Optional[AssetStatus] = None
is_archived: Optional[bool] = None
remark: Optional[str] = None
vps_detail: Optional[VPSDetailIn] = None
domain_detail: Optional[DomainDetailIn] = None
ai_detail: Optional[AIAccountIn] = None
# --------------------------------------------------------------------------- #
# 输出模型
# --------------------------------------------------------------------------- #
class VPSDetailRead(VPSDetailIn):
id: int
asset_id: int
class DomainDetailRead(DomainDetailIn):
id: int
asset_id: int
class AIAccountRead(AIAccountIn):
id: int
asset_id: int
last_synced_at: Optional[datetime] = None
class AssetRead(AssetBase):
"""资产完整输出"""
id: int
created_at: datetime
updated_at: datetime
days_to_expiry: Optional[int] = None
vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None
+1
View File
@@ -0,0 +1 @@
"""业务逻辑层"""
+246
View File
@@ -0,0 +1,246 @@
"""资产业务逻辑:CRUD + 统计聚合
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
"""
from datetime import date
from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.models.asset import (
AIAccount,
Asset,
AssetStatus,
AssetType,
DomainDetail,
VPSDetail,
)
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
AssetRead,
AssetUpdate,
DomainDetailRead,
VPSDetailRead,
)
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
DETAIL_MAP = {
AssetType.VPS: ("vps_detail", VPSDetail),
AssetType.DOMAIN: ("domain_detail", DomainDetail),
AssetType.AI_AGENT: ("ai_detail", AIAccount),
}
# 允许排序的字段白名单
SORTABLE_FIELDS = {"expiry_date", "name", "created_at", "cost", "updated_at"}
# --------------------------------------------------------------------------- #
# 辅助函数
# --------------------------------------------------------------------------- #
def _days_to_expiry(expiry_date: Optional[date]) -> Optional[int]:
"""计算距到期天数(已过期为负数),无到期日返回 None"""
if not expiry_date:
return None
return (expiry_date - date.today()).days
def _get_detail(session: Session, asset: Asset):
"""根据资产类型读取对应详情记录"""
item = DETAIL_MAP.get(asset.asset_type)
if not item:
return None
_, model = item
return session.exec(select(model).where(model.asset_id == asset.id)).first()
def _to_read(asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
if isinstance(detail, VPSDetail):
read.vps_detail = VPSDetailRead.model_validate(detail)
elif isinstance(detail, DomainDetail):
read.domain_detail = DomainDetailRead.model_validate(detail)
elif isinstance(detail, AIAccount):
read.ai_detail = AIAccountRead.model_validate(detail)
return read
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
"""创建资产及其详情"""
item = DETAIL_MAP.get(data.asset_type)
detail_in = None
model = None
if item:
field, model = item
detail_in = getattr(data, field)
if detail_in is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"asset_type={data.asset_type.value} 需要提供 {field}",
)
asset_data = data.model_dump(
exclude={"vps_detail", "domain_detail", "ai_detail"}
)
asset = Asset(**asset_data)
session.add(asset)
session.commit()
session.refresh(asset)
detail = None
if model is not None and detail_in is not None:
detail = model(asset_id=asset.id, **detail_in.model_dump())
session.add(detail)
session.commit()
session.refresh(detail)
return _to_read(asset, detail)
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))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
"""更新资产主表及详情(仅更新传入字段)"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
main_fields = data.model_dump(
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail"}
)
for key, value in main_fields.items():
setattr(asset, key, value)
session.add(asset)
# 详情表:以更新后的 asset_type 为准
detail = None
item = DETAIL_MAP.get(asset.asset_type)
if item:
field, model = item
detail_in = getattr(data, field)
existing = session.exec(
select(model).where(model.asset_id == asset.id)
).first()
if detail_in is not None:
if existing:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
session.add(existing)
detail = existing
else:
detail = model(asset_id=asset.id, **detail_in.model_dump())
session.add(detail)
else:
detail = existing
session.commit()
session.refresh(asset)
if detail is not None:
session.refresh(detail)
return _to_read(asset, detail)
def delete_asset(session: Session, asset_id: int) -> None:
"""删除资产及其详情"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
detail = _get_detail(session, asset)
if detail is not None:
session.delete(detail)
session.delete(asset)
session.commit()
def list_assets(
session: Session,
asset_type: Optional[AssetType] = None,
asset_status: Optional[AssetStatus] = None,
is_archived: Optional[bool] = None,
q: Optional[str] = None,
sort: str = "expiry_date",
order: str = "asc",
) -> List[AssetRead]:
"""资产列表(筛选 + 搜索 + 排序)"""
stmt = select(Asset)
if asset_type is not None:
stmt = stmt.where(Asset.asset_type == asset_type)
if asset_status is not None:
stmt = stmt.where(Asset.status == asset_status)
if is_archived is not None:
stmt = stmt.where(Asset.is_archived == is_archived)
if q:
pattern = f"%{q}%"
stmt = stmt.where(Asset.name.like(pattern) | Asset.provider.like(pattern))
sort_col = getattr(Asset, sort if sort in SORTABLE_FIELDS else "expiry_date")
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]
# --------------------------------------------------------------------------- #
# 统计
# --------------------------------------------------------------------------- #
def get_overview(session: Session) -> dict:
"""资产总览:数量分布、异常数、到期预警、支出合计"""
assets = session.exec(select(Asset)).all()
today = date.today()
by_type: dict = {}
by_status: dict = {}
abnormal = 0
expiring_30 = 0
year_cost = 0.0
month_cost = 0.0
for a in assets:
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
abnormal += 1
if a.expiry_date:
days = (a.expiry_date - today).days
if 0 <= days <= 30:
expiring_30 += 1
if a.expiry_date.year == today.year:
year_cost += a.cost
if a.expiry_date.month == today.month:
month_cost += a.cost
return {
"total": len(assets),
"by_type": by_type,
"by_status": by_status,
"abnormal_count": abnormal,
"expiring_30": expiring_30,
"year_cost": round(year_cost, 2),
"month_cost": round(month_cost, 2),
}
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
"""N 天内到期资产列表(按剩余天数升序)"""
today = date.today()
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
result = []
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
result.append(_to_read(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
+263
View File
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }}</title>
<script src="/static/js/tailwind.js"></script>
<script src="/static/js/vue.global.prod.js"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
</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>
</html>
+11
View File
@@ -0,0 +1,11 @@
# vps-manager 自动更新服务(oneshot,由 timer 触发)
# 部署位置:/etc/systemd/system/vps-manager-update.service
[Unit]
Description=Update vps-manager from Gitea
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/vps-manager/deploy/update.sh
+13
View File
@@ -0,0 +1,13 @@
# vps-manager 自动更新定时器(每 5 分钟检查一次)
# 部署位置:/etc/systemd/system/vps-manager-update.timer
[Unit]
Description=Periodic update check for vps-manager
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Unit=vps-manager-update.service
[Install]
WantedBy=timers.target
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.30
sqlmodel>=0.0.22
pydantic>=2.7
python-dotenv>=1.0
jinja2>=3.1
+157
View File
@@ -0,0 +1,157 @@
/* vps-manager 前端逻辑(Vue 3 全局构建) */
const { createApp, ref, reactive, onMounted } = Vue;
const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
// 若服务端 .env 配置了 API_KEY,请在此填写以通过写操作鉴权
const API_KEY = '';
const TYPE_LABELS = { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' };
const STATUS_LABELS = { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' };
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['X-API-Key'] = API_KEY;
const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function emptyForm() {
return {
name: '', asset_type: 'vps', provider: '', account: '', expiry_date: '',
auto_renew: false, cost: 0, currency: 'USD', status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '' },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
};
}
// 空字符串转 null(可选字段),保留数字与布尔
function cleanDetail(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (v === '' ? null : v);
}
return out;
}
createApp({
setup() {
const appName = CFG.appName;
const assets = ref([]);
const overview = ref({});
const expiring = ref([]);
const loading = ref(false);
const search = ref('');
const filterType = ref('');
const filterStatus = ref('');
const showModal = ref(false);
const editing = ref(null);
const form = reactive(emptyForm());
const error = ref('');
const typeLabels = TYPE_LABELS;
const statusLabels = STATUS_LABELS;
async function loadAssets() {
loading.value = true;
try {
const params = new URLSearchParams();
if (filterType.value) params.set('asset_type', filterType.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (search.value) params.set('q', search.value);
params.set('sort', 'expiry_date');
params.set('order', 'asc');
assets.value = await api('/assets?' + params.toString());
} catch (e) { error.value = e.message; } finally { loading.value = false; }
}
async function loadOverview() {
try { overview.value = await api('/stats/overview'); } catch (e) { error.value = e.message; }
}
async function loadExpiring() {
try { expiring.value = await api('/stats/expiring?days=30'); } catch (e) { error.value = e.message; }
}
async function loadAll() { await Promise.all([loadAssets(), loadOverview(), loadExpiring()]); }
function resetForm() { Object.assign(form, emptyForm()); }
function openCreate() { resetForm(); editing.value = null; showModal.value = true; }
function openEdit(a) {
resetForm();
editing.value = a.id;
['name', 'asset_type', 'provider', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark']
.forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
showModal.value = true;
}
function buildPayload() {
const p = {
name: form.name, asset_type: form.asset_type, provider: form.provider,
account: form.account || null, expiry_date: form.expiry_date || null,
auto_renew: !!form.auto_renew, cost: Number(form.cost) || 0, currency: form.currency,
status: form.status, is_archived: !!form.is_archived, remark: form.remark || null,
};
if (form.asset_type === 'vps') p.vps_detail = cleanDetail(form.vps_detail);
else if (form.asset_type === 'domain') p.domain_detail = cleanDetail(form.domain_detail);
else if (form.asset_type === 'ai_agent') p.ai_detail = cleanDetail(form.ai_detail);
return p;
}
async function saveAsset() {
error.value = '';
try {
const payload = buildPayload();
if (editing.value) {
await api('/assets/' + editing.value, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await api('/assets', { method: 'POST', body: JSON.stringify(payload) });
}
showModal.value = false;
await loadAll();
} catch (e) { error.value = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
error.value = '';
try { await api('/assets/' + a.id, { method: 'DELETE' }); await loadAll(); }
catch (e) { error.value = e.message; }
}
// 样式辅助
function typeBadge(t) {
return { vps: 'bg-blue-100 text-blue-700', domain: 'bg-green-100 text-green-700', ai_agent: 'bg-purple-100 text-purple-700', cloudflare: 'bg-orange-100 text-orange-700', other: 'bg-slate-100 text-slate-600' }[t] || 'bg-slate-100 text-slate-600';
}
function statusBadge(s) {
return { active: 'bg-green-100 text-green-700', expired: 'bg-red-100 text-red-700', stopped: 'bg-slate-200 text-slate-600', cancelled: 'bg-slate-200 text-slate-500', unknown: 'bg-yellow-100 text-yellow-700' }[s] || 'bg-slate-100 text-slate-600';
}
function expiryText(d) {
if (d === null || d === undefined) return 'text-slate-400';
if (d < 0) return 'text-red-600 font-bold';
if (d <= 7) return 'text-red-600 font-semibold';
if (d <= 30) return 'text-amber-600';
return 'text-slate-600';
}
function expiryBadge(d) {
if (d < 0) return 'bg-red-100 text-red-700';
if (d <= 7) return 'bg-red-100 text-red-700';
if (d <= 30) return 'bg-amber-100 text-amber-700';
return 'bg-slate-100 text-slate-600';
}
onMounted(loadAll);
return {
appName, assets, overview, expiring, loading, search, filterType, filterStatus,
showModal, editing, form, error, typeLabels, statusLabels,
loadAssets, openCreate, openEdit, saveAsset, deleteAsset,
typeBadge, statusBadge, expiryText, expiryBadge,
};
},
}).mount('#app');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long