refactor: 凭证下沉账号层——平台不再持有API凭证;账号支持登录凭证+API配置,SDK测试/同步改为账号维度,同步产出自动挂账号;含存量数据自动迁移

This commit is contained in:
gouki
2026-08-09 16:50:45 +00:00
parent cf18cadb6b
commit dc2134cf5a
9 changed files with 355 additions and 103 deletions
+90
View File
@@ -98,6 +98,18 @@ def _migrate_assets_db() -> None:
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段 # 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
_migrate_plaintext_api_keys(conn) _migrate_plaintext_api_keys(conn)
if insp.has_table("accounts"):
cols = {c["name"] for c in insp.get_columns("accounts")}
if "login_user" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_user VARCHAR"))
if "login_password_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_password_encrypted VARCHAR"))
if "api_config_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
if "last_synced_at" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
_backfill_account_credentials(conn)
def _migrate_indexes() -> None: def _migrate_indexes() -> None:
@@ -151,6 +163,84 @@ def _backfill_provider_services(conn) -> None:
) )
def _backfill_account_credentials(conn) -> None:
"""凭证下沉迁移(幂等,仅在首次加列后产生效果):
1. 平台凭证下沉:api_config_encrypted 非空的 Provider → 确保存在
{slug}-默认」账号并拷入凭证(不删平台原值,保留可回滚)。
2. AI 资产挂靠:account 为空的 ai_agent 资产 → 按 ai_accounts.provider
找/建账号并关联;账号无 API 配置时把该资产的 api_key 写入账号配置。
"""
import json
from datetime import datetime, timezone
import sqlalchemy as sa
from app.core.crypto import decrypt, encrypt
def _ensure_account(name: str, platform: str) -> int:
row = conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first()
if row:
return row[0]
conn.execute(
sa.text("INSERT INTO accounts (name, platform, created_at) VALUES (:name, :platform, :ts)"),
{"name": name, "platform": platform, "ts": datetime.now(timezone.utc).replace(tzinfo=None)},
)
return conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first()[0]
# 1. 平台凭证下沉到默认账号
for pid, slug, cfg in conn.execute(
sa.text(
"SELECT id, slug, api_config_encrypted FROM providers "
"WHERE api_config_encrypted IS NOT NULL AND api_config_encrypted != ''"
)
).fetchall():
acc_name = f"{slug}-默认"
_ensure_account(acc_name, slug)
conn.execute(
sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
),
{"cfg": cfg, "name": acc_name},
)
# 2. 无账号的 AI 资产按 provider 挂靠
rows = conn.execute(
sa.text(
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
"JOIN ai_accounts ai ON ai.asset_id = a.id "
"WHERE a.asset_type = 'ai_agent' AND (a.account IS NULL OR a.account = '')"
)
).fetchall()
for asset_id, provider in rows:
if not provider:
continue
_ensure_account(provider, provider)
conn.execute(
sa.text("UPDATE assets SET account = :acc WHERE id = :id"),
{"acc": provider, "id": asset_id},
)
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
key_row = conn.execute(
sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"),
{"id": asset_id},
).first()
if key_row and key_row[0]:
api_key = decrypt(key_row[0])
if api_key:
cfg_json = json.dumps({"api_key": api_key})
cfg_enc = encrypt(cfg_json)
if cfg_enc:
conn.execute(
sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
),
{"cfg": cfg_enc, "name": provider},
)
def get_session() -> Generator[Session, None, None]: def get_session() -> Generator[Session, None, None]:
"""资产库会话(默认) """资产库会话(默认)
+12
View File
@@ -202,6 +202,8 @@ class Account(SQLModel, table=True):
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属) """平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。 资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用),
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
""" """
__tablename__ = "accounts" __tablename__ = "accounts"
@@ -212,4 +214,14 @@ class Account(SQLModel, table=True):
default=None, index=True, description="所属平台(slug 或名称)" default=None, index=True, description="所属平台(slug 或名称)"
) )
remark: Optional[str] = Field(default=None, description="备注") remark: Optional[str] = Field(default=None, description="备注")
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
login_password_encrypted: Optional[str] = Field(
default=None, description="加密的登录密码(Fernet"
)
api_config_encrypted: Optional[str] = Field(
default=None, description="加密的 API 配置 JSONaccess_key/secret/api_key 等,供 SDK 同步)"
)
last_synced_at: Optional[datetime] = Field(
default=None, description="最近一次 SDK 同步时间"
)
created_at: datetime = Field(default_factory=utcnow, description="创建时间") created_at: datetime = Field(default_factory=utcnow, description="创建时间")
+20 -1
View File
@@ -2,6 +2,7 @@
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。 统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。 资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。
凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。
""" """
from typing import List from typing import List
@@ -12,7 +13,7 @@ from sqlmodel import Session
from app.core.security import require_api_key from app.core.security import require_api_key
from app.database import get_session from app.database import get_session
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import account_service from app.services import account_service, sync_service
router = APIRouter(prefix="/api/accounts", tags=["accounts"]) router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@@ -52,3 +53,21 @@ def update_account(
) )
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict: def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return account_service.delete_account(session, account_id) return account_service.delete_account(session, account_id)
@router.post(
"/{account_id}/test",
summary="测试账号凭证有效性(按账号所属平台的 SDK)",
dependencies=[Depends(require_api_key)],
)
def test_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return sync_service.test_account(session, account_id)
@router.post(
"/{account_id}/sync",
summary="同步账号资产到本地库(产出自动挂到该账号名下)",
dependencies=[Depends(require_api_key)],
)
def sync_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return sync_service.sync_account(session, account_id)
+15 -2
View File
@@ -1,4 +1,9 @@
"""平台账号 Schema""" """平台账号 Schema
凭证语义约定:
- login_password / api_configCreate 时传入即加密存储;Update 时 None 表示不修改。
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config)。
"""
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -10,19 +15,27 @@ class AccountBase(SQLModel):
name: str name: str
platform: Optional[str] = None platform: Optional[str] = None
remark: Optional[str] = None remark: Optional[str] = None
login_user: Optional[str] = None
class AccountCreate(AccountBase): class AccountCreate(AccountBase):
pass login_password: Optional[str] = None # 登录密码(明文传入,加密存储)
api_config: Optional[str] = None # API 配置 JSON 字符串(加密存储,供 SDK 同步)
class AccountUpdate(SQLModel): class AccountUpdate(SQLModel):
name: Optional[str] = None name: Optional[str] = None
platform: Optional[str] = None platform: Optional[str] = None
remark: Optional[str] = None remark: Optional[str] = None
login_user: Optional[str] = None
login_password: Optional[str] = None # None = 不修改
api_config: Optional[str] = None # None = 不修改
class AccountRead(AccountBase): class AccountRead(AccountBase):
id: int id: int
created_at: datetime created_at: datetime
asset_count: int = 0 # 引用该账号的资产数(列表接口填充) asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
has_login_password: bool = False
has_api_config: bool = False
last_synced_at: Optional[datetime] = None
+14 -1
View File
@@ -2,6 +2,7 @@
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。 账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
重命名账号时同步更新所有引用资产,保证两边一致。 重命名账号时同步更新所有引用资产,保证两边一致。
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
""" """
from typing import Dict, List from typing import Dict, List
@@ -10,6 +11,7 @@ from fastapi import HTTPException, status
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import Account, Asset from app.models.asset import Account, Asset
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
@@ -27,6 +29,8 @@ def _asset_counts(session: Session) -> Dict[str, int]:
def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead: def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead:
read = AccountRead.model_validate(account) read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.name, 0) read.asset_count = counts.get(account.name, 0)
read.has_login_password = bool(account.login_password_encrypted)
read.has_api_config = bool(account.api_config_encrypted)
return read return read
@@ -58,7 +62,9 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
if not name: if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
_check_name_taken(session, name) _check_name_taken(session, name)
account = Account(name=name, platform=data.platform, remark=data.remark) account = Account(name=name, platform=data.platform, remark=data.remark, login_user=data.login_user)
account.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(account) session.add(account)
session.commit() session.commit()
session.refresh(account) session.refresh(account)
@@ -83,6 +89,13 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
account.platform = data.platform or None account.platform = data.platform or None
if data.remark is not None: if data.remark is not None:
account.remark = data.remark or None account.remark = data.remark or None
if data.login_user is not None:
account.login_user = data.login_user or None
# 凭证:None = 不修改;空串 = 清除;非空 = 重新加密存储
if data.login_password is not None:
account.login_password_encrypted = crypto.encrypt(data.login_password)
if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(account) session.add(account)
session.commit() session.commit()
session.refresh(account) session.refresh(account)
+110 -21
View File
@@ -3,6 +3,10 @@
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo 将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo
写入或更新到资产库。以 (provider_id, external_id) 作为去重键, 写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
已存在则更新状态/详情,不存在则新建资产。 已存在则更新状态/详情,不存在则新建资产。
凭证层级:API 配置存在账号(Account.api_config_encrypted)上,
同步按账号维度进行(test_account/sync_account);同步产出的资产
自动挂到该账号名下(Asset.account)。平台不再持有凭证。
""" """
import json import json
@@ -20,6 +24,7 @@ from app.core import crypto
from app.core.timeutils import utcnow from app.core.timeutils import utcnow
from app.models.asset import ( from app.models.asset import (
AIAccount, AIAccount,
Account,
Asset, Asset,
AssetStatus, AssetStatus,
AssetType, AssetType,
@@ -32,7 +37,7 @@ _VALID_STATUS = {s.value for s in AssetStatus}
def _load_config(provider: Provider) -> dict: def _load_config(provider: Provider) -> dict:
"""解密平台 API 配置 JSON""" """(已弃用)解密平台 API 配置,仅为兼容历史数据保留"""
plain = crypto.decrypt(provider.api_config_encrypted) plain = crypto.decrypt(provider.api_config_encrypted)
if not plain: if not plain:
return {} return {}
@@ -42,6 +47,30 @@ def _load_config(provider: Provider) -> dict:
return {} return {}
def _load_account_config(account: Account) -> dict:
"""解密账号的 API 配置 JSON"""
plain = crypto.decrypt(account.api_config_encrypted)
if not plain:
return {}
try:
return json.loads(plain)
except (json.JSONDecodeError, TypeError):
return {}
def _find_provider_by_platform(session: Session, platform: str) -> Provider:
"""按账号的 platform(slug 或名称)定位平台"""
provider = session.exec(select(Provider).where(Provider.slug == platform)).first()
if not provider:
provider = session.exec(select(Provider).where(Provider.name == platform)).first()
if not provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"账号所属平台不存在:{platform}(请先在账号编辑中选择有效平台)",
)
return provider
def _get_provider(session: Session, provider_id: int) -> Provider: def _get_provider(session: Session, provider_id: int) -> Provider:
provider = session.get(Provider, provider_id) provider = session.get(Provider, provider_id)
if not provider: if not provider:
@@ -49,7 +78,7 @@ def _get_provider(session: Session, provider_id: int) -> Provider:
return provider return provider
def _build_adapter(provider: Provider) -> BaseAdapter: def _build_adapter(provider: Provider, config: dict) -> BaseAdapter:
if not provider.sdk_type: if not provider.sdk_type:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type" status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type"
@@ -59,7 +88,27 @@ def _build_adapter(provider: Provider) -> BaseAdapter:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"暂不支持的 SDK 类型:{provider.sdk_type}", detail=f"暂不支持的 SDK 类型:{provider.sdk_type}",
) )
return registry.get_adapter(provider.sdk_type, _load_config(provider)) return registry.get_adapter(provider.sdk_type, config)
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 _build_adapter_for_account(session: Session, account: Account):
"""按账号构建适配器:平台定 sdk_type,账号提供凭证"""
if not account.platform:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号未指定所属平台,无法确定 SDK 类型",
)
provider = _find_provider_by_platform(session, account.platform)
config = _load_account_config(account)
adapter = _build_adapter(provider, config)
return provider, adapter
def _norm_status(raw: Optional[str]) -> AssetStatus: def _norm_status(raw: Optional[str]) -> AssetStatus:
@@ -72,15 +121,37 @@ def _missing_config(adapter: BaseAdapter) -> list:
def test_provider(session: Session, provider_id: int) -> dict: def test_provider(session: Session, provider_id: int) -> dict:
"""测试平台连接 / 凭证有效性""" """(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
provider = _get_provider(session, provider_id) provider = _get_provider(session, provider_id)
adapter = _build_adapter(provider) account = _first_account_with_config(session, provider)
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type} if not account:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
)
return test_account(session, account.id)
def _first_account_with_config(session: Session, provider: Provider):
"""找该平台下第一个配置了 API 凭证的账号(兼容旧入口)"""
return session.exec(
select(Account)
.where(Account.platform == provider.slug)
.where(Account.api_config_encrypted.is_not(None)) # type: ignore[union-attr]
.order_by(Account.id.asc())
).first()
def test_account(session: Session, account_id: int) -> dict:
"""测试账号凭证有效性(按账号 platform 定 SDK,凭证取自账号)"""
account = _get_account(session, account_id)
provider, adapter = _build_adapter_for_account(session, account)
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type, "account": account.name}
missing = _missing_config(adapter) missing = _missing_config(adapter)
if missing: if missing:
return { return {
"ok": False, "ok": False,
"message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)", "message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)",
**base, **base,
} }
result = adapter.test_connection() result = adapter.test_connection()
@@ -98,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
).first() ).first()
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict:
created = updated = 0 created = updated = 0
for vps in adapter.list_vps(): for vps in adapter.list_vps():
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS) existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
@@ -108,6 +179,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
if vps.monthly_cost is not None: if vps.monthly_cost is not None:
existing.cost = vps.monthly_cost existing.cost = vps.monthly_cost
existing.currency = vps.currency existing.currency = vps.currency
if account_name:
existing.account = account_name
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(VPSDetail).where(VPSDetail.asset_id == existing.id) select(VPSDetail).where(VPSDetail.asset_id == existing.id)
@@ -128,6 +201,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=vps.external_id, external_id=vps.external_id,
account=account_name,
status=_norm_status(vps.status), status=_norm_status(vps.status),
cost=vps.monthly_cost or 0, cost=vps.monthly_cost or 0,
currency=vps.currency, currency=vps.currency,
@@ -150,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
return {"created": created, "updated": updated} return {"created": created, "updated": updated}
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict:
created = updated = 0 created = updated = 0
for dom in adapter.list_domains(): for dom in adapter.list_domains():
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN) existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
@@ -158,6 +232,8 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
existing.status = _norm_status(dom.status) existing.status = _norm_status(dom.status)
if dom.expiry_date: if dom.expiry_date:
existing.expiry_date = dom.expiry_date existing.expiry_date = dom.expiry_date
if account_name:
existing.account = account_name
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(DomainDetail).where(DomainDetail.asset_id == existing.id) select(DomainDetail).where(DomainDetail.asset_id == existing.id)
@@ -174,6 +250,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=dom.external_id, external_id=dom.external_id,
account=account_name,
status=_norm_status(dom.status), status=_norm_status(dom.status),
expiry_date=dom.expiry_date, expiry_date=dom.expiry_date,
) )
@@ -192,39 +269,51 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
def sync_provider(session: Session, provider_id: int) -> dict: def sync_provider(session: Session, provider_id: int) -> dict:
"""同步平台资产到本地库""" """(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
provider = _get_provider(session, provider_id) provider = _get_provider(session, provider_id)
adapter = _build_adapter(provider) account = _first_account_with_config(session, provider)
if not account:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
)
return sync_account(session, account.id)
def sync_account(session: Session, account_id: int) -> dict:
"""同步账号资产到本地库(凭证取自账号,同步产出自动挂到该账号名下)"""
account = _get_account(session, account_id)
provider, adapter = _build_adapter_for_account(session, account)
missing = _missing_config(adapter) missing = _missing_config(adapter)
if missing: if missing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)", detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
) )
caps = adapter.capabilities() caps = adapter.capabilities()
result = {"provider": provider.slug, "sdk_type": provider.sdk_type} result = {"provider": provider.slug, "sdk_type": provider.sdk_type, "account": account.name}
if caps["list_vps"]: if caps["list_vps"]:
try: try:
result["vps"] = _sync_vps(session, provider, adapter) result["vps"] = _sync_vps(session, provider, adapter, account.name)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["vps_error"] = str(e) result["vps_error"] = str(e)
if caps["list_domains"]: if caps["list_domains"]:
try: try:
result["domains"] = _sync_domains(session, provider, adapter) result["domains"] = _sync_domains(session, provider, adapter, account.name)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["domains_error"] = str(e) result["domains_error"] = str(e)
if caps["get_account"]: if caps["get_account"]:
try: try:
result["account"] = adapter.get_account().to_dict() result["account_info"] = adapter.get_account().to_dict()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["account_error"] = str(e) result["account_info_error"] = str(e)
provider.last_synced_at = utcnow() account.last_synced_at = utcnow()
session.add(provider) session.add(account)
session.commit() session.commit()
result["last_synced_at"] = provider.last_synced_at.isoformat() result["last_synced_at"] = account.last_synced_at.isoformat()
logger.info("同步平台 provider=%s result=%s", provider.slug, result) logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result)
return result return result
+77 -31
View File
@@ -160,14 +160,10 @@ const ProviderModal = {
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label> <label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
</div> </div>
</div> </div>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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="o in sdkOptions" :key="o.type" :value="o.type">{{ o.type }}</option></select></label> <label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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="o in sdkOptions" :key="o" :value="o">{{ o }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" 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="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" 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="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" 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="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" 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>
<div v-if="currentFields.length" class="col-span-2 border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2"> <div v-if="f.sdk_type" class="col-span-2 text-xs text-slate-400 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2">API 凭证请在平台账号中配置:保存后在平台卡片点「账号」→ 新增账号并填写 API 配置,即可测试/同步。</div>
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 凭证(加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span><input v-model="apiFields[field]" type="password" :placeholder="store.providerModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-else-if="f.sdk_type" class="col-span-2 text-xs text-amber-600 dark:text-amber-400">该 SDK 类型暂无预设凭证字段(可后续扩展)</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label> <label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@@ -179,19 +175,14 @@ const ProviderModal = {
</div>`, </div>`,
setup() { setup() {
const f = Vue.computed(() => store.providerModal.form); const f = Vue.computed(() => store.providerModal.form);
const adapterMeta = Vue.ref([]); const sdkOptions = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => { Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ } try {
}); const r = await Api.get('/providers/adapters');
const sdkOptions = Vue.computed(() => {
const opts = []; const opts = [];
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] }); for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
return opts; sdkOptions.value = opts;
}); } catch (e) { /* ignore */ }
const currentFields = Vue.computed(() => {
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
return opt ? opt.fields : [];
}); });
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合 // 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean))); const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
@@ -201,17 +192,8 @@ const ProviderModal = {
if (idx >= 0) arr.splice(idx, 1); else arr.push(k); if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
f.value.services = arr.join(','); f.value.services = arr.join(',');
} }
// 模态框每次打开时清空上次残留的凭证输入,避免串号 // 凭证已下沉到账号层,平台表单不再收集 API 凭证
Vue.watch(() => store.providerModal.show, (show) => { return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg);
saveProvider();
}
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
}, },
}; };
@@ -234,6 +216,17 @@ const AccountModal = {
<option value="">未指定</option> <option value="">未指定</option>
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option> <option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
</select></label> </select></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录用户名/邮箱</span>
<input v-model="f.login_user" 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"></label>
<label class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
<input v-model="f.login_password" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '可选'" 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>
</div>
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p> <p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
<div class="flex justify-end gap-2"> <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="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>
@@ -244,7 +237,29 @@ const AccountModal = {
</div>`, </div>`,
setup() { setup() {
const f = Vue.computed(() => store.accountModal.form); const f = Vue.computed(() => store.accountModal.form);
return { store, f, save: saveAccount }; const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
const currentFields = Vue.computed(() => {
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
if (!p || !p.sdk_type) return [];
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
return m ? (m.required_config || []) : [];
});
// 每次打开清空残留输入,避免串号
Vue.watch(() => store.accountModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
saveAccount();
}
return { store, f, currentFields, apiFields, save };
}, },
}; };
@@ -270,9 +285,14 @@ const AccountsViewModal = {
<div class="px-3 py-2 flex items-center gap-2"> <div class="px-3 py-2 flex items-center gap-2">
<button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button> <button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ a.remark || a.name }}</div> <div class="text-sm truncate">{{ a.remark || a.name }}
<span v-if="a.has_login_password" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-slate-100 dark:bg-slate-800 text-slate-400" title="已配置登录凭证">🔑登录</span>
<span v-if="a.has_api_config" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-500" title="已配置 API 凭证">⚙️API</span>
</div>
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div> <div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
</div> </div>
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button> <button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button> <button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
</div> </div>
@@ -322,13 +342,39 @@ const AccountsViewModal = {
return store.assets.filter(x => x.account === a.name); return store.assets.filter(x => x.account === a.name);
} }
function toggle(id) { expanded[id] = !expanded[id]; } function toggle(id) { expanded[id] = !expanded[id]; }
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
function providerOf(a) {
return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform);
}
function canSync(a) {
const p = providerOf(a);
return !!(a.has_api_config && p && p.sdk_type);
}
async function testAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/test', {});
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
} catch (e) { alert('测试失败:' + e.message); }
}
async function syncAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/sync', {});
const lines = ['同步完成'];
if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated);
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
alert(lines.join('\n'));
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { alert('同步失败:' + e.message); }
}
// 每次打开重置展开状态 // 每次打开重置展开状态
Vue.watch(() => store.accountsModal.show, (show) => { Vue.watch(() => store.accountsModal.show, (show) => {
if (show) for (const k of Object.keys(expanded)) delete expanded[k]; if (show) for (const k of Object.keys(expanded)) delete expanded[k];
}); });
return { return {
store, Fmt, expanded, singleProvider, title, accountList, groups, store, Fmt, expanded, singleProvider, title, accountList, groups,
platformName, assetsOf, toggle, platformName, assetsOf, toggle, canSync, testAcct, syncAcct,
add: () => openAccountCreate(store.accountsModal.providerSlug || ''), add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
edit: openAccountEdit, del: deleteAccount, edit: openAccountEdit, del: deleteAccount,
}; };
+13 -6
View File
@@ -141,15 +141,16 @@ async function deleteAsset(a) {
/* ---------------- 平台操作 ---------------- */ /* ---------------- 平台操作 ---------------- */
function openProviderCreate() { function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } }; store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
} }
function openProviderEdit(p) { function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } }; store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
} }
async function saveProvider() { async function saveProvider() {
store.error = ''; store.error = '';
const f = store.providerModal.form; const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null }; // 凭证已下沉到账号层,平台不再携带 api_config
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
try { try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload); if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload); else await Api.post('/providers', payload);
@@ -173,15 +174,21 @@ function openAccountsView(providerSlug) {
store.accountsModal = { show: true, providerSlug: providerSlug || null }; store.accountsModal = { show: true, providerSlug: providerSlug || null };
} }
function openAccountCreate(platform) { function openAccountCreate(platform) {
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '' } }; store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
} }
function openAccountEdit(a) { function openAccountEdit(a) {
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '' } }; // login_password / api_config 留空 = 不修改原凭证
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
} }
async function saveAccount() { async function saveAccount() {
store.error = ''; store.error = '';
const f = store.accountModal.form; const f = store.accountModal.form;
const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null }; const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null,
login_user: f.login_user || null,
login_password: f.login_password || null,
api_config: f.api_config || null,
};
try { try {
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload); if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
else await Api.post('/accounts', payload); else await Api.post('/accounts', payload);
+3 -40
View File
@@ -4,7 +4,7 @@ const ProvidersView = {
<div class="space-y-3"> <div class="space-y-3">
<!-- 说明条:让用户明白平台是什么 --> <!-- 说明条:让用户明白平台是什么 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2"> <div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。在「资产」中添加项目时选择归属平台;支持 API 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。 🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
</div> </div>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<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>
@@ -30,19 +30,14 @@ const ProvidersView = {
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button> <button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
<button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button> <button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button>
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span> <span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div> </div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div> <div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
</div> </div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap"> <div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button> <button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button> <button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div> </div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
</div> </div>
</div> </div>
<button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2"> <button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2">
@@ -50,11 +45,7 @@ const ProvidersView = {
</button> </button>
</div>`, </div>`,
setup() { setup() {
const testing = Vue.reactive({}); // 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
const syncing = Vue.reactive({});
const results = Vue.reactive({});
const supported = Vue.ref([]);
const capabilities = Vue.ref({});
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug // 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
const assetCounts = Vue.computed(() => { const assetCounts = Vue.computed(() => {
const m = {}; const m = {};
@@ -86,38 +77,10 @@ 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);
} }
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
function isSupported(t) { return !!t && supported.value.includes(t); }
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
function fmtTime(iso) { function fmtTime(iso) {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
async function test(p) { return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
testing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/test', {});
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { testing[p.id] = false; }
}
async function sync(p) {
syncing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/sync', {});
const parts = [];
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join('') || '无变化') };
await loadAll();
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; }
}
return { store, Fmt, testing, syncing, results, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
}, },
}; };