refactor: 凭证下沉账号层——平台不再持有API凭证;账号支持登录凭证+API配置,SDK测试/同步改为账号维度,同步产出自动挂账号;含存量数据自动迁移
This commit is contained in:
@@ -98,6 +98,18 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
|
||||
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
|
||||
_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:
|
||||
@@ -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]:
|
||||
"""资产库会话(默认)
|
||||
|
||||
|
||||
@@ -202,6 +202,8 @@ class Account(SQLModel, table=True):
|
||||
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
|
||||
|
||||
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。
|
||||
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用),
|
||||
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
|
||||
"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
@@ -212,4 +214,14 @@ class Account(SQLModel, table=True):
|
||||
default=None, index=True, description="所属平台(slug 或名称)"
|
||||
)
|
||||
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 配置 JSON(access_key/secret/api_key 等,供 SDK 同步)"
|
||||
)
|
||||
last_synced_at: Optional[datetime] = Field(
|
||||
default=None, description="最近一次 SDK 同步时间"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
|
||||
+20
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。
|
||||
凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
@@ -12,7 +13,7 @@ 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
|
||||
from app.services import account_service, sync_service
|
||||
|
||||
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:
|
||||
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
@@ -1,4 +1,9 @@
|
||||
"""平台账号 Schema"""
|
||||
"""平台账号 Schema
|
||||
|
||||
凭证语义约定:
|
||||
- login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -10,19 +15,27 @@ class AccountBase(SQLModel):
|
||||
name: str
|
||||
platform: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
login_user: Optional[str] = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
pass
|
||||
login_password: Optional[str] = None # 登录密码(明文传入,加密存储)
|
||||
api_config: Optional[str] = None # API 配置 JSON 字符串(加密存储,供 SDK 同步)
|
||||
|
||||
|
||||
class AccountUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
platform: 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):
|
||||
id: int
|
||||
created_at: datetime
|
||||
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||
has_login_password: bool = False
|
||||
has_api_config: bool = False
|
||||
last_synced_at: Optional[datetime] = None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
|
||||
重命名账号时同步更新所有引用资产,保证两边一致。
|
||||
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
@@ -10,6 +11,7 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Account, Asset
|
||||
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:
|
||||
read = AccountRead.model_validate(account)
|
||||
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
|
||||
|
||||
|
||||
@@ -58,7 +62,9 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
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)
|
||||
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.commit()
|
||||
session.refresh(account)
|
||||
@@ -83,6 +89,13 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
||||
account.platform = data.platform or None
|
||||
if data.remark is not 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.commit()
|
||||
session.refresh(account)
|
||||
|
||||
+110
-21
@@ -3,6 +3,10 @@
|
||||
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo)
|
||||
写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
|
||||
已存在则更新状态/详情,不存在则新建资产。
|
||||
|
||||
凭证层级:API 配置存在账号(Account.api_config_encrypted)上,
|
||||
同步按账号维度进行(test_account/sync_account);同步产出的资产
|
||||
自动挂到该账号名下(Asset.account)。平台不再持有凭证。
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -20,6 +24,7 @@ from app.core import crypto
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import (
|
||||
AIAccount,
|
||||
Account,
|
||||
Asset,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
@@ -32,7 +37,7 @@ _VALID_STATUS = {s.value for s in AssetStatus}
|
||||
|
||||
|
||||
def _load_config(provider: Provider) -> dict:
|
||||
"""解密平台的 API 配置 JSON"""
|
||||
"""(已弃用)解密平台级 API 配置,仅为兼容历史数据保留"""
|
||||
plain = crypto.decrypt(provider.api_config_encrypted)
|
||||
if not plain:
|
||||
return {}
|
||||
@@ -42,6 +47,30 @@ def _load_config(provider: Provider) -> dict:
|
||||
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:
|
||||
provider = session.get(Provider, provider_id)
|
||||
if not provider:
|
||||
@@ -49,7 +78,7 @@ def _get_provider(session: Session, provider_id: int) -> Provider:
|
||||
return provider
|
||||
|
||||
|
||||
def _build_adapter(provider: Provider) -> BaseAdapter:
|
||||
def _build_adapter(provider: Provider, config: dict) -> BaseAdapter:
|
||||
if not provider.sdk_type:
|
||||
raise HTTPException(
|
||||
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,
|
||||
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:
|
||||
@@ -72,15 +121,37 @@ def _missing_config(adapter: BaseAdapter) -> list:
|
||||
|
||||
|
||||
def test_provider(session: Session, provider_id: int) -> dict:
|
||||
"""测试平台连接 / 凭证有效性"""
|
||||
"""(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||||
provider = _get_provider(session, provider_id)
|
||||
adapter = _build_adapter(provider)
|
||||
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type}
|
||||
account = _first_account_with_config(session, provider)
|
||||
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)
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)",
|
||||
"message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)",
|
||||
**base,
|
||||
}
|
||||
result = adapter.test_connection()
|
||||
@@ -98,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
).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
|
||||
for vps in adapter.list_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:
|
||||
existing.cost = vps.monthly_cost
|
||||
existing.currency = vps.currency
|
||||
if account_name:
|
||||
existing.account = account_name
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
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_id=provider.id,
|
||||
external_id=vps.external_id,
|
||||
account=account_name,
|
||||
status=_norm_status(vps.status),
|
||||
cost=vps.monthly_cost or 0,
|
||||
currency=vps.currency,
|
||||
@@ -150,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
|
||||
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
|
||||
for dom in adapter.list_domains():
|
||||
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)
|
||||
if dom.expiry_date:
|
||||
existing.expiry_date = dom.expiry_date
|
||||
if account_name:
|
||||
existing.account = account_name
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
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_id=provider.id,
|
||||
external_id=dom.external_id,
|
||||
account=account_name,
|
||||
status=_norm_status(dom.status),
|
||||
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:
|
||||
"""同步平台资产到本地库"""
|
||||
"""(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||||
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)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)",
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||||
)
|
||||
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"]:
|
||||
try:
|
||||
result["vps"] = _sync_vps(session, provider, adapter)
|
||||
result["vps"] = _sync_vps(session, provider, adapter, account.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["vps_error"] = str(e)
|
||||
if caps["list_domains"]:
|
||||
try:
|
||||
result["domains"] = _sync_domains(session, provider, adapter)
|
||||
result["domains"] = _sync_domains(session, provider, adapter, account.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["domains_error"] = str(e)
|
||||
if caps["get_account"]:
|
||||
try:
|
||||
result["account"] = adapter.get_account().to_dict()
|
||||
result["account_info"] = adapter.get_account().to_dict()
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_error"] = str(e)
|
||||
result["account_info_error"] = str(e)
|
||||
|
||||
provider.last_synced_at = utcnow()
|
||||
session.add(provider)
|
||||
account.last_synced_at = utcnow()
|
||||
session.add(account)
|
||||
session.commit()
|
||||
result["last_synced_at"] = provider.last_synced_at.isoformat()
|
||||
logger.info("同步平台 provider=%s result=%s", provider.slug, result)
|
||||
result["last_synced_at"] = account.last_synced_at.isoformat()
|
||||
logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+77
-31
@@ -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>
|
||||
</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">管理面板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 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>
|
||||
<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>
|
||||
<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 class="flex justify-end gap-2">
|
||||
@@ -179,19 +175,14 @@ const ProviderModal = {
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.providerModal.form);
|
||||
const adapterMeta = Vue.ref([]);
|
||||
const apiFields = Vue.reactive({});
|
||||
const sdkOptions = Vue.ref([]);
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
|
||||
});
|
||||
const sdkOptions = Vue.computed(() => {
|
||||
try {
|
||||
const r = await Api.get('/providers/adapters');
|
||||
const opts = [];
|
||||
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] });
|
||||
return opts;
|
||||
});
|
||||
const currentFields = Vue.computed(() => {
|
||||
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
|
||||
return opt ? opt.fields : [];
|
||||
for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
|
||||
sdkOptions.value = opts;
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
|
||||
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);
|
||||
f.value.services = arr.join(',');
|
||||
}
|
||||
// 模态框每次打开时清空上次残留的凭证输入,避免串号
|
||||
Vue.watch(() => store.providerModal.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];
|
||||
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg);
|
||||
saveProvider();
|
||||
}
|
||||
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
|
||||
// 凭证已下沉到账号层,平台表单不再收集 API 凭证
|
||||
return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -234,6 +216,17 @@ const AccountModal = {
|
||||
<option value="">未指定</option>
|
||||
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
|
||||
</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>
|
||||
<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>
|
||||
@@ -244,7 +237,29 @@ const AccountModal = {
|
||||
</div>`,
|
||||
setup() {
|
||||
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">
|
||||
<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="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>
|
||||
<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="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
@@ -322,13 +342,39 @@ const AccountsViewModal = {
|
||||
return store.assets.filter(x => x.account === a.name);
|
||||
}
|
||||
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) => {
|
||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||
});
|
||||
return {
|
||||
store, Fmt, expanded, singleProvider, title, accountList, groups,
|
||||
platformName, assetsOf, toggle,
|
||||
platformName, assetsOf, toggle, canSync, testAcct, syncAcct,
|
||||
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
||||
edit: openAccountEdit, del: deleteAccount,
|
||||
};
|
||||
|
||||
+13
-6
@@ -141,15 +141,16 @@ async function deleteAsset(a) {
|
||||
|
||||
/* ---------------- 平台操作 ---------------- */
|
||||
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) {
|
||||
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() {
|
||||
store.error = '';
|
||||
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 {
|
||||
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
|
||||
else await Api.post('/providers', payload);
|
||||
@@ -173,15 +174,21 @@ function openAccountsView(providerSlug) {
|
||||
store.accountsModal = { show: true, providerSlug: providerSlug || null };
|
||||
}
|
||||
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) {
|
||||
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() {
|
||||
store.error = '';
|
||||
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 {
|
||||
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
|
||||
else await Api.post('/accounts', payload);
|
||||
|
||||
@@ -4,7 +4,7 @@ const ProvidersView = {
|
||||
<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">
|
||||
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。在「资产」中添加项目时选择归属平台;支持 API 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。
|
||||
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
|
||||
</div>
|
||||
<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>
|
||||
@@ -30,19 +30,14 @@ const ProvidersView = {
|
||||
<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="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>
|
||||
</div>
|
||||
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
|
||||
</div>
|
||||
<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 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>
|
||||
</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>
|
||||
<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>
|
||||
</div>`,
|
||||
setup() {
|
||||
const testing = Vue.reactive({});
|
||||
const syncing = Vue.reactive({});
|
||||
const results = Vue.reactive({});
|
||||
const supported = Vue.ref([]);
|
||||
const capabilities = Vue.ref({});
|
||||
// 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
|
||||
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
|
||||
const assetCounts = Vue.computed(() => {
|
||||
const m = {};
|
||||
@@ -86,38 +77,10 @@ const ProvidersView = {
|
||||
function serviceList(p) {
|
||||
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) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
async function test(p) {
|
||||
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 };
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user