feat: 资产 CRUD API + 统计接口 + Vue3 管理前端(可选 API Key 鉴权)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""核心配置与安全模块"""
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic/SQLModel 数据传输模型"""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""业务逻辑层"""
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user