feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用)
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
"""SQLite 数据库连接与初始化(分库)
|
"""SQLite 数据库连接与初始化(分库)
|
||||||
|
|
||||||
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount
|
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
|
||||||
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ engine = assets_engine
|
|||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""分库建表(幂等,可重复调用)"""
|
"""分库建表(幂等,可重复调用)"""
|
||||||
from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||||
from app.models.monitor import ( # noqa: F401
|
from app.models.monitor import ( # noqa: F401
|
||||||
EventLog,
|
EventLog,
|
||||||
MetricPoint,
|
MetricPoint,
|
||||||
@@ -57,7 +57,7 @@ def init_db() -> None:
|
|||||||
from app.models.provider import Provider # noqa: F401
|
from app.models.provider import Provider # noqa: F401
|
||||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||||
|
|
||||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert]
|
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Account]
|
||||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||||
|
|
||||||
SQLModel.metadata.create_all(
|
SQLModel.metadata.create_all(
|
||||||
|
|||||||
+40
-5
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -21,7 +22,7 @@ from sqlmodel import Session
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.seed import seed_providers
|
from app.core.seed import seed_providers
|
||||||
from app.database import assets_engine, init_db
|
from app.database import assets_engine, init_db
|
||||||
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync
|
from app.routers import accounts, agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||||
from app.services import cleanup_service
|
from app.services import cleanup_service
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -50,6 +51,30 @@ def _asset_version() -> str:
|
|||||||
|
|
||||||
_ASSET_VERSION_CACHE: str | None = None
|
_ASSET_VERSION_CACHE: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_app_version() -> str:
|
||||||
|
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev)"""
|
||||||
|
try:
|
||||||
|
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
|
||||||
|
except OSError:
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_git_commit() -> str:
|
||||||
|
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown)"""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
|
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
return out.stdout.strip() or "unknown"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = _read_app_version()
|
||||||
|
GIT_COMMIT = _read_git_commit()
|
||||||
|
|
||||||
# 全局日志配置:统一格式,便于生产环境排查
|
# 全局日志配置:统一格式,便于生产环境排查
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -71,7 +96,10 @@ async def lifespan(_: FastAPI):
|
|||||||
cleanup_task.cancel()
|
cleanup_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
|
app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
|
||||||
|
|
||||||
|
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
|
||||||
|
logging.getLogger(__name__).info("vps-manager 启动:版本 %s,commit %s", APP_VERSION, GIT_COMMIT)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -83,6 +111,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(assets.router)
|
app.include_router(assets.router)
|
||||||
app.include_router(providers.router)
|
app.include_router(providers.router)
|
||||||
|
app.include_router(accounts.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(monitor.router)
|
app.include_router(monitor.router)
|
||||||
@@ -105,7 +134,8 @@ app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
|||||||
def index() -> HTMLResponse:
|
def index() -> HTMLResponse:
|
||||||
"""前端 SPA 入口"""
|
"""前端 SPA 入口"""
|
||||||
html = jinja_env.get_template("index.html").render(
|
html = jinja_env.get_template("index.html").render(
|
||||||
app_name=settings.APP_NAME, asset_version=_asset_version()
|
app_name=settings.APP_NAME, asset_version=_asset_version(),
|
||||||
|
app_version=APP_VERSION, git_commit=GIT_COMMIT,
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@@ -129,5 +159,10 @@ def service_worker() -> FileResponse:
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check() -> dict:
|
def health_check() -> dict:
|
||||||
"""健康检查(version 动态读取,便于验证自动更新)"""
|
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本)"""
|
||||||
return {"status": "ok", "app": "vps-manager", "version": app.version}
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"app": "vps-manager",
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"commit": GIT_COMMIT,
|
||||||
|
}
|
||||||
|
|||||||
+19
-1
@@ -1,10 +1,11 @@
|
|||||||
"""资产数据库模型
|
"""资产数据库模型
|
||||||
|
|
||||||
包含四种核心资产模型:
|
包含五种核心模型:
|
||||||
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
|
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
|
||||||
- VPSDetail: VPS 资产详情(与 Asset 一对一关联)
|
- VPSDetail: VPS 资产详情(与 Asset 一对一关联)
|
||||||
- DomainDetail: 域名资产详情(与 Asset 一对一关联)
|
- DomainDetail: 域名资产详情(与 Asset 一对一关联)
|
||||||
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
|
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
|
||||||
|
- Account: 平台账号字典(Asset.account 按名称引用)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -195,3 +196,20 @@ class CloudflareDetail(SQLModel, table=True):
|
|||||||
default=None, index=True, description="关联的 zone/域名"
|
default=None, index=True, description="关联的 zone/域名"
|
||||||
)
|
)
|
||||||
status: Optional[str] = Field(default=None, description="子资产状态")
|
status: Optional[str] = Field(default=None, description="子资产状态")
|
||||||
|
|
||||||
|
|
||||||
|
class Account(SQLModel, table=True):
|
||||||
|
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
|
||||||
|
|
||||||
|
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: str = Field(unique=True, index=True, description="账号标识(如邮箱/用户名)")
|
||||||
|
platform: Optional[str] = Field(
|
||||||
|
default=None, index=True, description="所属平台(slug 或名称)"
|
||||||
|
)
|
||||||
|
remark: Optional[str] = Field(default=None, description="备注")
|
||||||
|
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""平台账号 CRUD 路由
|
||||||
|
|
||||||
|
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||||
|
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
from app.database import get_session
|
||||||
|
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||||
|
from app.services import account_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AccountRead], summary="账号列表(含关联资产数)")
|
||||||
|
def list_accounts(session: Session = Depends(get_session)):
|
||||||
|
return account_service.list_accounts(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=AccountRead,
|
||||||
|
status_code=201,
|
||||||
|
summary="创建账号",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def create_account(data: AccountCreate, session: Session = Depends(get_session)):
|
||||||
|
return account_service.create_account(session, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{account_id}",
|
||||||
|
response_model=AccountRead,
|
||||||
|
summary="更新账号(重命名会同步更新引用该账号的资产)",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def update_account(
|
||||||
|
account_id: int, data: AccountUpdate, session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
return account_service.update_account(session, account_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{account_id}",
|
||||||
|
summary="删除账号(资产中已填的账号名不受影响)",
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||||
|
return account_service.delete_account(session, account_id)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""平台账号 Schema"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
platform: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(AccountBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(SQLModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRead(AccountBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""平台账号业务逻辑
|
||||||
|
|
||||||
|
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
|
||||||
|
重命名账号时同步更新所有引用资产,保证两边一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.asset import Account, Asset
|
||||||
|
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_counts(session: Session) -> Dict[str, int]:
|
||||||
|
"""按 account 名称统计引用资产数"""
|
||||||
|
rows = session.exec(
|
||||||
|
select(Asset.account, func.count(Asset.id))
|
||||||
|
.where(Asset.account.is_not(None)) # type: ignore[union-attr]
|
||||||
|
.group_by(Asset.account)
|
||||||
|
).all()
|
||||||
|
return {name: cnt for name, cnt in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead:
|
||||||
|
read = AccountRead.model_validate(account)
|
||||||
|
read.asset_count = counts.get(account.name, 0)
|
||||||
|
return read
|
||||||
|
|
||||||
|
|
||||||
|
def list_accounts(session: Session) -> List[AccountRead]:
|
||||||
|
counts = _asset_counts(session)
|
||||||
|
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
|
||||||
|
return [_to_read(a, counts) for a in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_account(session: Session, account_id: int) -> Account:
|
||||||
|
account = session.get(Account, account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def _check_name_taken(session: Session, name: str, exclude_id: int | None = None) -> None:
|
||||||
|
stmt = select(Account).where(Account.name == name)
|
||||||
|
if exclude_id is not None:
|
||||||
|
stmt = stmt.where(Account.id != exclude_id)
|
||||||
|
if session.exec(stmt).first():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号已存在:{name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||||
|
name = data.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||||
|
_check_name_taken(session, name)
|
||||||
|
account = Account(name=name, platform=data.platform, remark=data.remark)
|
||||||
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(account)
|
||||||
|
return _to_read(account, _asset_counts(session))
|
||||||
|
|
||||||
|
|
||||||
|
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
|
||||||
|
account = _get_account(session, account_id)
|
||||||
|
if data.name is not None:
|
||||||
|
new_name = data.name.strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||||
|
if new_name != account.name:
|
||||||
|
_check_name_taken(session, new_name, exclude_id=account.id)
|
||||||
|
# 同步更新引用该账号的资产,避免重命名后资产端失联
|
||||||
|
assets = session.exec(select(Asset).where(Asset.account == account.name)).all()
|
||||||
|
for a in assets:
|
||||||
|
a.account = new_name
|
||||||
|
session.add(a)
|
||||||
|
account.name = new_name
|
||||||
|
if data.platform is not None:
|
||||||
|
account.platform = data.platform or None
|
||||||
|
if data.remark is not None:
|
||||||
|
account.remark = data.remark or None
|
||||||
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(account)
|
||||||
|
return _to_read(account, _asset_counts(session))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||||
|
account = _get_account(session, account_id)
|
||||||
|
affected = len(session.exec(select(Asset.id).where(Asset.account == account.name)).all())
|
||||||
|
session.delete(account)
|
||||||
|
session.commit()
|
||||||
|
# 资产端保留原账号名文本(不级联清空),由用户自行处理
|
||||||
|
return {"affected_assets": affected}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<script>tailwind.config = { darkMode: 'class' };</script>
|
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||||
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
|
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
|
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
|
||||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
|
<script>window.APP_CONFIG = { appName: "{{ app_name }}", version: "{{ app_version }}", commit: "{{ git_commit }}" };</script>
|
||||||
<style>
|
<style>
|
||||||
html { -webkit-tap-highlight-color: transparent; }
|
html { -webkit-tap-highlight-color: transparent; }
|
||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "部署完成!"
|
echo "部署完成!"
|
||||||
|
echo " 当前版本: v$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo dev) ($(git rev-parse --short HEAD 2>/dev/null || echo unknown))"
|
||||||
echo " 本地访问:http://127.0.0.1:8000"
|
echo " 本地访问:http://127.0.0.1:8000"
|
||||||
echo " Tailscale 内网:http://<本机tailscale_ip>:8000"
|
echo " Tailscale 内网:http://<本机tailscale_ip>:8000"
|
||||||
echo " 如需 PWA/HTTPS:tailscale serve --bg --https=443 http://127.0.0.1:8000"
|
echo " 如需 PWA/HTTPS:tailscale serve --bg --https=443 http://127.0.0.1:8000"
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ BRANCH="${GIT_BRANCH:-main}"
|
|||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
|
||||||
|
print_version() {
|
||||||
|
local semver commit
|
||||||
|
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
|
||||||
|
[ -z "$semver" ] && semver="dev"
|
||||||
|
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
|
||||||
|
}
|
||||||
|
|
||||||
git fetch -q origin "$BRANCH"
|
git fetch -q origin "$BRANCH"
|
||||||
|
|
||||||
LOCAL=$(git rev-parse HEAD)
|
LOCAL=$(git rev-parse HEAD)
|
||||||
@@ -26,6 +35,7 @@ REMOTE=$(git rev-parse "origin/$BRANCH")
|
|||||||
|
|
||||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||||
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
|
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
|
||||||
|
print_version
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -40,3 +50,4 @@ fi
|
|||||||
# 重启服务加载新代码
|
# 重启服务加载新代码
|
||||||
systemctl restart "$SERVICE_NAME"
|
systemctl restart "$SERVICE_NAME"
|
||||||
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
|
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
|
||||||
|
print_version
|
||||||
|
|||||||
+6
-1
@@ -53,6 +53,7 @@ const AppRoot = {
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
|
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||||
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
|
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
|
||||||
|
<div class="px-3 pt-1 text-[11px] text-slate-400 dark:text-slate-500" :title="'commit ' + appCommit">v{{ appVersion }} · {{ appCommit }}</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -82,9 +83,12 @@ const AppRoot = {
|
|||||||
|
|
||||||
<asset-modal></asset-modal>
|
<asset-modal></asset-modal>
|
||||||
<provider-modal></provider-modal>
|
<provider-modal></provider-modal>
|
||||||
|
<account-modal></account-modal>
|
||||||
</div>`,
|
</div>`,
|
||||||
setup() {
|
setup() {
|
||||||
const appName = Api.CFG.appName;
|
const appName = Api.CFG.appName;
|
||||||
|
const appVersion = Api.CFG.version || 'dev';
|
||||||
|
const appCommit = Api.CFG.commit || 'unknown';
|
||||||
const navs = NAVS;
|
const navs = NAVS;
|
||||||
const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]);
|
const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]);
|
||||||
const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view');
|
const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view');
|
||||||
@@ -107,7 +111,7 @@ const AppRoot = {
|
|||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => { loadAssets(); }, 300);
|
searchTimer = setTimeout(() => { loadAssets(); }, 300);
|
||||||
}
|
}
|
||||||
return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
|
return { store, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ app.component('servers-view', ServersView);
|
|||||||
app.component('settings-view', SettingsView);
|
app.component('settings-view', SettingsView);
|
||||||
app.component('asset-modal', AssetModal);
|
app.component('asset-modal', AssetModal);
|
||||||
app.component('provider-modal', ProviderModal);
|
app.component('provider-modal', ProviderModal);
|
||||||
|
app.component('account-modal', AccountModal);
|
||||||
applyDark();
|
applyDark();
|
||||||
initRouter();
|
initRouter();
|
||||||
loadAll();
|
loadAll();
|
||||||
|
|||||||
+43
-2
@@ -41,7 +41,10 @@ const AssetModal = {
|
|||||||
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
|
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
|
||||||
<input v-model="f.account" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
<input v-model="f.account" list="account-options" placeholder="在「平台」页管理账号" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
|
||||||
|
<datalist id="account-options">
|
||||||
|
<option v-for="a in store.accounts" :key="a.id" :value="a.name">{{ a.platform ? a.name + '(' + platformName(a.platform) + ')' : a.name }}</option>
|
||||||
|
</datalist></label>
|
||||||
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
|
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
|
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
|
||||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
|
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
|
||||||
@@ -128,7 +131,12 @@ const AssetModal = {
|
|||||||
const p = store.providers.find(x => x.id === f.value.provider_id);
|
const p = store.providers.find(x => x.id === f.value.provider_id);
|
||||||
if (p) f.value.provider = p.slug;
|
if (p) f.value.provider = p.slug;
|
||||||
}
|
}
|
||||||
return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset };
|
// 账号候选显示平台名:platform 存的是 slug,回退显示原值
|
||||||
|
function platformName(slug) {
|
||||||
|
const p = store.providers.find(x => x.slug === slug);
|
||||||
|
return p ? p.name : slug;
|
||||||
|
}
|
||||||
|
return { store, Fmt, f, providersForType, onProviderChange, platformName, save: saveAsset };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,3 +213,36 @@ const ProviderModal = {
|
|||||||
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
|
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 账号编辑模态框 */
|
||||||
|
const AccountModal = {
|
||||||
|
template: `
|
||||||
|
<div v-if="store.accountModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||||
|
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||||
|
<h3 class="font-semibold text-sm">{{ store.accountModal.editing ? '编辑账号' : '新增账号' }}</h3>
|
||||||
|
<button @click="store.accountModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||||
|
<label class="block"><span class="text-xs text-slate-500">账号标识 *(邮箱/用户名)</span>
|
||||||
|
<input v-model="f.name" required placeholder="如 you@example.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||||
|
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
|
||||||
|
<select v-model="f.platform" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
|
||||||
|
<option value="">未指定</option>
|
||||||
|
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="block"><span class="text-xs text-slate-500">备注</span>
|
||||||
|
<textarea v-model="f.remark" rows="2" placeholder="如:主账号/公司账号" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
|
||||||
|
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||||
|
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
setup() {
|
||||||
|
const f = Vue.computed(() => store.accountModal.form);
|
||||||
|
return { store, f, save: saveAccount };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
+32
-1
@@ -8,6 +8,7 @@ const store = reactive({
|
|||||||
view: 'dashboard',
|
view: 'dashboard',
|
||||||
assets: [],
|
assets: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
accounts: [],
|
||||||
subdomains: [],
|
subdomains: [],
|
||||||
siteCerts: [],
|
siteCerts: [],
|
||||||
overview: {},
|
overview: {},
|
||||||
@@ -23,6 +24,7 @@ const store = reactive({
|
|||||||
error: '',
|
error: '',
|
||||||
assetModal: { show: false, editing: null, form: null },
|
assetModal: { show: false, editing: null, form: null },
|
||||||
providerModal: { show: false, editing: null, form: null },
|
providerModal: { show: false, editing: null, form: null },
|
||||||
|
accountModal: { show: false, editing: null, form: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyDark() {
|
function applyDark() {
|
||||||
@@ -78,13 +80,14 @@ async function loadAssets() {
|
|||||||
store.assets = await Api.get('/assets?' + params.toString());
|
store.assets = await Api.get('/assets?' + params.toString());
|
||||||
}
|
}
|
||||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||||
|
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||||||
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
||||||
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||||
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
|
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
store.loading = true; store.error = '';
|
store.loading = true; store.error = '';
|
||||||
try { await Promise.all([loadAssets(), loadProviders(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
|
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
|
||||||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +165,34 @@ async function deleteProvider(p) {
|
|||||||
catch (e) { store.error = e.message; }
|
catch (e) { store.error = e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 账号操作 ---------------- */
|
||||||
|
function openAccountCreate(platform) {
|
||||||
|
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '' } };
|
||||||
|
}
|
||||||
|
function openAccountEdit(a) {
|
||||||
|
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '' } };
|
||||||
|
}
|
||||||
|
async function saveAccount() {
|
||||||
|
store.error = '';
|
||||||
|
const f = store.accountModal.form;
|
||||||
|
const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null };
|
||||||
|
try {
|
||||||
|
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
|
||||||
|
else await Api.post('/accounts', payload);
|
||||||
|
store.accountModal.show = false;
|
||||||
|
// 重命名会同步更新资产端引用,需一并刷新资产
|
||||||
|
await Promise.all([loadAccounts(), loadAssets()]);
|
||||||
|
} catch (e) { store.error = e.message; }
|
||||||
|
}
|
||||||
|
async function deleteAccount(a) {
|
||||||
|
let msg = '确认删除账号「' + a.name + '」?';
|
||||||
|
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
store.error = '';
|
||||||
|
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
|
||||||
|
catch (e) { store.error = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 服务器监控 ---------------- */
|
/* ---------------- 服务器监控 ---------------- */
|
||||||
async function viewServer(a) {
|
async function viewServer(a) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ const ProvidersView = {
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
|
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
|
||||||
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
|
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
|
||||||
|
<button @click="addAccount" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">+ 账号</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 账号管理:同一平台多账号时区分资产归属,资产表单「所属账号」引用此处 -->
|
||||||
|
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<h3 class="font-semibold text-sm">👤 我的账号({{ store.accounts.length }})</h3>
|
||||||
|
<button @click="addAccount" class="text-xs text-blue-600 dark:text-blue-400">+ 新增账号</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!store.accounts.length" class="text-xs text-slate-400">暂无账号。添加后,可在资产表单「所属账号」中快速选择,区分同一平台下的多个账号。</p>
|
||||||
|
<div v-else class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
|
<div v-for="a in store.accounts" :key="a.id" class="py-2 flex items-center gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm truncate">{{ a.name }}
|
||||||
|
<span v-if="a.platform" class="text-xs text-slate-400">@ {{ platformName(a.platform) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400">关联 {{ a.asset_count }} 个资产<span v-if="a.remark"> · {{ a.remark }}</span></div>
|
||||||
|
</div>
|
||||||
|
<button @click="editAccount(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
|
||||||
|
<button @click="delAccount(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
@@ -64,6 +86,11 @@ const ProvidersView = {
|
|||||||
function serviceList(p) {
|
function serviceList(p) {
|
||||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
// 账号所属平台显示名:platform 存的是 slug,回退显示原值
|
||||||
|
function platformName(slug) {
|
||||||
|
const p = store.providers.find(x => x.slug === slug);
|
||||||
|
return p ? p.name : slug;
|
||||||
|
}
|
||||||
Vue.onMounted(async () => {
|
Vue.onMounted(async () => {
|
||||||
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
|
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
|
||||||
});
|
});
|
||||||
@@ -96,6 +123,6 @@ const ProvidersView = {
|
|||||||
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
|
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
|
||||||
finally { syncing[p.id] = false; }
|
finally { syncing[p.id] = false; }
|
||||||
}
|
}
|
||||||
return { store, Fmt, testing, syncing, results, countOf, serviceList, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
|
return { store, Fmt, testing, syncing, results, countOf, serviceList, platformName, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, addAccount: () => openAccountCreate(''), editAccount: openAccountEdit, delAccount: deleteAccount };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,11 +37,18 @@ const SettingsView = {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
|
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
<h3 class="font-semibold text-sm mb-2">版本信息</h3>
|
||||||
|
<div class="text-xs text-slate-500">当前运行版本:<span class="text-slate-700 dark:text-slate-300 font-mono">v{{ version }} ({{ commit }})</span></div>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">与本地 git rev-parse --short HEAD 比对,可确认线上是否为最新代码。</p>
|
||||||
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
setup() {
|
setup() {
|
||||||
const notifyChannels = Vue.ref([]);
|
const notifyChannels = Vue.ref([]);
|
||||||
const notifyResult = Vue.ref('');
|
const notifyResult = Vue.ref('');
|
||||||
const dataResult = Vue.ref('');
|
const dataResult = Vue.ref('');
|
||||||
|
const version = Api.CFG.version || 'dev';
|
||||||
|
const commit = Api.CFG.commit || 'unknown';
|
||||||
Vue.onMounted(async () => {
|
Vue.onMounted(async () => {
|
||||||
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
||||||
});
|
});
|
||||||
@@ -91,6 +98,6 @@ const SettingsView = {
|
|||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
|
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile, version, commit };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user