From b866b387e0da70d48887e63f10b76909c65ca7bd Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Wed, 3 Jun 2026 17:03:11 +0800 Subject: [PATCH] feat: sync upstream keys and reorder priorities --- backend/app/routers/websites.py | 49 ++++++++- backend/app/schemas/website.py | 20 ++++ backend/app/services/upstream_client.py | 115 ++++++++++++++++----- backend/app/services/website_sync.py | 40 +++++--- backend/test_priority_sync.py | 68 ++++++++++--- backend/test_upstream_key_sync.py | 130 ++++++++++++++++++++++++ frontend/src/api/index.ts | 12 +++ frontend/src/views/Websites.vue | 38 ++++++- 8 files changed, 415 insertions(+), 57 deletions(-) diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 0808731..e5fc1a5 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -23,6 +23,9 @@ from app.schemas.website import ( ImportGroupItem, ImportGroupsRequest, ImportGroupsResponse, + ReorderPriorityItem, + ReorderPriorityRequest, + ReorderPriorityResponse, SyncImportStatusRequest, TestResult, WebsiteCreate, @@ -34,7 +37,13 @@ from app.schemas.website import ( ) from app.services.website_client import Sub2ApiWebsiteClient -from app.services.website_sync import binding_sources, sync_binding, build_rate_priority_map, reconcile_upstream_keys_full +from app.services.website_sync import ( + binding_sources, + sync_binding, + build_rate_priority_map, + reconcile_upstream_keys_full, + sync_account_priorities_for_upstream, +) from app.utils.auth import get_current_user router = APIRouter(tags=["websites"]) @@ -463,6 +472,44 @@ def sync_imported_upstream_keys( ) +@router.post("/api/websites/{wid}/accounts/reorder-priority", response_model=ReorderPriorityResponse) +def reorder_account_priorities( + wid: int, + body: ReorderPriorityRequest, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + website = db.query(Website).filter(Website.id == wid).first() + if not website: + raise HTTPException(404, "website not found") + if website.site_type != "sub2api": + raise HTTPException(400, "目前只支持 sub2api") + + upstream = db.query(Upstream).filter(Upstream.id == body.upstream_id).first() + if not upstream: + raise HTTPException(404, "upstream not found") + + results = sync_account_priorities_for_upstream(db, body.upstream_id, website_id=wid) + failed_count = sum(1 for item in results if item.get("status") == "failed") + success_count = sum(1 for item in results if item.get("status") == "success") + skipped_count = sum(1 for item in results if item.get("status") == "skipped") + + parts = [] + if success_count: + parts.append(f"更新 {success_count}") + if failed_count: + parts.append(f"失败 {failed_count}") + if skipped_count: + parts.append(f"跳过 {skipped_count}") + message = "、".join(parts) + f" / 共 {len(results)} 个" if parts else "没有需要重排的账号" + + return ReorderPriorityResponse( + success=failed_count == 0, + message=message, + items=[ReorderPriorityItem(**item) for item in results], + ) + + @router.post("/api/websites/{wid}/accounts/import-upstream-keys", response_model=ImportAccountsResponse) def import_upstream_keys_as_accounts( wid: int, diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index 4da00ec..f2a3a02 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -180,6 +180,26 @@ class ImportAccountsResponse(BaseModel): items: list[ImportAccountItem] +class ReorderPriorityRequest(BaseModel): + upstream_id: int = Field(gt=0) + + +class ReorderPriorityItem(BaseModel): + account_id: Optional[str] = None + group_id: str = "" + upstream_id: int + old_priority: Optional[int] = None + new_priority: Optional[int] = None + status: str + message: str = "" + + +class ReorderPriorityResponse(BaseModel): + success: bool + message: str + items: list[ReorderPriorityItem] + + class WebsiteBatchSyncResponse(BaseModel): total: int success: int diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index 49c8308..6262845 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -407,17 +407,25 @@ class UpstreamClient: out["group_name"] = str(out.get("group")) return out - def _list_new_api_tokens( - self, - search: str = "", - group_id: str | int | None = None, - ) -> list[dict[str, Any]]: - if search: - path = "/api/token/search" - params = {"keyword": search, "token": "", "p": 1, "size": 100} - else: - path = "/api/token/" - params = {"p": 1, "size": 100} + @staticmethod + def _extract_new_api_token_items(payload: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: + nested = _unwrap_data(payload) + meta: dict[str, Any] = {} + items: list[dict[str, Any]] | None = None + if isinstance(nested, list): + items = [i for i in nested if isinstance(i, dict)] + elif isinstance(nested, dict): + meta = nested + for key in ("items", "tokens", "list", "records"): + value = nested.get(key) + if isinstance(value, list): + items = [i for i in value if isinstance(i, dict)] + break + if items is None: + raise UpstreamError("unexpected New-API token list response") + return items, meta + + def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: resp = self._client.request( "GET", self._url(path), @@ -429,23 +437,80 @@ class UpstreamClient: resp.raise_for_status() data = resp.json() self._ensure_api_success(data, "list New-API tokens") - nested = _unwrap_data(data) - items: list[dict[str, Any]] | None = None - if isinstance(nested, list): - items = [i for i in nested if isinstance(i, dict)] - elif isinstance(nested, dict): - for key in ("items", "tokens", "list", "records"): - value = nested.get(key) - if isinstance(value, list): - items = [i for i in value if isinstance(i, dict)] - break - if items is None: - raise UpstreamError("unexpected New-API token list response") - normalized = [self._normalize_key_record(i) for i in items] + return self._extract_new_api_token_items(data) + + def _list_all_new_api_tokens(self, page_size: int = 100, max_pages: int = 20) -> list[dict[str, Any]]: + all_items: list[dict[str, Any]] = [] + for page in range(1, max_pages + 1): + items, meta = self._request_new_api_token_list( + "/api/token/", + {"p": page, "size": page_size}, + ) + all_items.extend(items) + total = meta.get("total") if isinstance(meta, dict) else None + if isinstance(total, int) and len(all_items) >= total: + break + if len(items) < page_size: + break + return [self._normalize_key_record(i) for i in all_items] + + @staticmethod + def _matches_new_api_token_search(record: dict[str, Any], search: str) -> bool: + if not search: + return True + needle = search.strip() + if not needle: + return True + name = str(record.get("name") or record.get("key_name") or "") + key = str(record.get("key") or record.get("api_key") or record.get("apiKey") or record.get("token") or "") + return name.startswith(needle) or name == needle or key == needle + + def _hydrate_new_api_token_key(self, record: dict[str, Any]) -> dict[str, Any]: + out = dict(record) + key_value = _extract_key_value(out) + if key_value and "*" not in key_value: + out["key"] = key_value + out["masked_key"] = out.get("masked_key") or mask_secret(key_value) + return out + token_id = out.get("id") + if token_id is None: + return out + try: + plaintext = self._get_new_api_token_key(token_id) + except Exception: + return out + out["key"] = plaintext + out["masked_key"] = mask_secret(plaintext) + return out + + def _list_new_api_tokens( + self, + search: str = "", + group_id: str | int | None = None, + ) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + if search: + search_items, _ = self._request_new_api_token_list( + "/api/token/search", + {"keyword": search, "token": "", "p": 1, "size": 100}, + ) + normalized.extend(self._normalize_key_record(i) for i in search_items) + + all_tokens = self._list_all_new_api_tokens() + seen_ids = {str(i.get("id")) for i in normalized if i.get("id") is not None} + for item in all_tokens: + item_id = item.get("id") + if item_id is not None and str(item_id) in seen_ids: + continue + if self._matches_new_api_token_search(item, search): + normalized.append(item) + if item_id is not None: + seen_ids.add(str(item_id)) + if group_id is not None: gid = str(group_id) normalized = [i for i in normalized if str(i.get("group_id") or i.get("group") or "") == gid] - return normalized + return [self._hydrate_new_api_token_key(i) for i in normalized] def _get_new_api_token_key(self, token_id: str | int) -> str: payload = self._request("POST", f"/api/token/{token_id}/key") diff --git a/backend/app/services/website_sync.py b/backend/app/services/website_sync.py index dd92979..b955958 100644 --- a/backend/app/services/website_sync.py +++ b/backend/app/services/website_sync.py @@ -18,6 +18,15 @@ from app.services import webhook_service logger = logging.getLogger(__name__) +PRIORITY_BASE = 1 +PRIORITY_STEP = 10 + + +def priority_for_rate_rank(rank: int) -> int: + """Convert a zero-based sorted rate rank to an account priority.""" + return PRIORITY_BASE + rank * PRIORITY_STEP + + def binding_sources(binding: WebsiteGroupBinding) -> list[dict[str, Any]]: try: data = json.loads(binding.source_groups_json or "[]") @@ -187,7 +196,7 @@ def build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, in 使用 (upstream_id, group_id) 复合键避免不同上游的同名分组互相覆盖。 遍历所有涉及的上游的最新快照,收集分组的倍率,按倍率升序排列后赋值 priority。 - 倍率最低的 priority=1,次低的 priority=2,以此类推。相同倍率的分组共享同一 priority。 + 倍率最低的 priority=1,次低的 priority=11,以此类推。相同倍率的分组共享同一 priority。 """ group_rates: dict[str, float] = {} for uid in upstream_ids: @@ -199,7 +208,7 @@ def build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, in key = f"{uid}:{gid}" group_rates[key] = rate unique_rates = sorted(set(group_rates.values())) - rate_to_priority = {rate: idx + 1 for idx, rate in enumerate(unique_rates)} + rate_to_priority = {rate: priority_for_rate_rank(idx) for idx, rate in enumerate(unique_rates)} return {key: rate_to_priority[rate] for key, rate in group_rates.items()} @@ -285,27 +294,30 @@ def _try_send_priority_webhook( logger.warning("account_priority_changed webhook failed for website %s: %s", wid, exc) -def sync_account_priorities_for_upstream(db: Session, upstream_id: int) -> list[dict]: +def sync_account_priorities_for_upstream( + db: Session, + upstream_id: int, + website_id: int | None = None, +) -> list[dict]: """上游倍率变化后,自动更新已导入下游账号的 priority。 只处理同一目标分组内有多个账号(存在竞争)的情况: - 竞争分组键:imported_target_group_id(老数据 fallback 到 group_id) - - 同一竞争分组内按倍率升序排序,priority 从 1 开始(相同倍率共享) + - 同一竞争分组内按倍率升序排序,priority 从 1 开始,每档间隔 10(相同倍率共享) - 单账号分组:完全跳过,不调用 update_account,不发通知 - 无竞争分组:直接返回,不写日志,不发通知 """ from collections import defaultdict - key_rows = ( - db.query(UpstreamGeneratedKey) - .filter( - UpstreamGeneratedKey.upstream_id == upstream_id, - UpstreamGeneratedKey.imported_website_id.isnot(None), - UpstreamGeneratedKey.imported_account_id.isnot(None), - UpstreamGeneratedKey.status != "orphaned", - ) - .all() + key_query = db.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.upstream_id == upstream_id, + UpstreamGeneratedKey.imported_website_id.isnot(None), + UpstreamGeneratedKey.imported_account_id.isnot(None), + UpstreamGeneratedKey.status != "orphaned", ) + if website_id is not None: + key_query = key_query.filter(UpstreamGeneratedKey.imported_website_id == website_id) + key_rows = key_query.all() if not key_rows: return [] @@ -387,7 +399,7 @@ def sync_account_priorities_for_upstream(db: Session, upstream_id: int) -> list[ continue # 组内按倍率升序排序(倍率低 → priority 小 → 优先) unique_rates = sorted(set(r for _, r in rated)) - rate_to_prio = {rate: idx + 1 for idx, rate in enumerate(unique_rates)} + rate_to_prio = {rate: priority_for_rate_rank(idx) for idx, rate in enumerate(unique_rates)} for row, rate in rated: priority_assignment[row.imported_account_id] = rate_to_prio[rate] diff --git a/backend/test_priority_sync.py b/backend/test_priority_sync.py index 8cdacf9..0e5097b 100644 --- a/backend/test_priority_sync.py +++ b/backend/test_priority_sync.py @@ -5,8 +5,8 @@ - 竞争分组键 = imported_target_group_id or group_id(老数据 fallback) - 只有同一竞争分组内账号数 > 1 时才更新 priority / 发通知 - 不同分组各 1 个账号:不调用 update_account,不发通知 -- 同一目标分组多账号:组内按倍率升序独立排序,priority 从 1 开始 -- 两个目标分组各有多账号:彼此独立,每组内 priority 都从 1 开始 +- 同一目标分组多账号:组内按倍率升序独立排序,priority 从 1 开始,每档间隔 10 +- 两个目标分组各有多账号:彼此独立,每组内 priority 都从 1 开始,每档间隔 10 - 老数据 imported_target_group_id=NULL:fallback group_id,不报错 """ import json @@ -164,9 +164,9 @@ def test_same_target_group_two_accounts_updated(db_session, monkeypatch): assert len(update_calls) == 2 priority_map = {aid: data["priority"] for aid, data in update_calls} - # G1 rate=1.0 → priority=1(低倍率优先);G2 rate=2.0 → priority=2 + # G1 rate=1.0 → priority=1(低倍率优先);G2 rate=2.0 → priority=11 assert priority_map["A1"] == 1 - assert priority_map["A2"] == 2 + assert priority_map["A2"] == 11 # 写了日志 log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).first() @@ -185,9 +185,9 @@ def test_two_target_groups_independent_priority(db_session, monkeypatch): _make_snapshot(db_session, u1.id, { "G1": 1.0, # → TG1 中排 priority=1 - "G2": 2.0, # → TG1 中排 priority=2 + "G2": 2.0, # → TG1 中排 priority=11 "G3": 0.5, # → TG2 中排 priority=1 - "G4": 3.0, # → TG2 中排 priority=2 + "G4": 3.0, # → TG2 中排 priority=11 }) # TG1: G1(1.0), G2(2.0) @@ -208,12 +208,12 @@ def test_two_target_groups_independent_priority(db_session, monkeypatch): priority_map = {aid: data["priority"] for aid, data in update_calls} assert len(update_calls) == 4 - # TG1 内部:G1(1.0)→p1, G2(2.0)→p2 + # TG1 内部:G1(1.0)→p1, G2(2.0)→p11 assert priority_map["A1"] == 1 - assert priority_map["A2"] == 2 - # TG2 内部:G3(0.5)→p1, G4(3.0)→p2(独立从 1 开始) + assert priority_map["A2"] == 11 + # TG2 内部:G3(0.5)→p1, G4(3.0)→p11(独立从 1 开始) assert priority_map["A3"] == 1 - assert priority_map["A4"] == 2 + assert priority_map["A4"] == 11 def test_old_data_null_target_group_fallback(db_session, monkeypatch): @@ -334,9 +334,9 @@ def test_partial_missing_rate_sufficient_accounts_still_updates(db_session, monk updated = {c[0]: c[1]["priority"] for c in update_calls} # A3 无快照 → 不参与排序,不被更新 assert "A3" not in updated - # A1(G1, rate=1.0) → priority=1;A2(G2, rate=2.0) → priority=2 + # A1(G1, rate=1.0) → priority=1;A2(G2, rate=2.0) → priority=11 assert updated["A1"] == 1 - assert updated["A2"] == 2 + assert updated["A2"] == 11 def test_priority_sync_log_structure(db_session, monkeypatch): @@ -392,7 +392,7 @@ def test_priority_sync_cross_upstream_group(db_session): priority_map = build_rate_priority_map(db_session, {u1.id, u2.id}) assert priority_map[f"{u1.id}:VIP"] == 1 - assert priority_map[f"{u2.id}:VIP"] == 2 + assert priority_map[f"{u2.id}:VIP"] == 11 assert len(priority_map) == 2 @@ -446,8 +446,46 @@ def test_import_auto_priority_by_rate(db_session, monkeypatch): import_upstream_keys_as_accounts(w.id, req, db_session) assert len(created_accounts) == 2 - # G2 rate=1.0 → priority 1;G1 rate=2.0 → priority 2 + # G2 rate=1.0 → priority 1;G1 rate=2.0 → priority 11 p1 = next(a["priority"] for a in created_accounts if "G1" in a["name"]) p2 = next(a["priority"] for a in created_accounts if "G2" in a["name"]) assert p2 == 1 - assert p1 == 2 + assert p1 == 11 + + +def test_reorder_priority_endpoint_scopes_to_current_website(db_session, monkeypatch): + """手动重排只影响当前网站,不误改同一上游导入到其他网站的账号。""" + from app.routers.websites import reorder_account_priorities + from app.schemas.website import ReorderPriorityRequest + + w1 = Website(name="W1", base_url="http://w1", enabled=True, + auth_config_json="{}", timeout_seconds=30) + w2 = Website(name="W2", base_url="http://w2", enabled=True, + auth_config_json="{}", timeout_seconds=30) + u1 = Upstream(name="U1", base_url="http://u1") + db_session.add_all([w1, w2, u1]) + db_session.commit() + db_session.refresh(w1); db_session.refresh(w2); db_session.refresh(u1) + + _make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0}) + + _make_key(db_session, u1.id, "G1", "K1", "V1", w1.id, "W1A1", imported_target_group_id="TG1") + _make_key(db_session, u1.id, "G2", "K2", "V2", w1.id, "W1A2", imported_target_group_id="TG1") + _make_key(db_session, u1.id, "G1", "K3", "V3", w2.id, "W2A1", imported_target_group_id="TG1") + _make_key(db_session, u1.id, "G2", "K4", "V4", w2.id, "W2A2", imported_target_group_id="TG1") + + update_calls = [] + monkeypatch.setattr( + "app.services.website_sync.Sub2ApiWebsiteClient", + make_mock_client(update_calls), + ) + + response = reorder_account_priorities( + w1.id, + ReorderPriorityRequest(upstream_id=u1.id), + db_session, + ) + + updated = {account_id: payload["priority"] for account_id, payload in update_calls} + assert response.success is True + assert updated == {"W1A1": 1, "W1A2": 11} diff --git a/backend/test_upstream_key_sync.py b/backend/test_upstream_key_sync.py index cfa36bd..d0ab4b0 100644 --- a/backend/test_upstream_key_sync.py +++ b/backend/test_upstream_key_sync.py @@ -637,6 +637,136 @@ def test_new_api_create_token_fetches_plaintext_key(monkeypatch): assert created_bodies[0]["expired_time"] > 0 +def test_new_api_list_tokens_uses_full_list_and_fetches_plaintext_when_search_misses(): + """New-API search 可能不匹配前缀;应拉完整 token 列表并按 id 回填明文。""" + from app.services.upstream_client import UpstreamClient + + client = UpstreamClient( + base_url="http://newapi.local", + api_prefix="", + auth_type="cookie", + auth_config={"cookie_string": "session=abc", "new_api_user": "7"}, + ) + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + self.cookies = {} + self.headers = {"content-type": "application/json"} + self.content = b"{}" + self.text = "{}" + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + class FakeHttpClient: + def request(self, method, url, **kwargs): + path = url.replace("http://newapi.local", "") + params = kwargs.get("params") or {} + if method == "GET" and path == "/api/token/search": + assert params["keyword"] == "SmartUp" + return FakeResponse({ + "success": True, + "data": {"page": 1, "page_size": 100, "total": 0, "items": []}, + }) + if method == "GET" and path == "/api/token/": + return FakeResponse({ + "success": True, + "data": { + "page": 1, + "page_size": 100, + "total": 4, + "items": [ + {"id": 447, "name": "SmartUp-4-gptpro-gpt pro", "group": "gpt pro", "key": "sk-XE2o********WWh"}, + {"id": 446, "name": "SmartUp-4-gptplus-gpt plus", "group": "gpt plus", "key": "sk-JRi1********rtum"}, + {"id": 445, "name": "SmartUp-4-claude特价kiro-claude 特价kiro", "group": "claude 特价kiro", "key": "sk-Aldb********08W2"}, + {"id": 56, "name": "plus", "group": "gpt plus", "key": "sk-20cB********pEfE"}, + ], + }, + }) + if method == "POST" and path == "/api/token/447/key": + return FakeResponse({"success": True, "data": {"key": "sk-gptpro-plain"}}) + if method == "POST" and path == "/api/token/446/key": + return FakeResponse({"success": True, "data": {"key": "sk-gptplus-plain"}}) + if method == "POST" and path == "/api/token/445/key": + return FakeResponse({"success": True, "data": {"key": "sk-claude-plain"}}) + raise AssertionError(f"unexpected request {method} {path} {params}") + + client._client = FakeHttpClient() + + rows = client.list_api_keys(search="SmartUp", status="active") + + assert [row["id"] for row in rows] == [447, 446, 445] + assert [row["group_id"] for row in rows] == ["gpt pro", "gpt plus", "claude 特价kiro"] + assert [row["key"] for row in rows] == ["sk-gptpro-plain", "sk-gptplus-plain", "sk-claude-plain"] + + +def test_generated_keys_persists_new_api_tokens_with_plaintext(db_session, monkeypatch): + """generated-keys 应把 New-API 远端 token 回填成本地可导入记录。""" + from app.routers import upstreams as upstreams_router + from app.models.upstream_key import UpstreamGeneratedKey + + upstream = Upstream( + name="NewAPI", + base_url="http://newapi.local", + api_prefix="", + auth_type="cookie", + auth_config_json=json.dumps({"cookie_string": "session=abc", "new_api_user": "7"}), + groups_endpoint="/api/user/self/groups", + rate_endpoint="/api/user/self/groups", + ) + db_session.add(upstream) + db_session.commit() + db_session.refresh(upstream) + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def login(self): + return None + + def list_api_keys(self, search="", status="active"): + assert search == "SmartUp" + return [ + { + "id": 447, + "name": "SmartUp-4-gptpro-gpt pro", + "group": "gpt pro", + "group_id": "gpt pro", + "key": "sk-gptpro-plain", + "masked_key": "sk-g********lain", + } + ] + + monkeypatch.setattr(upstreams_router, "UpstreamClient", FakeClient) + + response = upstreams_router.list_generated_keys(upstream.id, db_session, object()) + + assert len(response) == 1 + assert response[0].has_key_value is True + assert response[0].id is not None + assert response[0].key_name == "SmartUp-4-gptpro-gpt pro" + + row = db_session.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.upstream_id == upstream.id, + UpstreamGeneratedKey.key_id == "447", + ).one() + assert row.group_id == "gpt pro" + assert row.group_name == "gpt pro" + assert row.key_value == "sk-gptpro-plain" + assert row.managed_prefix == "SmartUp" + + def test_generate_keys_allows_new_api_user_upstream(db_session, monkeypatch): """New-API 普通账号上游应允许按分组生成 token。""" from app.routers import upstreams as upstreams_router diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 268eb00..5638692 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -291,6 +291,16 @@ export interface ImportAccountItem { raw: Record } +export interface ReorderPriorityItem { + account_id: string | null + group_id: string + upstream_id: number + old_priority: number | null + new_priority: number | null + status: string + message: string +} + export interface WebsiteBatchSyncResponse { total: number success: number @@ -311,6 +321,8 @@ export const websitesApi = { api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data), syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/sync-imported-upstream-keys`, data), + reorderAccountPriorities: (id: number, data: { upstream_id: number }) => + api.post<{ success: boolean; message: string; items: ReorderPriorityItem[] }>(`/api/websites/${id}/accounts/reorder-priority`, data), importAccountsFromUpstreamKeys: (id: number, data: { upstream_key_ids: number[] target_group_map: Record diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 51aab07..38ba4c6 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -324,6 +324,16 @@ 刷新导入状态 + + 重排优先级 + 已校验已导入标记 {{ importSyncStatus.total }} 个,清除 {{ importSyncStatus.cleared }} 个失效标记 @@ -353,7 +363,7 @@ @@ -447,7 +457,7 @@ import { computed, onMounted, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import type { FormInstance } from 'element-plus' import dayjs from 'dayjs' -import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh, WarningFilled } from '@element-plus/icons-vue' +import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh, Sort, WarningFilled } from '@element-plus/icons-vue' import { upstreamsApi, websitesApi, @@ -485,6 +495,7 @@ const importingAccounts = ref(false) const generatedKeyLoading = ref(false) const importSyncStatus = ref<{ total: number; cleared: number; failed: number } | null>(null) const syncingImportStatus = ref(false) +const reorderingPriorities = ref(false) const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s) const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[s] || s) @@ -752,6 +763,29 @@ async function syncImportStatus() { return reloaded } +async function reorderImportPriorities() { + const websiteId = importAccountsForm.value.website_id + const upstreamId = importAccountsForm.value.upstream_id + if (!websiteId || !upstreamId) { + ElMessage.error('请选择目标网站和来源上游') + return + } + reorderingPriorities.value = true + try { + const res = await websitesApi.reorderAccountPriorities(websiteId, { upstream_id: upstreamId }) + if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return + ElMessage[res.data.success ? 'success' : 'warning'](res.data.message) + await Promise.all([ + loadImportGeneratedKeys(upstreamId), + loadLogs(), + ]) + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '重排优先级失败') + } finally { + reorderingPriorities.value = false + } +} + async function loadImportGeneratedKeys(upstreamId: number) { importGeneratedKeys.value = [] if (!upstreamId) return