diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f7526d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# vps-manager 配置示例(复制为 .env 后生效) + +# 系统显示名称 +APP_NAME=VPS 资产管理系统 + +# 写操作(POST/PUT/DELETE)鉴权密钥 +# 留空 = 不校验(适用于纯 Tailscale 内网环境) +# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥> +API_KEY= diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..b5fdd46 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""核心配置与安全模块""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..4b2abfc --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..3d18ec7 --- /dev/null +++ b/app/core/security.py @@ -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 无效或缺失", + ) diff --git a/app/database.py b/app/database.py index 8da6310..137cbcc 100644 --- a/app/database.py +++ b/app/database.py @@ -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 diff --git a/app/main.py b/app/main.py index 5212efc..13b80d2 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..fb31fe2 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""API 路由""" diff --git a/app/routers/assets.py b/app/routers/assets.py new file mode 100644 index 0000000..9a503dc --- /dev/null +++ b/app/routers/assets.py @@ -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) diff --git a/app/routers/stats.py b/app/routers/stats.py new file mode 100644 index 0000000..3fd1691 --- /dev/null +++ b/app/routers/stats.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..3ad111b --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic/SQLModel 数据传输模型""" diff --git a/app/schemas/asset.py b/app/schemas/asset.py new file mode 100644 index 0000000..4d4a6d6 --- /dev/null +++ b/app/schemas/asset.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..2ed763b --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""业务逻辑层""" diff --git a/app/services/asset_service.py b/app/services/asset_service.py new file mode 100644 index 0000000..d479590 --- /dev/null +++ b/app/services/asset_service.py @@ -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 diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..53f6bac --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,263 @@ + + +
+ + +| 名称 | +类型 | +服务商 | +到期时间 | +剩余 | +状态 | +操作 | +
|---|---|---|---|---|---|---|
| 暂无资产,点击右上角「新增资产」开始录入 | +||||||
| + {{ a.name }} + (已归档) + | ++ {{ typeLabels[a.asset_type] }} + | +{{ a.provider }} | +{{ a.expiry_date || '—' }} | ++ {{ a.days_to_expiry === null || a.days_to_expiry === undefined ? '—' : a.days_to_expiry + '天' }} + | ++ {{ statusLabels[a.status] }} + | ++ + + | +
g?u[c][f]=s+1:n.charAt(c-1)===i.charAt(f-1)?u[c][f]=u[c-1][f-1]:u[c][f]=Math.min(u[c-1][f-1]+1,Math.min(u[c][f-1]+1,u[c-1][f]+1)),u[c][f] d?X(e,r,i,!0,!1,p):E(t,n,o,r,i,l,c,a,p)},q=(e,t,n,o,r,i,l,c,a)=>{let u=0;const d=t.length;let p=e.length-1,h=d-1;for(;u<=p&&u<=h;){const s=e[u],o=t[u]=a?sr(t[u]):nr(t[u]);if(!zo(s,o))break;y(s,o,n,null,r,i,l,c,a),u++}for(;u<=p&&u<=h;){const s=e[p],o=t[h]=a?sr(t[h]):nr(t[h]);if(!zo(s,o))break;y(s,o,n,null,r,i,l,c,a),p--,h--}if(u>p){if(u<=h){const e=h+1,s=e{l();function _m(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Em(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Em(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=_m(r[i],e)+t;return t}return _m(r,e)}Tm.exports=Em});var Im=x((G9,Dm)=>{l();var Jn="-".charCodeAt(0),Xn="+".charCodeAt(0),nl=".".charCodeAt(0),oC="e".charCodeAt(0),lC="E".charCodeAt(0);function uC(r){var e=r.charCodeAt(0),t;if(e===Xn||e===Jn){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===nl&&i>=48&&i<=57}return e===nl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Dm.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!uC(r))return!1;for(i=r.charCodeAt(e),(i===Xn||i===Jn)&&e++;e{l();function Ag(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Og(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Og(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Ag(r[i],e)+t;return t}return Ag(r,e)}_g.exports=Og});var Pg=x((H8,Tg)=>{l();var as="-".charCodeAt(0),os="+".charCodeAt(0),Sl=".".charCodeAt(0),jC="e".charCodeAt(0),UC="E".charCodeAt(0);function VC(r){var e=r.charCodeAt(0),t;if(e===os||e===as){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===Sl&&i>=48&&i<=57}return e===Sl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Tg.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!VC(r))return!1;for(i=r.charCodeAt(e),(i===os||i===as)&&e++;e0;let f=!1,m=0,g=!1,v=!1,b=!1,_=!1,S=!1,x=!1;const C=[],k=e=>{u.length&&(d.push(Gl(tu(u),c)),u=[]),e&&d.push(e)},T=({key:e,value:n})=>{if(fc(e)){const r=e.content,l=i(r);if(!l||s&&!o||"onclick"===r.toLowerCase()||"onUpdate:modelValue"===r||w(r)||(_=!0),l&&w(r)&&(x=!0),l&&14===n.type&&(n=n.arguments[0]),20===n.type||(4===n.type||8===n.type)&&ya(n,t)>0)return;"ref"===r?g=!0:"class"===r?v=!0:"style"===r?b=!0:"key"===r||C.includes(r)||C.push(r),!s||"class"!==r&&"style"!==r||C.includes(r)||C.push(r)}else S=!0};for(let i=0;it(e,n,void 0,r&&r[n])));else{const n=Object.keys(e);o=new Array(n.length);for(let s=0,i=n.length;sv(e)?e:null==e?"":p(e)||b(e)&&(e.toString===S||!g(e.toString))?JSON.stringify(e,se,2):String(e),e.toHandlerKey=F,e.toHandlers=function(e,t){const n={};for(const s in e)n[t&&/[A-Z]/.test(s)?`on:${s}`:F(s)]=e[s];return n},e.toRaw=Ct,e.toRef=function(e,t,n){return It(e)?e:g(e)?new Dt(e):b(e)&&arguments.length>1?Ut(e,t,n):Rt(e)},e.toRefs=function(e){const t=p(e)?new Array(e.length):{};for(const n in e)t[n]=Ut(e,n);return t},e.toValue=function(e){return g(e)?e():Ft(e)},e.transformVNodeArgs=function(e){},e.triggerRef=function(e){At(e,4)},e.unref=Ft,e.useAttrs=function(){return Os().attrs},e.useCssModule=function(e="$style"){return n},e.useCssVars=function(e){const t=ur();if(!t)return;const n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach((e=>ri(e,n)))},s=()=>{const s=e(t.proxy);oi(t.subTree,s),n(s)};Mn(s),vs((()=>{const e=new MutationObserver(s);e.observe(t.subTree.el.parentNode,{childList:!0}),Ss((()=>e.disconnect()))}))},e.useModel=function(e,t,s=n){const o=ur(),r=I(t),i=O(t),l=Bt(((n,l)=>{let c;return Pn((()=>{const n=e[t];M(c,n)&&(c=n,l())})),{get:()=>(n(),s.get?s.get(c):c),set(e){const n=o.vnode.props;n&&(t in n||r in n||i in n)&&(`onUpdate:${t}`in n||`onUpdate:${r}`in n||`onUpdate:${i}`in n)||!M(e,c)||(c=e,l()),o.emit(`update:${t}`,s.set?s.set(e):e)}}})),c="modelValue"===t?"modelModifiers":`${t}Modifiers`;return l[Symbol.iterator]=()=>{let t=0;return{next:()=>t<2?{value:t++?e[c]||{}:l,done:!1}:{done:!0}}},l},e.useSSRContext=()=>{},e.useSlots=function(){return Os().slots},e.useTransitionState=Kn,e.vModelCheckbox=$i,e.vModelDynamic=qi,e.vModelRadio=Vi,e.vModelSelect=Di,e.vModelText=Pi,e.vShow=ti,e.version=Nr,e.warn=Ar,e.watch=Bn,e.watchEffect=function(e,t){return Vn(e,null,t)},e.watchPostEffect=Mn,e.watchSyncEffect=Pn,e.withAsyncContext=function(e){const t=ur();let n=e();return fr(),_(n)&&(n=n.catch((e=>{throw hr(t),e}))),[n,()=>hr(t)]},e.withCtx=gn,e.withDefaults=function(e,t){return null},e.withDirectives=function(e,t){if(null===hn)return e;const s=Cr(hn)||hn.proxy,o=e.dirs||(e.dirs=[]);for(let r=0;r