Spaces:
Paused
Paused
feat: AutoTeam 大版本升级:安全轮换、并行、IPv6、指纹加固与前端重构
Browse files- 安全轮换逻辑:先踢人再换人,降低轮转冲突和失败回弹
- 并行能力:同步、注册与目标分发链路并发化,整体吞吐更高
- IPv6 支持:接入 IPv6 代理池与状态面,扩展可用出口
- 更安全的指纹:统一浏览器上下文与启动/会话参数,减少特征暴露
- 轮转增强:速度与安全性双提升,轮转后凭证即时同步到配置目标
- 更好的前端:Dashboard / Settings / Sync / Task 视图全面重构,实时性、信息密度和可读性提升
- 同步与诊断:统一 sync_targets 分发层,增强主号、注册和轮转后的诊断与目标同步
- .env.example +22 -0
- src/autoteam/accounts.py +29 -4
- src/autoteam/api.py +27 -7
- src/autoteam/codex_auth.py +1 -3
- src/autoteam/config.py +25 -0
- src/autoteam/cpa_sync.py +93 -30
- src/autoteam/invite.py +2 -5
- src/autoteam/manager.py +51 -1
- src/autoteam/sub2api_sync.py +1049 -0
- src/autoteam/sync_targets.py +278 -0
- src/autoteam/web/dist/assets/index-BSht6bmF.js +0 -0
- src/autoteam/web/dist/assets/index-BZ-R3x09.js +0 -0
- src/autoteam/web/dist/assets/{index-DHotZNsL.css → index-CbNAiyvN.css} +1 -1
- src/autoteam/web/dist/index.html +2 -2
- tests/unit/test_accounts.py +24 -0
- tests/unit/test_api_playwright_cleanup.py +59 -2
- tests/unit/test_cpa_sync.py +124 -0
- tests/unit/test_sub2api_sync.py +659 -0
- tests/unit/test_sync_targets.py +110 -0
- web/src/App.vue +41 -88
- web/src/api.js +5 -1
- web/src/components/AtButton.vue +6 -6
- web/src/components/Dashboard.vue +240 -94
- web/src/components/HealthDonut.vue +3 -3
- web/src/components/LogViewer.vue +36 -21
- web/src/components/MailProviderCard.vue +21 -21
- web/src/components/MasterHealthBanner.vue +34 -60
- web/src/components/OAuthPage.vue +21 -21
- web/src/components/PoolHealthCard.vue +33 -43
- web/src/components/Settings.vue +43 -49
- web/src/components/SetupPage.vue +25 -20
- web/src/components/Sidebar.vue +6 -6
- web/src/components/StatusBadge.vue +3 -2
- web/src/components/SyncPage.vue +5 -3
- web/src/components/TaskHistory.vue +30 -22
- web/src/components/TaskHistoryPage.vue +2 -2
- web/src/components/TaskPanel.vue +48 -37
- web/src/components/TasksPage.vue +1 -1
- web/src/components/TeamMembers.vue +18 -18
- web/src/components/ToastHost.vue +16 -15
- web/src/composables/useAppState.js +259 -0
- web/src/composables/useRotateStream.js +10 -1
- web/src/composables/useStatus.js +20 -8
- web/src/style.css +4 -20
.env.example
CHANGED
|
@@ -23,6 +23,25 @@ MAILLAB_DOMAIN= # 创建邮箱时使用,缺省回落
|
|
| 23 |
CPA_URL=http://127.0.0.1:8317
|
| 24 |
CPA_KEY=your_key
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# API 鉴权(不设置则不启用,任何人都可访问)
|
| 27 |
API_KEY=
|
| 28 |
|
|
@@ -61,8 +80,11 @@ EMAIL_POLL_TIMEOUT=300
|
|
| 61 |
|
| 62 |
# 自动巡检(API 模式下生效)
|
| 63 |
AUTO_CHECK_INTERVAL=300 # 巡检间隔(秒),默认 5 分钟
|
|
|
|
| 64 |
AUTO_CHECK_THRESHOLD=10 # 额度低于此百分比触发轮转,默认 10%
|
| 65 |
AUTO_CHECK_MIN_LOW=2 # 至少几个账号低于阈值才触发,默认 2
|
|
|
|
|
|
|
| 66 |
|
| 67 |
# 对账策略(autoteam reconcile / cmd_check 入口自动对账)
|
| 68 |
# RECONCILE_KICK_ORPHAN=true:workspace 有 active + 本地 auth_file 缺失的"残废"自动 KICK
|
|
|
|
| 23 |
CPA_URL=http://127.0.0.1:8317
|
| 24 |
CPA_KEY=your_key
|
| 25 |
|
| 26 |
+
# 远端同步目标开关(建议显式设置,避免占位值被误判为已启用)
|
| 27 |
+
SYNC_TARGET_CPA=false
|
| 28 |
+
SYNC_TARGET_SUB2API=false
|
| 29 |
+
|
| 30 |
+
# Sub2API
|
| 31 |
+
SUB2API_URL=http://127.0.0.1:8080
|
| 32 |
+
SUB2API_EMAIL=admin@example.com
|
| 33 |
+
SUB2API_PASSWORD=your_password
|
| 34 |
+
SUB2API_GROUP=
|
| 35 |
+
SUB2API_PROXY=
|
| 36 |
+
SUB2API_CONCURRENCY=10
|
| 37 |
+
SUB2API_PRIORITY=1
|
| 38 |
+
SUB2API_RATE_MULTIPLIER=1
|
| 39 |
+
SUB2API_AUTO_PAUSE_ON_EXPIRED=true
|
| 40 |
+
SUB2API_MODEL_WHITELIST=
|
| 41 |
+
SUB2API_OPENAI_WS_MODE=off
|
| 42 |
+
SUB2API_OPENAI_PASSTHROUGH=false
|
| 43 |
+
SUB2API_OVERWRITE_ACCOUNT_SETTINGS=false
|
| 44 |
+
|
| 45 |
# API 鉴权(不设置则不启用,任何人都可访问)
|
| 46 |
API_KEY=
|
| 47 |
|
|
|
|
| 80 |
|
| 81 |
# 自动巡检(API 模式下生效)
|
| 82 |
AUTO_CHECK_INTERVAL=300 # 巡检间隔(秒),默认 5 分钟
|
| 83 |
+
AUTO_CHECK_TARGET_SEATS=3 # 自动巡检目标 Team 总 seat 数(最多 1 母 + 2 子)
|
| 84 |
AUTO_CHECK_THRESHOLD=10 # 额度低于此百分比触发轮转,默认 10%
|
| 85 |
AUTO_CHECK_MIN_LOW=2 # 至少几个账号低于阈值才触发,默认 2
|
| 86 |
+
AUTO_CHECK_RETRY_ADD_PHONE=true # 是否自动重试 add_phone(手机号验证)
|
| 87 |
+
AUTO_CHECK_ADD_PHONE_MAX_RETRIES=3 # add_phone 最大自动重试次数
|
| 88 |
|
| 89 |
# 对账策略(autoteam reconcile / cmd_check 入口自动对账)
|
| 90 |
# RECONCILE_KICK_ORPHAN=true:workspace 有 active + 本地 auth_file 缺失的"残废"自动 KICK
|
src/autoteam/accounts.py
CHANGED
|
@@ -82,24 +82,39 @@ def _is_main_account_email(email):
|
|
| 82 |
return bool(_normalized_email(email)) and _normalized_email(email) == _normalized_email(get_admin_email())
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def load_accounts():
|
| 86 |
"""加载账号列表"""
|
| 87 |
if ACCOUNTS_FILE.exists():
|
| 88 |
text = read_text(ACCOUNTS_FILE).strip()
|
| 89 |
if text:
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
return []
|
| 92 |
|
| 93 |
|
| 94 |
def save_accounts(accounts):
|
| 95 |
"""保存账号列表"""
|
| 96 |
-
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def find_account(accounts, email):
|
| 100 |
"""按邮箱查找账号"""
|
|
|
|
| 101 |
for acc in accounts:
|
| 102 |
-
if acc
|
| 103 |
return acc
|
| 104 |
return None
|
| 105 |
|
|
@@ -146,6 +161,7 @@ def add_account(email, password, cloudmail_account_id=None, seat_type=SEAT_UNKNO
|
|
| 146 |
"quota_exhausted_at": None, # 额度用完的时间
|
| 147 |
"quota_resets_at": None, # 额度恢复时间
|
| 148 |
"last_quota_check_at": None, # 最近一次 wham/usage 探测时间戳,用于 standby 探测去重
|
|
|
|
| 149 |
# Round 11 V7 — 双失效探测(access_token + refresh_token 同时被 server-side invalidate):
|
| 150 |
# 主循环周期性调 is_token_pair_invalidated,命中后落该字段供事后排查 / UI 展示。
|
| 151 |
"last_token_pair_invalidated_at": None,
|
|
@@ -220,7 +236,13 @@ def delete_account(email):
|
|
| 220 |
|
| 221 |
def get_active_accounts():
|
| 222 |
"""获取所有活跃账号"""
|
| 223 |
-
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
|
| 226 |
def get_personal_accounts():
|
|
@@ -236,6 +258,8 @@ def get_standby_accounts():
|
|
| 236 |
for a in accounts:
|
| 237 |
if _is_main_account_email(a.get("email")):
|
| 238 |
continue
|
|
|
|
|
|
|
| 239 |
if a["status"] == STATUS_STANDBY:
|
| 240 |
resets_at = a.get("quota_resets_at")
|
| 241 |
if resets_at is None:
|
|
@@ -302,6 +326,7 @@ __all__ = [
|
|
| 302 |
"get_next_reusable_account",
|
| 303 |
"get_personal_accounts",
|
| 304 |
"get_standby_accounts",
|
|
|
|
| 305 |
"is_supported_plan",
|
| 306 |
"load_accounts",
|
| 307 |
"normalize_plan_type",
|
|
|
|
| 82 |
return bool(_normalized_email(email)) and _normalized_email(email) == _normalized_email(get_admin_email())
|
| 83 |
|
| 84 |
|
| 85 |
+
def is_account_disabled(acc: dict | None) -> bool:
|
| 86 |
+
"""Return whether an account is locally disabled for automation flows."""
|
| 87 |
+
return bool((acc or {}).get("disabled", False))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _normalize_account(acc: dict) -> dict:
|
| 91 |
+
normalized = dict(acc or {})
|
| 92 |
+
normalized["disabled"] = bool(normalized.get("disabled", False))
|
| 93 |
+
return normalized
|
| 94 |
+
|
| 95 |
+
|
| 96 |
def load_accounts():
|
| 97 |
"""加载账号列表"""
|
| 98 |
if ACCOUNTS_FILE.exists():
|
| 99 |
text = read_text(ACCOUNTS_FILE).strip()
|
| 100 |
if text:
|
| 101 |
+
data = json.loads(text)
|
| 102 |
+
if isinstance(data, list):
|
| 103 |
+
return [_normalize_account(acc) for acc in data if isinstance(acc, dict)]
|
| 104 |
return []
|
| 105 |
|
| 106 |
|
| 107 |
def save_accounts(accounts):
|
| 108 |
"""保存账号列表"""
|
| 109 |
+
normalized = [_normalize_account(acc) for acc in accounts if isinstance(acc, dict)]
|
| 110 |
+
write_text(ACCOUNTS_FILE, json.dumps(normalized, indent=2, ensure_ascii=False))
|
| 111 |
|
| 112 |
|
| 113 |
def find_account(accounts, email):
|
| 114 |
"""按邮箱查找账号"""
|
| 115 |
+
target = _normalized_email(email)
|
| 116 |
for acc in accounts:
|
| 117 |
+
if _normalized_email(acc.get("email")) == target:
|
| 118 |
return acc
|
| 119 |
return None
|
| 120 |
|
|
|
|
| 161 |
"quota_exhausted_at": None, # 额度用完的时间
|
| 162 |
"quota_resets_at": None, # 额度恢复时间
|
| 163 |
"last_quota_check_at": None, # 最近一次 wham/usage 探测时间戳,用于 standby 探测去重
|
| 164 |
+
"disabled": False, # 本地禁用:保留记录和 auth_file,但自动化巡检/轮转/同步跳过
|
| 165 |
# Round 11 V7 — 双失效探测(access_token + refresh_token 同时被 server-side invalidate):
|
| 166 |
# 主循环周期性调 is_token_pair_invalidated,命中后落该字段供事后排查 / UI 展示。
|
| 167 |
"last_token_pair_invalidated_at": None,
|
|
|
|
| 236 |
|
| 237 |
def get_active_accounts():
|
| 238 |
"""获取所有活跃账号"""
|
| 239 |
+
return [
|
| 240 |
+
a
|
| 241 |
+
for a in load_accounts()
|
| 242 |
+
if a["status"] == STATUS_ACTIVE
|
| 243 |
+
and not _is_main_account_email(a.get("email"))
|
| 244 |
+
and not is_account_disabled(a)
|
| 245 |
+
]
|
| 246 |
|
| 247 |
|
| 248 |
def get_personal_accounts():
|
|
|
|
| 258 |
for a in accounts:
|
| 259 |
if _is_main_account_email(a.get("email")):
|
| 260 |
continue
|
| 261 |
+
if is_account_disabled(a):
|
| 262 |
+
continue
|
| 263 |
if a["status"] == STATUS_STANDBY:
|
| 264 |
resets_at = a.get("quota_resets_at")
|
| 265 |
if resets_at is None:
|
|
|
|
| 326 |
"get_next_reusable_account",
|
| 327 |
"get_personal_accounts",
|
| 328 |
"get_standby_accounts",
|
| 329 |
+
"is_account_disabled",
|
| 330 |
"is_supported_plan",
|
| 331 |
"load_accounts",
|
| 332 |
"normalize_plan_type",
|
src/autoteam/api.py
CHANGED
|
@@ -883,9 +883,18 @@ def _finish_main_codex_sync():
|
|
| 883 |
_main_codex_step = None
|
| 884 |
if _playwright_lock.locked():
|
| 885 |
_playwright_lock.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
return {
|
| 887 |
"status": "completed",
|
| 888 |
-
"message":
|
| 889 |
"codex": _main_codex_status(),
|
| 890 |
"info": info,
|
| 891 |
}
|
|
@@ -1494,14 +1503,25 @@ def post_main_codex_start():
|
|
| 1494 |
_playwright_lock.release()
|
| 1495 |
|
| 1496 |
from autoteam.codex_auth import get_saved_main_auth_file
|
| 1497 |
-
from autoteam.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1498 |
|
| 1499 |
saved_auth_file = get_saved_main_auth_file()
|
| 1500 |
if saved_auth_file:
|
| 1501 |
sync_main_codex_to_cpa(saved_auth_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1502 |
return {
|
| 1503 |
"status": "completed",
|
| 1504 |
-
"message":
|
| 1505 |
"codex": _main_codex_status(),
|
| 1506 |
"info": {"auth_file": saved_auth_file},
|
| 1507 |
}
|
|
@@ -1899,7 +1919,7 @@ def delete_accounts_batch(params: DeleteBatchParams):
|
|
| 1899 |
from autoteam.accounts import load_accounts
|
| 1900 |
from autoteam.chatgpt_api import ChatGPTTeamAPI
|
| 1901 |
from autoteam.cloudmail import CloudMailClient
|
| 1902 |
-
from autoteam.
|
| 1903 |
|
| 1904 |
raw_emails = [(e or "").strip() for e in (params.emails or [])]
|
| 1905 |
emails = [e for e in raw_emails if e]
|
|
@@ -2364,7 +2384,7 @@ def post_account_login(params: LoginAccountParams):
|
|
| 2364 |
quota_resets_at=quota_result_resets_at(info) or int(time.time() + 18000),
|
| 2365 |
)
|
| 2366 |
# 同步到 CPA
|
| 2367 |
-
from autoteam.
|
| 2368 |
|
| 2369 |
sync_to_cpa()
|
| 2370 |
return {
|
|
@@ -2448,7 +2468,7 @@ def get_status():
|
|
| 2448 |
@app.post("/api/sync")
|
| 2449 |
def post_sync():
|
| 2450 |
"""同步认证文件到 CPA"""
|
| 2451 |
-
from autoteam.
|
| 2452 |
|
| 2453 |
sync_to_cpa()
|
| 2454 |
return {"message": "同步完成"}
|
|
@@ -2769,7 +2789,7 @@ def get_logs(limit: int = 100, since: float = 0):
|
|
| 2769 |
|
| 2770 |
@app.post("/api/sync/main-codex")
|
| 2771 |
def post_sync_main_codex():
|
| 2772 |
-
"""兼容旧接口:开始主号 Codex 登录并同步到
|
| 2773 |
return post_main_codex_start()
|
| 2774 |
|
| 2775 |
|
|
|
|
| 883 |
_main_codex_step = None
|
| 884 |
if _playwright_lock.locked():
|
| 885 |
_playwright_lock.release()
|
| 886 |
+
from autoteam.sync_targets import describe_sync_targets, get_enabled_sync_targets
|
| 887 |
+
|
| 888 |
+
enabled_targets = get_enabled_sync_targets()
|
| 889 |
+
target_label = describe_sync_targets(enabled_targets)
|
| 890 |
+
message = (
|
| 891 |
+
f"主号 Codex 已同步到 {target_label}"
|
| 892 |
+
if enabled_targets
|
| 893 |
+
else "主号 Codex 已保存,本轮未启用远端同步目标"
|
| 894 |
+
)
|
| 895 |
return {
|
| 896 |
"status": "completed",
|
| 897 |
+
"message": message,
|
| 898 |
"codex": _main_codex_status(),
|
| 899 |
"info": info,
|
| 900 |
}
|
|
|
|
| 1503 |
_playwright_lock.release()
|
| 1504 |
|
| 1505 |
from autoteam.codex_auth import get_saved_main_auth_file
|
| 1506 |
+
from autoteam.sync_targets import (
|
| 1507 |
+
describe_sync_targets,
|
| 1508 |
+
get_enabled_sync_targets,
|
| 1509 |
+
sync_main_codex_to_configured_targets as sync_main_codex_to_cpa,
|
| 1510 |
+
)
|
| 1511 |
|
| 1512 |
saved_auth_file = get_saved_main_auth_file()
|
| 1513 |
if saved_auth_file:
|
| 1514 |
sync_main_codex_to_cpa(saved_auth_file)
|
| 1515 |
+
enabled_targets = get_enabled_sync_targets()
|
| 1516 |
+
target_label = describe_sync_targets(enabled_targets)
|
| 1517 |
+
message = (
|
| 1518 |
+
f"主号 Codex 已同步到 {target_label}"
|
| 1519 |
+
if enabled_targets
|
| 1520 |
+
else "主号 Codex 已保存,本轮未启用远端同步目标"
|
| 1521 |
+
)
|
| 1522 |
return {
|
| 1523 |
"status": "completed",
|
| 1524 |
+
"message": message,
|
| 1525 |
"codex": _main_codex_status(),
|
| 1526 |
"info": {"auth_file": saved_auth_file},
|
| 1527 |
}
|
|
|
|
| 1919 |
from autoteam.accounts import load_accounts
|
| 1920 |
from autoteam.chatgpt_api import ChatGPTTeamAPI
|
| 1921 |
from autoteam.cloudmail import CloudMailClient
|
| 1922 |
+
from autoteam.sync_targets import sync_to_configured_targets as sync_to_cpa
|
| 1923 |
|
| 1924 |
raw_emails = [(e or "").strip() for e in (params.emails or [])]
|
| 1925 |
emails = [e for e in raw_emails if e]
|
|
|
|
| 2384 |
quota_resets_at=quota_result_resets_at(info) or int(time.time() + 18000),
|
| 2385 |
)
|
| 2386 |
# 同步到 CPA
|
| 2387 |
+
from autoteam.sync_targets import sync_to_configured_targets as sync_to_cpa
|
| 2388 |
|
| 2389 |
sync_to_cpa()
|
| 2390 |
return {
|
|
|
|
| 2468 |
@app.post("/api/sync")
|
| 2469 |
def post_sync():
|
| 2470 |
"""同步认证文件到 CPA"""
|
| 2471 |
+
from autoteam.sync_targets import sync_to_configured_targets as sync_to_cpa
|
| 2472 |
|
| 2473 |
sync_to_cpa()
|
| 2474 |
return {"message": "同步完成"}
|
|
|
|
| 2789 |
|
| 2790 |
@app.post("/api/sync/main-codex")
|
| 2791 |
def post_sync_main_codex():
|
| 2792 |
+
"""兼容旧接口:开始主号 Codex 登录并同步到已启用远端目标。"""
|
| 2793 |
return post_main_codex_start()
|
| 2794 |
|
| 2795 |
|
src/autoteam/codex_auth.py
CHANGED
|
@@ -2752,9 +2752,7 @@ class MainCodexSyncFlow(SessionCodexAuthFlow):
|
|
| 2752 |
|
| 2753 |
def complete(self):
|
| 2754 |
info = super().complete()
|
| 2755 |
-
|
| 2756 |
-
from autoteam.cpa_sync import sync_main_codex_to_cpa
|
| 2757 |
-
|
| 2758 |
sync_main_codex_to_cpa(info["auth_file"])
|
| 2759 |
return {
|
| 2760 |
"email": info.get("email"),
|
|
|
|
| 2752 |
|
| 2753 |
def complete(self):
|
| 2754 |
info = super().complete()
|
| 2755 |
+
from autoteam.sync_targets import sync_main_codex_to_configured_targets as sync_main_codex_to_cpa
|
|
|
|
|
|
|
| 2756 |
sync_main_codex_to_cpa(info["auth_file"])
|
| 2757 |
return {
|
| 2758 |
"email": info.get("email"),
|
src/autoteam/config.py
CHANGED
|
@@ -39,6 +39,13 @@ def _normalize_chatgpt_api_transport(value: str) -> str:
|
|
| 39 |
return "playwright"
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# CloudMail 配置
|
| 43 |
CLOUDMAIL_BASE_URL = os.environ.get("CLOUDMAIL_BASE_URL", "")
|
| 44 |
CLOUDMAIL_EMAIL = os.environ.get("CLOUDMAIL_EMAIL", "")
|
|
@@ -61,6 +68,7 @@ API_KEY = os.environ.get("API_KEY", "")
|
|
| 61 |
|
| 62 |
# 自动巡检配置
|
| 63 |
AUTO_CHECK_INTERVAL = _get_int_env("AUTO_CHECK_INTERVAL", 300) # 巡检间隔(秒),默认 5 分钟
|
|
|
|
| 64 |
AUTO_CHECK_THRESHOLD = _get_int_env("AUTO_CHECK_THRESHOLD", 10) # 额度低于此百分比触发轮转,默认 10%
|
| 65 |
AUTO_CHECK_MIN_LOW = _get_int_env("AUTO_CHECK_MIN_LOW", 2) # 至少几个账号低于阈值才触发,默认 2
|
| 66 |
|
|
@@ -72,6 +80,23 @@ def _get_bool_env(name: str, default: bool) -> bool:
|
|
| 72 |
return str(raw).strip().lower() in ("1", "true", "yes", "on", "y", "t")
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
# Round 12 S3 — auth_repair 状态机配置(cherry-pick from upstream).
|
| 76 |
# AUTO_CHECK_RETRY_ADD_PHONE=true(默认): 注册被 OpenAI 要求 add_phone 时,
|
| 77 |
# 不立即放弃,而是按指数退避(2^n * AUTO_CHECK_INTERVAL)重试 N 次,N 由
|
|
|
|
| 39 |
return "playwright"
|
| 40 |
|
| 41 |
|
| 42 |
+
def _normalize_sub2api_ws_mode(value: str) -> str:
|
| 43 |
+
mode = str(value or "").strip().lower()
|
| 44 |
+
if mode in {"off", "ctx_pool", "passthrough"}:
|
| 45 |
+
return mode
|
| 46 |
+
return "off"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
# CloudMail 配置
|
| 50 |
CLOUDMAIL_BASE_URL = os.environ.get("CLOUDMAIL_BASE_URL", "")
|
| 51 |
CLOUDMAIL_EMAIL = os.environ.get("CLOUDMAIL_EMAIL", "")
|
|
|
|
| 68 |
|
| 69 |
# 自动巡检配置
|
| 70 |
AUTO_CHECK_INTERVAL = _get_int_env("AUTO_CHECK_INTERVAL", 300) # 巡检间隔(秒),默认 5 分钟
|
| 71 |
+
AUTO_CHECK_TARGET_SEATS = max(1, _get_int_env("AUTO_CHECK_TARGET_SEATS", 3))
|
| 72 |
AUTO_CHECK_THRESHOLD = _get_int_env("AUTO_CHECK_THRESHOLD", 10) # 额度低于此百分比触发轮转,默认 10%
|
| 73 |
AUTO_CHECK_MIN_LOW = _get_int_env("AUTO_CHECK_MIN_LOW", 2) # 至少几个账号低于阈值才触发,默认 2
|
| 74 |
|
|
|
|
| 80 |
return str(raw).strip().lower() in ("1", "true", "yes", "on", "y", "t")
|
| 81 |
|
| 82 |
|
| 83 |
+
# Sub2API target sync configuration. Empty required fields keep the target
|
| 84 |
+
# disabled until explicitly configured.
|
| 85 |
+
SUB2API_URL = os.environ.get("SUB2API_URL", "")
|
| 86 |
+
SUB2API_EMAIL = os.environ.get("SUB2API_EMAIL", "")
|
| 87 |
+
SUB2API_PASSWORD = os.environ.get("SUB2API_PASSWORD", "")
|
| 88 |
+
SUB2API_GROUP = os.environ.get("SUB2API_GROUP", "")
|
| 89 |
+
SUB2API_PROXY = _get_str_env("SUB2API_PROXY", "")
|
| 90 |
+
SUB2API_CONCURRENCY = _get_int_env("SUB2API_CONCURRENCY", 10)
|
| 91 |
+
SUB2API_PRIORITY = _get_int_env("SUB2API_PRIORITY", 1)
|
| 92 |
+
SUB2API_RATE_MULTIPLIER = _get_float_env("SUB2API_RATE_MULTIPLIER", 1)
|
| 93 |
+
SUB2API_AUTO_PAUSE_ON_EXPIRED = _get_bool_env("SUB2API_AUTO_PAUSE_ON_EXPIRED", True)
|
| 94 |
+
SUB2API_MODEL_WHITELIST = _get_str_env("SUB2API_MODEL_WHITELIST", "")
|
| 95 |
+
SUB2API_OPENAI_WS_MODE = _normalize_sub2api_ws_mode(_get_str_env("SUB2API_OPENAI_WS_MODE", "off"))
|
| 96 |
+
SUB2API_OPENAI_PASSTHROUGH = _get_bool_env("SUB2API_OPENAI_PASSTHROUGH", False)
|
| 97 |
+
SUB2API_OVERWRITE_ACCOUNT_SETTINGS = _get_bool_env("SUB2API_OVERWRITE_ACCOUNT_SETTINGS", False)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
# Round 12 S3 — auth_repair 状态机配置(cherry-pick from upstream).
|
| 101 |
# AUTO_CHECK_RETRY_ADD_PHONE=true(默认): 注册被 OpenAI 要求 add_phone 时,
|
| 102 |
# 不立即放弃,而是按指数退避(2^n * AUTO_CHECK_INTERVAL)重试 N 次,N 由
|
src/autoteam/cpa_sync.py
CHANGED
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
| 11 |
import requests
|
| 12 |
|
| 13 |
from autoteam.auth_storage import AUTH_DIR, ensure_auth_dir, ensure_auth_file_permissions
|
| 14 |
-
from autoteam.config import CPA_KEY, CPA_URL
|
| 15 |
from autoteam.textio import write_text
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
|
@@ -209,6 +209,52 @@ def _refresh_account_proxy_url_for_upload(acc: dict, path: Path) -> None:
|
|
| 209 |
logger.warning("[CPA] IPv6 proxy_url 刷新失败,继续上传原凭证: %s (%s)", email, exc)
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def _normalized_auth_path(bundle, main=False):
|
| 213 |
email = bundle.get("email", "")
|
| 214 |
account_id = bundle.get("account_id", "")
|
|
@@ -659,6 +705,34 @@ def sync_to_cpa():
|
|
| 659 |
# CPA 认证文件
|
| 660 |
cpa_files = list_cpa_files()
|
| 661 |
cpa_names = {f["name"]: f for f in cpa_files if f.get("name")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
logger.info(
|
| 664 |
"[CPA] 待同步认证文件: %d (Team=%d, Personal=%d), CPA 现有: %d",
|
|
@@ -667,6 +741,12 @@ def sync_to_cpa():
|
|
| 667 |
synced_personal,
|
| 668 |
len(cpa_files),
|
| 669 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
# 上传:所有 active + personal 认证文件(覆盖同名文件,确保 token 最新)
|
| 672 |
uploaded = 0
|
|
@@ -675,47 +755,26 @@ def sync_to_cpa():
|
|
| 675 |
if upload_to_cpa(path):
|
| 676 |
uploaded += 1
|
| 677 |
|
| 678 |
-
# 删除:CPA 中有但不在同步列表的(仅限本地管理的账号 — 避免误删主号或 CPA 手动文件)
|
| 679 |
-
# 注意:personal 号已计入 files_to_sync,这里不会被删掉;只有状态变成 STANDBY/EXHAUSTED 等才会清理
|
| 680 |
-
#
|
| 681 |
-
# Bug 4B 防御:删 CPA 文件之前再加一道闸 —�� 若该 email 在本地 accounts.json 仍持有
|
| 682 |
-
# **同名** auth_file(物理文件存在),说明本地仍认为这份 token 有用,只是 status 暂时
|
| 683 |
-
# 不在 active/personal(可能是母号切换瞬间 sync_account_states 错刷成 standby、或者
|
| 684 |
-
# exhausted 待 rotate 处置)。这种情况下删 CPA 文件等于把"还能用的实物 token"丢掉,
|
| 685 |
-
# 即便事后状态修回来,CPA 端也得等下一次 sync_to_cpa 重新上传(且若 auth_file 期间
|
| 686 |
-
# 被刷 token 失败,这份就永久丢失)。
|
| 687 |
-
# 因此守卫规则:本地 auth_file 路径存在 + 文件名完全等于 CPA 端 name → 跳过删除,
|
| 688 |
-
# 留 WARN log 让用户知道。让删除只在"本地真的没这份文件了/换名字了"时才发生。
|
| 689 |
-
local_auth_by_email = {}
|
| 690 |
-
for acc in accounts:
|
| 691 |
-
email_l = (acc.get("email") or "").lower()
|
| 692 |
-
auth_path_str = acc.get("auth_file")
|
| 693 |
-
if not email_l or not auth_path_str:
|
| 694 |
-
continue
|
| 695 |
-
try:
|
| 696 |
-
ap = Path(auth_path_str)
|
| 697 |
-
except Exception:
|
| 698 |
-
continue
|
| 699 |
-
local_auth_by_email[email_l] = ap
|
| 700 |
-
|
| 701 |
deleted = 0
|
|
|
|
| 702 |
skipped_protected = 0
|
| 703 |
for name, cpa_file in cpa_names.items():
|
| 704 |
email = cpa_file.get("email", "").lower()
|
| 705 |
if email in local_emails and name not in files_to_sync:
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
logger.warning(
|
| 709 |
-
"[CPA] 跳过删除 %s — 本地仍持有同名 auth_file 物理文件,等下一轮状态稳定后再处理",
|
| 710 |
-
name,
|
| 711 |
-
)
|
| 712 |
skipped_protected += 1
|
| 713 |
continue
|
|
|
|
|
|
|
|
|
|
| 714 |
logger.info("[CPA] 删除非 active/personal 文件: %s (%s)", name, email)
|
| 715 |
if delete_from_cpa(name):
|
| 716 |
deleted += 1
|
| 717 |
if skipped_protected:
|
| 718 |
logger.info("[CPA] 守卫保留 %d 个 CPA 文件(本地仍持有,避免误删 token)", skipped_protected)
|
|
|
|
|
|
|
| 719 |
|
| 720 |
if disabled_skipped:
|
| 721 |
logger.info("[CPA] 跳过 %d 个本地禁用账号", disabled_skipped)
|
|
@@ -741,7 +800,11 @@ def sync_to_cpa():
|
|
| 741 |
"disabled_skipped": disabled_skipped,
|
| 742 |
"local_duplicates_deleted": local_duplicates_deleted,
|
| 743 |
"delete_guard": {
|
|
|
|
|
|
|
|
|
|
| 744 |
"skipped_protected": skipped_protected,
|
|
|
|
| 745 |
},
|
| 746 |
}
|
| 747 |
|
|
|
|
| 11 |
import requests
|
| 12 |
|
| 13 |
from autoteam.auth_storage import AUTH_DIR, ensure_auth_dir, ensure_auth_file_permissions
|
| 14 |
+
from autoteam.config import AUTO_CHECK_TARGET_SEATS, CPA_KEY, CPA_URL
|
| 15 |
from autoteam.textio import write_text
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
|
|
|
| 209 |
logger.warning("[CPA] IPv6 proxy_url 刷新失败,继续上传原凭证: %s (%s)", email, exc)
|
| 210 |
|
| 211 |
|
| 212 |
+
def _active_auth_publish_decision(acc: dict, path: Path) -> str:
|
| 213 |
+
"""Return publish/delete_remote/keep_remote for a local active Codex auth file."""
|
| 214 |
+
if not path.exists():
|
| 215 |
+
return "delete_remote"
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
auth_data = json.loads(path.read_text(encoding="utf-8"))
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
logger.warning("[CPA] 跳过无法读取的 active 凭证 %s: %s", path.name, exc)
|
| 221 |
+
return "delete_remote"
|
| 222 |
+
|
| 223 |
+
access_token = auth_data.get("access_token")
|
| 224 |
+
if not access_token:
|
| 225 |
+
logger.warning("[CPA] 跳过缺少 access_token 的 active 凭证: %s", path.name)
|
| 226 |
+
return "delete_remote"
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
from autoteam.codex_auth import check_codex_quota
|
| 230 |
+
|
| 231 |
+
quota_status, _info = check_codex_quota(access_token, timeout=8)
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
logger.warning("[CPA] active 凭证实时验证异常,保留远端副本等待下轮: %s (%s)", path.name, exc)
|
| 234 |
+
return "keep_remote"
|
| 235 |
+
|
| 236 |
+
if quota_status == "network_error":
|
| 237 |
+
logger.warning("[CPA] active 凭证实时验证网络异常,保留远端副本等待下轮: %s", path.name)
|
| 238 |
+
return "keep_remote"
|
| 239 |
+
|
| 240 |
+
if quota_status == "ok":
|
| 241 |
+
try:
|
| 242 |
+
from autoteam.ipv6_pool import ipv6_pool
|
| 243 |
+
|
| 244 |
+
email = (acc.get("email") or auth_data.get("email") or "").strip().lower()
|
| 245 |
+
proxy_url = ipv6_pool.ensure(email) or ""
|
| 246 |
+
if proxy_url and auth_data.get("proxy_url") != proxy_url:
|
| 247 |
+
auth_data["proxy_url"] = proxy_url
|
| 248 |
+
write_text(path, json.dumps(auth_data, indent=2, ensure_ascii=False))
|
| 249 |
+
logger.info("[CPA] 已刷新 active 凭证 proxy_url: %s", email)
|
| 250 |
+
except Exception as exc:
|
| 251 |
+
logger.warning("[CPA] active 凭证 IPv6 proxy_url 刷新失败,继续上传原凭证: %s", exc)
|
| 252 |
+
return "publish"
|
| 253 |
+
|
| 254 |
+
logger.warning("[CPA] 跳过实时验证失败的 active 凭证: %s (%s)", path.name, quota_status)
|
| 255 |
+
return "delete_remote"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
def _normalized_auth_path(bundle, main=False):
|
| 259 |
email = bundle.get("email", "")
|
| 260 |
account_id = bundle.get("account_id", "")
|
|
|
|
| 705 |
# CPA 认证文件
|
| 706 |
cpa_files = list_cpa_files()
|
| 707 |
cpa_names = {f["name"]: f for f in cpa_files if f.get("name")}
|
| 708 |
+
min_active_for_remote_delete = max(1, int(AUTO_CHECK_TARGET_SEATS) - 1)
|
| 709 |
+
allow_remote_delete = synced_active >= min_active_for_remote_delete
|
| 710 |
+
|
| 711 |
+
try:
|
| 712 |
+
from autoteam.manager import _is_protected_local_credential_seat
|
| 713 |
+
except Exception:
|
| 714 |
+
_is_protected_local_credential_seat = None
|
| 715 |
+
|
| 716 |
+
protected_remote_names = set()
|
| 717 |
+
protected_remote_emails = set()
|
| 718 |
+
if _is_protected_local_credential_seat is not None:
|
| 719 |
+
for acc in accounts:
|
| 720 |
+
if is_account_disabled(acc):
|
| 721 |
+
continue
|
| 722 |
+
email = str(acc.get("email") or "").strip().lower()
|
| 723 |
+
auth_path_value = acc.get("auth_file")
|
| 724 |
+
if not email:
|
| 725 |
+
continue
|
| 726 |
+
try:
|
| 727 |
+
if _is_protected_local_credential_seat(acc):
|
| 728 |
+
protected_remote_emails.add(email)
|
| 729 |
+
if auth_path_value:
|
| 730 |
+
protected_remote_names.add(Path(auth_path_value).name)
|
| 731 |
+
except Exception as exc:
|
| 732 |
+
logger.warning("[CPA] 判断受保护凭证失败,保留远端副本: %s (%s)", email, exc)
|
| 733 |
+
protected_remote_emails.add(email)
|
| 734 |
+
if auth_path_value:
|
| 735 |
+
protected_remote_names.add(Path(auth_path_value).name)
|
| 736 |
|
| 737 |
logger.info(
|
| 738 |
"[CPA] 待同步认证文件: %d (Team=%d, Personal=%d), CPA 现有: %d",
|
|
|
|
| 741 |
synced_personal,
|
| 742 |
len(cpa_files),
|
| 743 |
)
|
| 744 |
+
if not allow_remote_delete:
|
| 745 |
+
logger.warning(
|
| 746 |
+
"[CPA] active 凭证不足,跳过本轮远端删除: %d/%d",
|
| 747 |
+
synced_active,
|
| 748 |
+
min_active_for_remote_delete,
|
| 749 |
+
)
|
| 750 |
|
| 751 |
# 上传:所有 active + personal 认证文件(覆盖同名文件,确保 token 最新)
|
| 752 |
uploaded = 0
|
|
|
|
| 755 |
if upload_to_cpa(path):
|
| 756 |
uploaded += 1
|
| 757 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
deleted = 0
|
| 759 |
+
skipped_remote_delete = 0
|
| 760 |
skipped_protected = 0
|
| 761 |
for name, cpa_file in cpa_names.items():
|
| 762 |
email = cpa_file.get("email", "").lower()
|
| 763 |
if email in local_emails and name not in files_to_sync:
|
| 764 |
+
if name in protected_remote_names or email in protected_remote_emails:
|
| 765 |
+
logger.info("[CPA] 保留受保护本地凭证远端副本: %s (%s)", name, email)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
skipped_protected += 1
|
| 767 |
continue
|
| 768 |
+
if not allow_remote_delete:
|
| 769 |
+
skipped_remote_delete += 1
|
| 770 |
+
continue
|
| 771 |
logger.info("[CPA] 删除非 active/personal 文件: %s (%s)", name, email)
|
| 772 |
if delete_from_cpa(name):
|
| 773 |
deleted += 1
|
| 774 |
if skipped_protected:
|
| 775 |
logger.info("[CPA] 守卫保留 %d 个 CPA 文件(本地仍持有,避免误删 token)", skipped_protected)
|
| 776 |
+
if skipped_remote_delete:
|
| 777 |
+
logger.warning("[CPA] 本轮因 active 凭证不足保留远端非 active 文件: %d", skipped_remote_delete)
|
| 778 |
|
| 779 |
if disabled_skipped:
|
| 780 |
logger.info("[CPA] 跳过 %d 个本地禁用账号", disabled_skipped)
|
|
|
|
| 800 |
"disabled_skipped": disabled_skipped,
|
| 801 |
"local_duplicates_deleted": local_duplicates_deleted,
|
| 802 |
"delete_guard": {
|
| 803 |
+
"allow_remote_delete": allow_remote_delete,
|
| 804 |
+
"min_active_for_remote_delete": min_active_for_remote_delete,
|
| 805 |
+
"skipped_remote_delete": skipped_remote_delete,
|
| 806 |
"skipped_protected": skipped_protected,
|
| 807 |
+
"skipped_protected_delete": skipped_protected,
|
| 808 |
},
|
| 809 |
}
|
| 810 |
|
src/autoteam/invite.py
CHANGED
|
@@ -32,7 +32,7 @@ from autoteam.accounts import (
|
|
| 32 |
)
|
| 33 |
from autoteam.chatgpt_api import ChatGPTTeamAPI
|
| 34 |
from autoteam.cloudmail import CloudMailClient
|
| 35 |
-
from autoteam.config import get_playwright_launch_options
|
| 36 |
from autoteam.identity import random_password
|
| 37 |
from autoteam.playwright_lifecycle import close_playwright_objects
|
| 38 |
from autoteam.signup_profile import generate_signup_profile
|
|
@@ -580,10 +580,7 @@ def run():
|
|
| 580 |
page = None
|
| 581 |
try:
|
| 582 |
browser = p.chromium.launch(**get_playwright_launch_options())
|
| 583 |
-
context = browser.new_context(
|
| 584 |
-
viewport={"width": 1280, "height": 800},
|
| 585 |
-
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
| 586 |
-
)
|
| 587 |
page = context.new_page()
|
| 588 |
|
| 589 |
result, pwd = register_with_invite(page, invite_link, email, mail_client)
|
|
|
|
| 32 |
)
|
| 33 |
from autoteam.chatgpt_api import ChatGPTTeamAPI
|
| 34 |
from autoteam.cloudmail import CloudMailClient
|
| 35 |
+
from autoteam.config import get_playwright_context_options, get_playwright_launch_options
|
| 36 |
from autoteam.identity import random_password
|
| 37 |
from autoteam.playwright_lifecycle import close_playwright_objects
|
| 38 |
from autoteam.signup_profile import generate_signup_profile
|
|
|
|
| 580 |
page = None
|
| 581 |
try:
|
| 582 |
browser = p.chromium.launch(**get_playwright_launch_options())
|
| 583 |
+
context = browser.new_context(**get_playwright_context_options())
|
|
|
|
|
|
|
|
|
|
| 584 |
page = context.new_page()
|
| 585 |
|
| 586 |
result, pwd = register_with_invite(page, invite_link, email, mail_client)
|
src/autoteam/manager.py
CHANGED
|
@@ -65,12 +65,17 @@ from autoteam.codex_auth import (
|
|
| 65 |
save_auth_file,
|
| 66 |
)
|
| 67 |
from autoteam.config import get_playwright_context_options, get_playwright_launch_options
|
| 68 |
-
from autoteam.cpa_sync import sync_from_cpa
|
| 69 |
from autoteam.identity import random_birthday, random_password
|
| 70 |
from autoteam.invite import RegisterBlocked # SPEC-2 shared/add-phone-detection §5 — 5 处 OAuth 调用方 catch
|
| 71 |
from autoteam.playwright_lifecycle import close_playwright_objects
|
| 72 |
from autoteam.register_failures import MASTER_SUBSCRIPTION_DEGRADED, record_failure
|
| 73 |
from autoteam.signup_profile import SignupProfile, generate_signup_profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
from autoteam.textio import read_text, write_text
|
| 75 |
|
| 76 |
logger = logging.getLogger(__name__)
|
|
@@ -191,6 +196,46 @@ def _has_auth_file(acc: dict | None) -> bool:
|
|
| 191 |
return bool(auth_file) and Path(auth_file).exists()
|
| 192 |
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
def _ensure_account_ipv6_proxy(email: str | None) -> tuple[str, str]:
|
| 195 |
email = _normalized_email(email)
|
| 196 |
if not email:
|
|
@@ -2449,6 +2494,8 @@ def _run_post_register_oauth(
|
|
| 2449 |
)
|
| 2450 |
# personal 分支:已主动退出 Team,bundle 是个人 free/plus plan,算 codex 席位
|
| 2451 |
update_account(email, **update_fields)
|
|
|
|
|
|
|
| 2452 |
logger.info("[注册] 免费号就绪: %s (plan=%s, attempts=%d)",
|
| 2453 |
email, bundle.get("plan_type"), len(plan_drift_history) + 1)
|
| 2454 |
_record_outcome("success", plan=bundle.get("plan_type"))
|
|
@@ -2624,6 +2671,8 @@ def _run_post_register_oauth(
|
|
| 2624 |
stage="run_post_register_oauth_team")
|
| 2625 |
|
| 2626 |
update_account(email, **update_fields)
|
|
|
|
|
|
|
| 2627 |
if update_fields["status"] == STATUS_ACTIVE:
|
| 2628 |
logger.info("[注册] 账号就绪: %s (seat=%s)", email, seat_label)
|
| 2629 |
_record_outcome("success", plan=bundle_plan)
|
|
@@ -4223,6 +4272,7 @@ def reinvite_account(chatgpt_api, mail_client, acc):
|
|
| 4223 |
"[轮转] %s _auth_repair_reset 抛异常(忽略): %s",
|
| 4224 |
email, repair_exc,
|
| 4225 |
)
|
|
|
|
| 4226 |
logger.info("[轮转] 旧账号已恢复: %s", email)
|
| 4227 |
return True
|
| 4228 |
|
|
|
|
| 65 |
save_auth_file,
|
| 66 |
)
|
| 67 |
from autoteam.config import get_playwright_context_options, get_playwright_launch_options
|
| 68 |
+
from autoteam.cpa_sync import sync_from_cpa
|
| 69 |
from autoteam.identity import random_birthday, random_password
|
| 70 |
from autoteam.invite import RegisterBlocked # SPEC-2 shared/add-phone-detection §5 — 5 处 OAuth 调用方 catch
|
| 71 |
from autoteam.playwright_lifecycle import close_playwright_objects
|
| 72 |
from autoteam.register_failures import MASTER_SUBSCRIPTION_DEGRADED, record_failure
|
| 73 |
from autoteam.signup_profile import SignupProfile, generate_signup_profile
|
| 74 |
+
from autoteam.sync_targets import (
|
| 75 |
+
sync_account_to_configured_targets,
|
| 76 |
+
sync_main_codex_to_configured_targets as sync_main_codex_to_cpa,
|
| 77 |
+
sync_to_configured_targets as sync_to_cpa,
|
| 78 |
+
)
|
| 79 |
from autoteam.textio import read_text, write_text
|
| 80 |
|
| 81 |
logger = logging.getLogger(__name__)
|
|
|
|
| 196 |
return bool(auth_file) and Path(auth_file).exists()
|
| 197 |
|
| 198 |
|
| 199 |
+
def _has_account_mail_binding(acc: dict | None) -> bool:
|
| 200 |
+
acc = acc or {}
|
| 201 |
+
return acc.get("mail_account_id") is not None or acc.get("cloudmail_account_id") is not None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _is_protected_local_credential_seat(acc: dict | None) -> bool:
|
| 205 |
+
if not acc or acc.get("disabled") is True or _is_main_account_email(acc.get("email")):
|
| 206 |
+
return False
|
| 207 |
+
has_auth = _has_auth_file(acc)
|
| 208 |
+
if acc.get("status") == STATUS_EXHAUSTED:
|
| 209 |
+
return False
|
| 210 |
+
if acc.get("protect_team_seat") is True:
|
| 211 |
+
return has_auth
|
| 212 |
+
if not has_auth:
|
| 213 |
+
return False
|
| 214 |
+
if acc.get("status") in (STATUS_STANDBY, STATUS_AUTH_INVALID):
|
| 215 |
+
return True
|
| 216 |
+
return not _has_account_mail_binding(acc)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _sync_ready_credential_to_targets(email: str, auth_file: str | None, *, stage_label: str) -> dict:
|
| 220 |
+
if not auth_file:
|
| 221 |
+
logger.warning("%s 新凭证已就绪但缺少 auth_file,跳过即时同步: %s", stage_label, email)
|
| 222 |
+
return {"ok": False, "skipped": True, "reason": "missing_auth_file"}
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
result = sync_account_to_configured_targets(email, str(auth_file))
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
logger.warning("%s 新凭证即时同步失败,保留本地结果: %s (%s)", stage_label, email, exc)
|
| 228 |
+
return {"ok": False, "error": str(exc)}
|
| 229 |
+
|
| 230 |
+
if isinstance(result, dict) and result.get("ok") and not result.get("skipped"):
|
| 231 |
+
logger.info("%s 新凭证已即时同步: %s (%s)", stage_label, email, Path(auth_file).name)
|
| 232 |
+
elif isinstance(result, dict) and result.get("ok") and result.get("skipped"):
|
| 233 |
+
logger.info("%s 新凭证即时同步跳过: %s (%s)", stage_label, email, result.get("reason"))
|
| 234 |
+
else:
|
| 235 |
+
logger.warning("%s 新凭证即时同步未完全成功,保留本地结果: %s (%s)", stage_label, email, result)
|
| 236 |
+
return result if isinstance(result, dict) else {"ok": False, "result": result}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def _ensure_account_ipv6_proxy(email: str | None) -> tuple[str, str]:
|
| 240 |
email = _normalized_email(email)
|
| 241 |
if not email:
|
|
|
|
| 2494 |
)
|
| 2495 |
# personal 分支:已主动退出 Team,bundle 是个人 free/plus plan,算 codex 席位
|
| 2496 |
update_account(email, **update_fields)
|
| 2497 |
+
if update_fields["status"] == STATUS_ACTIVE:
|
| 2498 |
+
_sync_ready_credential_to_targets(email, auth_file, stage_label="[注册]")
|
| 2499 |
logger.info("[注册] 免费号就绪: %s (plan=%s, attempts=%d)",
|
| 2500 |
email, bundle.get("plan_type"), len(plan_drift_history) + 1)
|
| 2501 |
_record_outcome("success", plan=bundle.get("plan_type"))
|
|
|
|
| 2671 |
stage="run_post_register_oauth_team")
|
| 2672 |
|
| 2673 |
update_account(email, **update_fields)
|
| 2674 |
+
if update_fields["status"] == STATUS_ACTIVE:
|
| 2675 |
+
_sync_ready_credential_to_targets(email, auth_file, stage_label="[注册]")
|
| 2676 |
if update_fields["status"] == STATUS_ACTIVE:
|
| 2677 |
logger.info("[注册] 账号就绪: %s (seat=%s)", email, seat_label)
|
| 2678 |
_record_outcome("success", plan=bundle_plan)
|
|
|
|
| 4272 |
"[轮转] %s _auth_repair_reset 抛异常(忽略): %s",
|
| 4273 |
email, repair_exc,
|
| 4274 |
)
|
| 4275 |
+
_sync_ready_credential_to_targets(email, auth_file, stage_label="[轮转]")
|
| 4276 |
logger.info("[轮转] 旧账号已恢复: %s", email)
|
| 4277 |
return True
|
| 4278 |
|
src/autoteam/sub2api_sync.py
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sub2API 账号同步。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
from autoteam.codex_auth import CODEX_CLIENT_ID
|
| 15 |
+
from autoteam.config import (
|
| 16 |
+
SUB2API_AUTO_PAUSE_ON_EXPIRED,
|
| 17 |
+
SUB2API_CONCURRENCY,
|
| 18 |
+
SUB2API_EMAIL,
|
| 19 |
+
SUB2API_GROUP,
|
| 20 |
+
SUB2API_MODEL_WHITELIST,
|
| 21 |
+
SUB2API_OPENAI_PASSTHROUGH,
|
| 22 |
+
SUB2API_OPENAI_WS_MODE,
|
| 23 |
+
SUB2API_OVERWRITE_ACCOUNT_SETTINGS,
|
| 24 |
+
SUB2API_PASSWORD,
|
| 25 |
+
SUB2API_PRIORITY,
|
| 26 |
+
SUB2API_PROXY,
|
| 27 |
+
SUB2API_RATE_MULTIPLIER,
|
| 28 |
+
SUB2API_URL,
|
| 29 |
+
)
|
| 30 |
+
from autoteam.textio import read_text
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
_TIMEOUT = 10
|
| 35 |
+
_PAGE_SIZE = 200
|
| 36 |
+
|
| 37 |
+
_EXTRA_MANAGED = "autoteam_managed"
|
| 38 |
+
_EXTRA_KIND = "autoteam_kind"
|
| 39 |
+
_EXTRA_EMAIL = "autoteam_email"
|
| 40 |
+
_EXTRA_AUTH_FILE = "autoteam_auth_file"
|
| 41 |
+
_EXTRA_SOURCE = "autoteam_source"
|
| 42 |
+
_EXTRA_LAST_SYNC_AT = "autoteam_last_sync_at"
|
| 43 |
+
_EXTRA_GROUP_IDS = "autoteam_sub2api_group_ids"
|
| 44 |
+
_EXTRA_GROUP_NAMES = "autoteam_sub2api_group_names"
|
| 45 |
+
|
| 46 |
+
_KIND_POOL = "pool"
|
| 47 |
+
_KIND_MAIN = "main"
|
| 48 |
+
|
| 49 |
+
_REMOTE_AUTH_FILE_PREFIX = "sub2api-"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _excerpt(text: str | bytes | None, limit: int = 200) -> str:
|
| 53 |
+
value = text.decode("utf-8", errors="ignore") if isinstance(text, bytes) else str(text or "")
|
| 54 |
+
value = value.strip().replace("\n", " ")
|
| 55 |
+
if len(value) > limit:
|
| 56 |
+
value = value[:limit] + "..."
|
| 57 |
+
return value
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _api_base_url() -> str:
|
| 61 |
+
base = (SUB2API_URL or "").strip().rstrip("/")
|
| 62 |
+
if base.endswith("/api/v1"):
|
| 63 |
+
return base
|
| 64 |
+
if base.endswith("/api"):
|
| 65 |
+
return f"{base}/v1"
|
| 66 |
+
return f"{base}/api/v1"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _request(method: str, path: str, *, token: str | None = None, label: str, **kwargs):
|
| 70 |
+
url = f"{_api_base_url()}{path}"
|
| 71 |
+
headers = dict(kwargs.pop("headers", {}) or {})
|
| 72 |
+
if token:
|
| 73 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 74 |
+
resp = requests.request(method, url, headers=headers, timeout=_TIMEOUT, **kwargs)
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
payload = resp.json()
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
raise RuntimeError(f"[Sub2API] {label}返回了非 JSON 内容: {_excerpt(resp.text)}") from exc
|
| 80 |
+
|
| 81 |
+
if resp.status_code != 200:
|
| 82 |
+
message = payload.get("message") or payload.get("detail") or _excerpt(resp.text)
|
| 83 |
+
raise RuntimeError(f"[Sub2API] {label}失败: HTTP {resp.status_code} {message}")
|
| 84 |
+
|
| 85 |
+
if payload.get("code", 0) != 0:
|
| 86 |
+
message = payload.get("message") or _excerpt(resp.text)
|
| 87 |
+
raise RuntimeError(f"[Sub2API] {label}失败: {message}")
|
| 88 |
+
|
| 89 |
+
return payload.get("data")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _login() -> str:
|
| 93 |
+
data = _request(
|
| 94 |
+
"POST",
|
| 95 |
+
"/auth/login",
|
| 96 |
+
label="管理员登录",
|
| 97 |
+
json={"email": SUB2API_EMAIL, "password": SUB2API_PASSWORD},
|
| 98 |
+
)
|
| 99 |
+
if not isinstance(data, dict):
|
| 100 |
+
raise RuntimeError("[Sub2API] 管理员登录返回格式异常")
|
| 101 |
+
if data.get("requires_2fa"):
|
| 102 |
+
raise RuntimeError("[Sub2API] 当前账号启用了 2FA,AutoTeam 暂不支持自动同步到 Sub2API")
|
| 103 |
+
access_token = (data.get("access_token") or "").strip()
|
| 104 |
+
if not access_token:
|
| 105 |
+
raise RuntimeError("[Sub2API] 管理员登录成功但未返回 access_token")
|
| 106 |
+
return access_token
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _list_openai_oauth_accounts(token: str) -> list[dict]:
|
| 110 |
+
items = []
|
| 111 |
+
page = 1
|
| 112 |
+
|
| 113 |
+
while True:
|
| 114 |
+
data = _request(
|
| 115 |
+
"GET",
|
| 116 |
+
"/admin/accounts",
|
| 117 |
+
token=token,
|
| 118 |
+
label="获取账号列表",
|
| 119 |
+
params={
|
| 120 |
+
"page": page,
|
| 121 |
+
"page_size": _PAGE_SIZE,
|
| 122 |
+
"platform": "openai",
|
| 123 |
+
"type": "oauth",
|
| 124 |
+
"sort_by": "id",
|
| 125 |
+
"sort_order": "asc",
|
| 126 |
+
},
|
| 127 |
+
)
|
| 128 |
+
page_items = data.get("items") if isinstance(data, dict) else None
|
| 129 |
+
if not isinstance(page_items, list):
|
| 130 |
+
break
|
| 131 |
+
items.extend(page_items)
|
| 132 |
+
total = int(data.get("total") or 0) if isinstance(data, dict) else 0
|
| 133 |
+
if not page_items or len(items) >= total:
|
| 134 |
+
break
|
| 135 |
+
page += 1
|
| 136 |
+
|
| 137 |
+
return items
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _is_managed_account(item: dict, *, kind: str | None = None) -> bool:
|
| 141 |
+
extra = item.get("extra") or {}
|
| 142 |
+
if not isinstance(extra, dict):
|
| 143 |
+
return False
|
| 144 |
+
if extra.get(_EXTRA_SOURCE) != "autoteam" and not extra.get(_EXTRA_MANAGED):
|
| 145 |
+
return False
|
| 146 |
+
if kind and extra.get(_EXTRA_KIND) != kind:
|
| 147 |
+
return False
|
| 148 |
+
return True
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _managed_email(item: dict) -> str:
|
| 152 |
+
extra = item.get("extra") or {}
|
| 153 |
+
credentials = item.get("credentials") or {}
|
| 154 |
+
return (extra.get(_EXTRA_EMAIL) or credentials.get("email") or item.get("name") or "").strip().lower()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _managed_auth_file(item: dict) -> str:
|
| 158 |
+
extra = item.get("extra") or {}
|
| 159 |
+
return (extra.get(_EXTRA_AUTH_FILE) or "").strip()
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _managed_group_ids(item: dict) -> list[int]:
|
| 163 |
+
extra = item.get("extra") or {}
|
| 164 |
+
values = extra.get(_EXTRA_GROUP_IDS)
|
| 165 |
+
if not isinstance(values, list):
|
| 166 |
+
return []
|
| 167 |
+
|
| 168 |
+
result = []
|
| 169 |
+
seen = set()
|
| 170 |
+
for value in values:
|
| 171 |
+
try:
|
| 172 |
+
group_id = int(value)
|
| 173 |
+
except (TypeError, ValueError):
|
| 174 |
+
continue
|
| 175 |
+
if group_id <= 0 or group_id in seen:
|
| 176 |
+
continue
|
| 177 |
+
seen.add(group_id)
|
| 178 |
+
result.append(group_id)
|
| 179 |
+
return result
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _remote_auth_file_name(local_name: str) -> str:
|
| 183 |
+
value = str(local_name or "").strip()
|
| 184 |
+
if not value:
|
| 185 |
+
return ""
|
| 186 |
+
if value.startswith(_REMOTE_AUTH_FILE_PREFIX):
|
| 187 |
+
return value
|
| 188 |
+
return f"{_REMOTE_AUTH_FILE_PREFIX}{value}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _remote_auth_file_candidates(names: list[str] | None) -> set[str]:
|
| 192 |
+
candidates = set()
|
| 193 |
+
for name in names or []:
|
| 194 |
+
value = str(name or "").strip()
|
| 195 |
+
if not value:
|
| 196 |
+
continue
|
| 197 |
+
candidates.add(value)
|
| 198 |
+
candidates.add(_remote_auth_file_name(value))
|
| 199 |
+
return candidates
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _split_group_spec(value: str | None) -> list[str]:
|
| 203 |
+
text = str(value or "").replace(",", ",")
|
| 204 |
+
parts = []
|
| 205 |
+
seen = set()
|
| 206 |
+
for raw in text.replace("\n", ",").split(","):
|
| 207 |
+
item = raw.strip()
|
| 208 |
+
if not item:
|
| 209 |
+
continue
|
| 210 |
+
lowered = item.lower()
|
| 211 |
+
if lowered in seen:
|
| 212 |
+
continue
|
| 213 |
+
seen.add(lowered)
|
| 214 |
+
parts.append(item)
|
| 215 |
+
return parts
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _split_csv_spec(value: str | None) -> list[str]:
|
| 219 |
+
text = str(value or "").replace(",", ",")
|
| 220 |
+
parts = []
|
| 221 |
+
seen = set()
|
| 222 |
+
for raw in text.replace("\n", ",").split(","):
|
| 223 |
+
item = raw.strip()
|
| 224 |
+
if not item:
|
| 225 |
+
continue
|
| 226 |
+
lowered = item.lower()
|
| 227 |
+
if lowered in seen:
|
| 228 |
+
continue
|
| 229 |
+
seen.add(lowered)
|
| 230 |
+
parts.append(item)
|
| 231 |
+
return parts
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _build_managed_model_mapping(model_whitelist: str | None = None) -> dict[str, str] | None:
|
| 235 |
+
models = _split_csv_spec(model_whitelist if model_whitelist is not None else SUB2API_MODEL_WHITELIST)
|
| 236 |
+
if not models:
|
| 237 |
+
return None
|
| 238 |
+
return {model: model for model in models}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _build_account_settings() -> dict:
|
| 242 |
+
return {
|
| 243 |
+
"concurrency": SUB2API_CONCURRENCY,
|
| 244 |
+
"priority": SUB2API_PRIORITY,
|
| 245 |
+
"rate_multiplier": SUB2API_RATE_MULTIPLIER,
|
| 246 |
+
"auto_pause_on_expired": SUB2API_AUTO_PAUSE_ON_EXPIRED,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _apply_managed_credentials_settings(credentials: dict, *, model_whitelist: str | None = None) -> dict:
|
| 251 |
+
model_mapping = _build_managed_model_mapping(model_whitelist)
|
| 252 |
+
if model_mapping:
|
| 253 |
+
credentials["model_mapping"] = model_mapping
|
| 254 |
+
return credentials
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _apply_managed_extra_settings(extra: dict) -> dict:
|
| 258 |
+
extra["openai_oauth_responses_websockets_v2_mode"] = SUB2API_OPENAI_WS_MODE
|
| 259 |
+
extra["openai_oauth_responses_websockets_v2_enabled"] = SUB2API_OPENAI_WS_MODE != "off"
|
| 260 |
+
if SUB2API_OPENAI_PASSTHROUGH:
|
| 261 |
+
extra["openai_passthrough"] = True
|
| 262 |
+
else:
|
| 263 |
+
extra.pop("openai_passthrough", None)
|
| 264 |
+
extra.pop("openai_oauth_passthrough", None)
|
| 265 |
+
return extra
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _dedupe_managed_accounts(token: str, items: list[dict], *, kind: str) -> tuple[dict[str, dict], int]:
|
| 269 |
+
deduped: dict[str, dict] = {}
|
| 270 |
+
duplicates_deleted = 0
|
| 271 |
+
|
| 272 |
+
for item in items:
|
| 273 |
+
if not _is_managed_account(item, kind=kind):
|
| 274 |
+
continue
|
| 275 |
+
|
| 276 |
+
key = _managed_email(item)
|
| 277 |
+
if not key:
|
| 278 |
+
key = f"{kind}:{item.get('id')}"
|
| 279 |
+
|
| 280 |
+
previous = deduped.get(key)
|
| 281 |
+
if previous is None or int(item.get("id") or 0) > int(previous.get("id") or 0):
|
| 282 |
+
if previous is not None:
|
| 283 |
+
_delete_account(token, previous, label="删除重复账号")
|
| 284 |
+
duplicates_deleted += 1
|
| 285 |
+
deduped[key] = item
|
| 286 |
+
else:
|
| 287 |
+
_delete_account(token, item, label="删除重复账号")
|
| 288 |
+
duplicates_deleted += 1
|
| 289 |
+
|
| 290 |
+
return deduped, duplicates_deleted
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _select_managed_account_by_email(items: list[dict], email: str, *, kind: str) -> tuple[dict | None, int]:
|
| 294 |
+
matches = [
|
| 295 |
+
item
|
| 296 |
+
for item in items
|
| 297 |
+
if _is_managed_account(item, kind=kind) and _managed_email(item) == email.lower()
|
| 298 |
+
]
|
| 299 |
+
if not matches:
|
| 300 |
+
return None, 0
|
| 301 |
+
matches.sort(key=lambda item: int(item.get("id") or 0), reverse=True)
|
| 302 |
+
return matches[0], max(0, len(matches) - 1)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _parse_jwt_payload(token: str) -> dict:
|
| 306 |
+
parts = (token or "").split(".")
|
| 307 |
+
if len(parts) < 2:
|
| 308 |
+
return {}
|
| 309 |
+
payload = parts[1]
|
| 310 |
+
payload += "=" * (-len(payload) % 4)
|
| 311 |
+
try:
|
| 312 |
+
return json.loads(base64.urlsafe_b64decode(payload))
|
| 313 |
+
except Exception:
|
| 314 |
+
return {}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _list_openai_groups(token: str) -> list[dict]:
|
| 318 |
+
data = _request(
|
| 319 |
+
"GET",
|
| 320 |
+
"/admin/groups/all",
|
| 321 |
+
token=token,
|
| 322 |
+
label="获取 Sub2API 分组列表",
|
| 323 |
+
params={"platform": "openai"},
|
| 324 |
+
)
|
| 325 |
+
return data if isinstance(data, list) else []
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _get_group_by_id(token: str, group_id: int) -> dict | None:
|
| 329 |
+
try:
|
| 330 |
+
data = _request(
|
| 331 |
+
"GET",
|
| 332 |
+
f"/admin/groups/{group_id}",
|
| 333 |
+
token=token,
|
| 334 |
+
label=f"获取 Sub2API 分组 {group_id}",
|
| 335 |
+
)
|
| 336 |
+
except Exception:
|
| 337 |
+
return None
|
| 338 |
+
return data if isinstance(data, dict) else None
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _resolve_group_binding(token: str, group_spec: str | None = None) -> tuple[list[int], list[str]]:
|
| 342 |
+
parts = _split_group_spec(group_spec if group_spec is not None else SUB2API_GROUP)
|
| 343 |
+
if not parts:
|
| 344 |
+
return [], []
|
| 345 |
+
|
| 346 |
+
groups = _list_openai_groups(token)
|
| 347 |
+
by_id = {}
|
| 348 |
+
by_name = {}
|
| 349 |
+
for item in groups:
|
| 350 |
+
if not isinstance(item, dict):
|
| 351 |
+
continue
|
| 352 |
+
try:
|
| 353 |
+
group_id = int(item.get("id") or 0)
|
| 354 |
+
except (TypeError, ValueError):
|
| 355 |
+
continue
|
| 356 |
+
if group_id <= 0:
|
| 357 |
+
continue
|
| 358 |
+
by_id[group_id] = item
|
| 359 |
+
name = str(item.get("name") or "").strip()
|
| 360 |
+
if name:
|
| 361 |
+
by_name[name.lower()] = item
|
| 362 |
+
|
| 363 |
+
resolved_ids = []
|
| 364 |
+
resolved_names = []
|
| 365 |
+
seen_ids = set()
|
| 366 |
+
|
| 367 |
+
for part in parts:
|
| 368 |
+
group = None
|
| 369 |
+
if part.isdigit():
|
| 370 |
+
group = by_id.get(int(part)) or _get_group_by_id(token, int(part))
|
| 371 |
+
else:
|
| 372 |
+
group = by_name.get(part.lower())
|
| 373 |
+
|
| 374 |
+
if not isinstance(group, dict):
|
| 375 |
+
raise RuntimeError(f"[Sub2API] 未找到分组: {part}")
|
| 376 |
+
|
| 377 |
+
platform = str(group.get("platform") or "").strip().lower()
|
| 378 |
+
if platform and platform != "openai":
|
| 379 |
+
raise RuntimeError(f"[Sub2API] 分组 {part} 不是 openai 平台,当前平台: {platform}")
|
| 380 |
+
|
| 381 |
+
try:
|
| 382 |
+
group_id = int(group.get("id") or 0)
|
| 383 |
+
except (TypeError, ValueError):
|
| 384 |
+
group_id = 0
|
| 385 |
+
if group_id <= 0:
|
| 386 |
+
raise RuntimeError(f"[Sub2API] 分组 {part} 缺少有效 ID")
|
| 387 |
+
if group_id in seen_ids:
|
| 388 |
+
continue
|
| 389 |
+
|
| 390 |
+
seen_ids.add(group_id)
|
| 391 |
+
resolved_ids.append(group_id)
|
| 392 |
+
resolved_names.append(str(group.get("name") or group_id))
|
| 393 |
+
|
| 394 |
+
return resolved_ids, resolved_names
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _list_proxies(token: str) -> list[dict]:
|
| 398 |
+
data = _request(
|
| 399 |
+
"GET",
|
| 400 |
+
"/admin/proxies/all",
|
| 401 |
+
token=token,
|
| 402 |
+
label="获取 Sub2API 代理列表",
|
| 403 |
+
)
|
| 404 |
+
return data if isinstance(data, list) else []
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _resolve_proxy_id(token: str, proxy_spec: str | None = None) -> int | None:
|
| 408 |
+
spec = str(proxy_spec if proxy_spec is not None else SUB2API_PROXY or "").strip()
|
| 409 |
+
if not spec:
|
| 410 |
+
return None
|
| 411 |
+
|
| 412 |
+
if spec.lstrip("+-").isdigit():
|
| 413 |
+
proxy_id = int(spec)
|
| 414 |
+
if proxy_id <= 0:
|
| 415 |
+
raise RuntimeError(f"[Sub2API] 代理 ID 必须是正整数: {spec}")
|
| 416 |
+
return proxy_id
|
| 417 |
+
|
| 418 |
+
proxies = _list_proxies(token)
|
| 419 |
+
matches = []
|
| 420 |
+
for item in proxies:
|
| 421 |
+
if not isinstance(item, dict):
|
| 422 |
+
continue
|
| 423 |
+
name = str(item.get("name") or "").strip()
|
| 424 |
+
if name.lower() == spec.lower():
|
| 425 |
+
matches.append(item)
|
| 426 |
+
|
| 427 |
+
if not matches:
|
| 428 |
+
raise RuntimeError(f"[Sub2API] 未找到代理: {spec}")
|
| 429 |
+
if len(matches) > 1:
|
| 430 |
+
raise RuntimeError(f"[Sub2API] 找到多个同名代理: {spec}")
|
| 431 |
+
|
| 432 |
+
try:
|
| 433 |
+
proxy_id = int(matches[0].get("id") or 0)
|
| 434 |
+
except (TypeError, ValueError):
|
| 435 |
+
proxy_id = 0
|
| 436 |
+
if proxy_id <= 0:
|
| 437 |
+
raise RuntimeError(f"[Sub2API] 代理 {spec} 缺少有效 ID")
|
| 438 |
+
return proxy_id
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _extract_organization_id(auth_claims: dict) -> str:
|
| 442 |
+
organizations = auth_claims.get("organizations")
|
| 443 |
+
if not isinstance(organizations, list):
|
| 444 |
+
return ""
|
| 445 |
+
for item in organizations:
|
| 446 |
+
if not isinstance(item, dict):
|
| 447 |
+
continue
|
| 448 |
+
if item.get("is_default") and item.get("id"):
|
| 449 |
+
return str(item["id"])
|
| 450 |
+
for item in organizations:
|
| 451 |
+
if isinstance(item, dict) and item.get("id"):
|
| 452 |
+
return str(item["id"])
|
| 453 |
+
return ""
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _parse_timestamp(value, *, default: int | None = None) -> int | None:
|
| 457 |
+
if isinstance(value, (int, float)):
|
| 458 |
+
return int(value)
|
| 459 |
+
text = str(value or "").strip()
|
| 460 |
+
if not text:
|
| 461 |
+
return default
|
| 462 |
+
try:
|
| 463 |
+
return int(float(text))
|
| 464 |
+
except Exception:
|
| 465 |
+
pass
|
| 466 |
+
try:
|
| 467 |
+
if text.endswith("Z"):
|
| 468 |
+
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp())
|
| 469 |
+
return int(datetime.fromisoformat(text).timestamp())
|
| 470 |
+
except Exception:
|
| 471 |
+
return default
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _to_local_iso(ts: int | None) -> str:
|
| 475 |
+
if not ts:
|
| 476 |
+
return ""
|
| 477 |
+
return datetime.fromtimestamp(int(ts), timezone.utc).astimezone().isoformat(timespec="seconds")
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _quota_extra_fields(quota_info: dict | None, *, now_ts: int | None = None) -> dict:
|
| 481 |
+
if not isinstance(quota_info, dict):
|
| 482 |
+
return {}
|
| 483 |
+
|
| 484 |
+
now_ts = int(now_ts or time.time())
|
| 485 |
+
primary_pct = int(quota_info.get("primary_pct", 0) or 0)
|
| 486 |
+
weekly_pct = int(quota_info.get("weekly_pct", 0) or 0)
|
| 487 |
+
primary_resets_at = _parse_timestamp(quota_info.get("primary_resets_at"))
|
| 488 |
+
weekly_resets_at = _parse_timestamp(quota_info.get("weekly_resets_at"))
|
| 489 |
+
|
| 490 |
+
extra = {
|
| 491 |
+
"codex_5h_used_percent": primary_pct,
|
| 492 |
+
"codex_5h_window_minutes": 300,
|
| 493 |
+
"codex_7d_used_percent": weekly_pct,
|
| 494 |
+
"codex_7d_window_minutes": 10080,
|
| 495 |
+
"codex_primary_used_percent": primary_pct,
|
| 496 |
+
"codex_primary_window_minutes": 300,
|
| 497 |
+
"codex_secondary_used_percent": weekly_pct,
|
| 498 |
+
"codex_secondary_window_minutes": 10080,
|
| 499 |
+
"codex_primary_over_secondary_percent": 0,
|
| 500 |
+
"codex_usage_updated_at": _to_local_iso(now_ts),
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
if primary_resets_at:
|
| 504 |
+
primary_after = max(0, primary_resets_at - now_ts)
|
| 505 |
+
extra.update(
|
| 506 |
+
{
|
| 507 |
+
"codex_5h_reset_after_seconds": primary_after,
|
| 508 |
+
"codex_5h_reset_at": _to_local_iso(primary_resets_at),
|
| 509 |
+
"codex_primary_reset_after_seconds": primary_after,
|
| 510 |
+
}
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
if weekly_resets_at:
|
| 514 |
+
weekly_after = max(0, weekly_resets_at - now_ts)
|
| 515 |
+
extra.update(
|
| 516 |
+
{
|
| 517 |
+
"codex_7d_reset_after_seconds": weekly_after,
|
| 518 |
+
"codex_7d_reset_at": _to_local_iso(weekly_resets_at),
|
| 519 |
+
"codex_secondary_reset_after_seconds": weekly_after,
|
| 520 |
+
}
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
return extra
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _load_auth_data(path: Path) -> dict:
|
| 527 |
+
return json.loads(read_text(path))
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def _build_credentials(auth_data: dict) -> dict:
|
| 531 |
+
id_token = auth_data.get("id_token", "")
|
| 532 |
+
claims = _parse_jwt_payload(id_token) if id_token else {}
|
| 533 |
+
auth_claims = claims.get("https://api.openai.com/auth", {}) if isinstance(claims, dict) else {}
|
| 534 |
+
|
| 535 |
+
credentials = {"access_token": auth_data.get("access_token", "")}
|
| 536 |
+
|
| 537 |
+
expires_at = _parse_timestamp(auth_data.get("expired"), default=int(time.time()) + 3600)
|
| 538 |
+
if expires_at:
|
| 539 |
+
credentials["expires_at"] = expires_at
|
| 540 |
+
|
| 541 |
+
refresh_token = auth_data.get("refresh_token", "")
|
| 542 |
+
if refresh_token:
|
| 543 |
+
credentials["refresh_token"] = refresh_token
|
| 544 |
+
if id_token:
|
| 545 |
+
credentials["id_token"] = id_token
|
| 546 |
+
|
| 547 |
+
client_id = auth_data.get("client_id") or claims.get("aud", [""])[0] if isinstance(claims.get("aud"), list) else ""
|
| 548 |
+
client_id = client_id or CODEX_CLIENT_ID
|
| 549 |
+
if client_id:
|
| 550 |
+
credentials["client_id"] = client_id
|
| 551 |
+
|
| 552 |
+
email = auth_data.get("email") or claims.get("email") or ""
|
| 553 |
+
if email:
|
| 554 |
+
credentials["email"] = email
|
| 555 |
+
|
| 556 |
+
account_id = auth_data.get("account_id") or auth_claims.get("chatgpt_account_id") or ""
|
| 557 |
+
if account_id:
|
| 558 |
+
credentials["chatgpt_account_id"] = account_id
|
| 559 |
+
|
| 560 |
+
user_id = auth_claims.get("chatgpt_user_id") or ""
|
| 561 |
+
if user_id:
|
| 562 |
+
credentials["chatgpt_user_id"] = user_id
|
| 563 |
+
|
| 564 |
+
organization_id = auth_claims.get("poid") or _extract_organization_id(auth_claims)
|
| 565 |
+
if organization_id:
|
| 566 |
+
credentials["organization_id"] = organization_id
|
| 567 |
+
|
| 568 |
+
plan_type = auth_claims.get("chatgpt_plan_type") or ""
|
| 569 |
+
if plan_type:
|
| 570 |
+
credentials["plan_type"] = plan_type
|
| 571 |
+
|
| 572 |
+
subscription_expires_at = auth_claims.get("chatgpt_subscription_active_until") or ""
|
| 573 |
+
if subscription_expires_at:
|
| 574 |
+
credentials["subscription_expires_at"] = subscription_expires_at
|
| 575 |
+
|
| 576 |
+
return credentials
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _build_extra(email: str, auth_file_name: str, *, kind: str, quota_info: dict | None = None) -> dict:
|
| 580 |
+
extra = {
|
| 581 |
+
_EXTRA_MANAGED: True,
|
| 582 |
+
_EXTRA_KIND: kind,
|
| 583 |
+
_EXTRA_EMAIL: email.lower(),
|
| 584 |
+
_EXTRA_AUTH_FILE: _remote_auth_file_name(auth_file_name),
|
| 585 |
+
_EXTRA_SOURCE: "autoteam",
|
| 586 |
+
_EXTRA_LAST_SYNC_AT: int(time.time()),
|
| 587 |
+
"email": email.lower(),
|
| 588 |
+
}
|
| 589 |
+
extra.update(_quota_extra_fields(quota_info))
|
| 590 |
+
return extra
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _attach_group_metadata(extra: dict, group_ids: list[int] | None, group_names: list[str] | None) -> dict:
|
| 594 |
+
extra[_EXTRA_GROUP_IDS] = [int(value) for value in group_ids or []]
|
| 595 |
+
extra[_EXTRA_GROUP_NAMES] = [str(value) for value in group_names or [] if str(value).strip()]
|
| 596 |
+
return extra
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _account_group_ids(account: dict) -> list[int]:
|
| 600 |
+
result = []
|
| 601 |
+
seen = set()
|
| 602 |
+
|
| 603 |
+
for value in account.get("group_ids") or []:
|
| 604 |
+
try:
|
| 605 |
+
group_id = int(value)
|
| 606 |
+
except (TypeError, ValueError):
|
| 607 |
+
continue
|
| 608 |
+
if group_id <= 0 or group_id in seen:
|
| 609 |
+
continue
|
| 610 |
+
seen.add(group_id)
|
| 611 |
+
result.append(group_id)
|
| 612 |
+
|
| 613 |
+
for item in account.get("groups") or []:
|
| 614 |
+
if not isinstance(item, dict):
|
| 615 |
+
continue
|
| 616 |
+
try:
|
| 617 |
+
group_id = int(item.get("id") or 0)
|
| 618 |
+
except (TypeError, ValueError):
|
| 619 |
+
continue
|
| 620 |
+
if group_id <= 0 or group_id in seen:
|
| 621 |
+
continue
|
| 622 |
+
seen.add(group_id)
|
| 623 |
+
result.append(group_id)
|
| 624 |
+
|
| 625 |
+
return result
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _merge_group_ids(account: dict, desired_group_ids: list[int] | None) -> list[int]:
|
| 629 |
+
desired = set()
|
| 630 |
+
for value in desired_group_ids or []:
|
| 631 |
+
try:
|
| 632 |
+
group_id = int(value)
|
| 633 |
+
except (TypeError, ValueError):
|
| 634 |
+
continue
|
| 635 |
+
if group_id > 0:
|
| 636 |
+
desired.add(group_id)
|
| 637 |
+
existing = set(_account_group_ids(account))
|
| 638 |
+
previous_managed = set(_managed_group_ids(account))
|
| 639 |
+
merged = (existing - previous_managed) | desired
|
| 640 |
+
return sorted(merged)
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def _create_account(
|
| 644 |
+
token: str,
|
| 645 |
+
*,
|
| 646 |
+
name: str,
|
| 647 |
+
credentials: dict,
|
| 648 |
+
extra: dict,
|
| 649 |
+
label: str,
|
| 650 |
+
group_ids: list[int] | None = None,
|
| 651 |
+
account_settings: dict | None = None,
|
| 652 |
+
proxy_id: int | None = None,
|
| 653 |
+
) -> dict:
|
| 654 |
+
payload = {
|
| 655 |
+
"name": name,
|
| 656 |
+
"platform": "openai",
|
| 657 |
+
"type": "oauth",
|
| 658 |
+
"credentials": credentials,
|
| 659 |
+
"extra": extra,
|
| 660 |
+
**_build_account_settings(),
|
| 661 |
+
"group_ids": list(group_ids or []),
|
| 662 |
+
}
|
| 663 |
+
if proxy_id is not None:
|
| 664 |
+
payload["proxy_id"] = int(proxy_id)
|
| 665 |
+
if account_settings:
|
| 666 |
+
payload.update(account_settings)
|
| 667 |
+
return _request(
|
| 668 |
+
"POST",
|
| 669 |
+
"/admin/accounts",
|
| 670 |
+
token=token,
|
| 671 |
+
label=label,
|
| 672 |
+
json=payload,
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _update_account(
|
| 677 |
+
token: str,
|
| 678 |
+
account: dict,
|
| 679 |
+
*,
|
| 680 |
+
credentials: dict,
|
| 681 |
+
extra: dict,
|
| 682 |
+
name: str | None = None,
|
| 683 |
+
status: str | None = None,
|
| 684 |
+
group_ids: list[int] | None = None,
|
| 685 |
+
account_settings: dict | None = None,
|
| 686 |
+
):
|
| 687 |
+
payload = {"credentials": credentials, "extra": extra}
|
| 688 |
+
if name:
|
| 689 |
+
payload["name"] = name
|
| 690 |
+
if status:
|
| 691 |
+
payload["status"] = status
|
| 692 |
+
if group_ids is not None:
|
| 693 |
+
payload["group_ids"] = list(group_ids)
|
| 694 |
+
if account_settings:
|
| 695 |
+
payload.update(account_settings)
|
| 696 |
+
return _request(
|
| 697 |
+
"PUT",
|
| 698 |
+
f"/admin/accounts/{account['id']}",
|
| 699 |
+
token=token,
|
| 700 |
+
label=f"更新账号 {name or _managed_email(account) or account.get('id')}",
|
| 701 |
+
json=payload,
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def _upsert_managed_pool_account(
|
| 706 |
+
token: str,
|
| 707 |
+
*,
|
| 708 |
+
target: dict,
|
| 709 |
+
existing: dict | None,
|
| 710 |
+
group_ids: list[int],
|
| 711 |
+
group_names: list[str],
|
| 712 |
+
overwrite_account_settings: bool,
|
| 713 |
+
proxy_id: int | None = None,
|
| 714 |
+
) -> dict:
|
| 715 |
+
email = target["email"]
|
| 716 |
+
desired_credentials = _build_credentials(target["auth_data"])
|
| 717 |
+
desired_extra = _build_extra(
|
| 718 |
+
email,
|
| 719 |
+
target["auth_path"].name,
|
| 720 |
+
kind=_KIND_POOL,
|
| 721 |
+
quota_info=target.get("quota_info"),
|
| 722 |
+
)
|
| 723 |
+
_attach_group_metadata(desired_extra, group_ids, group_names)
|
| 724 |
+
|
| 725 |
+
if existing:
|
| 726 |
+
merged_credentials = dict(existing.get("credentials") or {})
|
| 727 |
+
merged_credentials.update(desired_credentials)
|
| 728 |
+
merged_extra = dict(existing.get("extra") or {})
|
| 729 |
+
merged_extra.update(desired_extra)
|
| 730 |
+
account_settings = None
|
| 731 |
+
if overwrite_account_settings:
|
| 732 |
+
account_settings = _build_account_settings()
|
| 733 |
+
_apply_managed_credentials_settings(merged_credentials)
|
| 734 |
+
_apply_managed_extra_settings(merged_extra)
|
| 735 |
+
_update_account(
|
| 736 |
+
token,
|
| 737 |
+
existing,
|
| 738 |
+
credentials=merged_credentials,
|
| 739 |
+
extra=merged_extra,
|
| 740 |
+
status="active" if existing.get("status") != "active" else None,
|
| 741 |
+
group_ids=_merge_group_ids(existing, group_ids),
|
| 742 |
+
account_settings=account_settings,
|
| 743 |
+
)
|
| 744 |
+
logger.info("[Sub2API] 更新: %s", email)
|
| 745 |
+
return {"action": "updated", "account_id": existing.get("id")}
|
| 746 |
+
|
| 747 |
+
_apply_managed_credentials_settings(desired_credentials)
|
| 748 |
+
_apply_managed_extra_settings(desired_extra)
|
| 749 |
+
created = _create_account(
|
| 750 |
+
token,
|
| 751 |
+
name=target["name"],
|
| 752 |
+
credentials=desired_credentials,
|
| 753 |
+
extra=desired_extra,
|
| 754 |
+
label=f"创建账号 {email}",
|
| 755 |
+
group_ids=group_ids,
|
| 756 |
+
account_settings=_build_account_settings(),
|
| 757 |
+
proxy_id=proxy_id,
|
| 758 |
+
)
|
| 759 |
+
logger.info("[Sub2API] 创建: %s", email)
|
| 760 |
+
account_id = created.get("id") if isinstance(created, dict) else None
|
| 761 |
+
return {"action": "created", "account_id": account_id}
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def _delete_account(token: str, account: dict, *, label: str = "删除账号") -> bool:
|
| 765 |
+
_request(
|
| 766 |
+
"DELETE",
|
| 767 |
+
f"/admin/accounts/{account['id']}",
|
| 768 |
+
token=token,
|
| 769 |
+
label=f"{label} {account.get('name') or account.get('id')}",
|
| 770 |
+
)
|
| 771 |
+
return True
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
def verify_sub2api_connection() -> bool:
|
| 775 |
+
try:
|
| 776 |
+
token = _login()
|
| 777 |
+
accounts = _list_openai_oauth_accounts(token)
|
| 778 |
+
group_ids, group_names = _resolve_group_binding(token)
|
| 779 |
+
if group_ids:
|
| 780 |
+
logger.info(
|
| 781 |
+
"[验证] Sub2API 连接成功(当前 %d 个 OpenAI OAuth 账号,分组: %s)",
|
| 782 |
+
len(accounts),
|
| 783 |
+
", ".join(f"{name}#{group_id}" for group_id, name in zip(group_ids, group_names)),
|
| 784 |
+
)
|
| 785 |
+
else:
|
| 786 |
+
logger.info("[验证] Sub2API 连接成功(当前 %d 个 OpenAI OAuth 账号)", len(accounts))
|
| 787 |
+
return True
|
| 788 |
+
except Exception as exc:
|
| 789 |
+
logger.error("[验证] Sub2API 连接失败: %s", exc)
|
| 790 |
+
return False
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def sync_to_sub2api():
|
| 794 |
+
from autoteam.accounts import STATUS_ACTIVE, is_account_disabled, load_accounts
|
| 795 |
+
|
| 796 |
+
accounts = load_accounts()
|
| 797 |
+
local_emails = {str(acc.get("email") or "").lower() for acc in accounts if acc.get("email")}
|
| 798 |
+
active_targets = {}
|
| 799 |
+
|
| 800 |
+
for acc in accounts:
|
| 801 |
+
if is_account_disabled(acc):
|
| 802 |
+
continue
|
| 803 |
+
if acc.get("status") != STATUS_ACTIVE or not acc.get("auth_file"):
|
| 804 |
+
continue
|
| 805 |
+
auth_path = Path(acc["auth_file"])
|
| 806 |
+
if not auth_path.exists():
|
| 807 |
+
continue
|
| 808 |
+
try:
|
| 809 |
+
auth_data = _load_auth_data(auth_path)
|
| 810 |
+
except Exception as exc:
|
| 811 |
+
logger.warning("[Sub2API] 读取 auth 文件失败,跳过 %s: %s", auth_path, exc)
|
| 812 |
+
continue
|
| 813 |
+
|
| 814 |
+
email = (auth_data.get("email") or acc.get("email") or "").strip().lower()
|
| 815 |
+
if not email:
|
| 816 |
+
continue
|
| 817 |
+
|
| 818 |
+
active_targets[email] = {
|
| 819 |
+
"email": email,
|
| 820 |
+
"name": acc.get("email") or email,
|
| 821 |
+
"auth_path": auth_path,
|
| 822 |
+
"auth_data": auth_data,
|
| 823 |
+
"quota_info": acc.get("last_quota"),
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
token = _login()
|
| 827 |
+
group_ids, group_names = _resolve_group_binding(token)
|
| 828 |
+
remote_accounts = _list_openai_oauth_accounts(token)
|
| 829 |
+
existing_by_email, duplicates_deleted = _dedupe_managed_accounts(token, remote_accounts, kind=_KIND_POOL)
|
| 830 |
+
|
| 831 |
+
logger.info(
|
| 832 |
+
"[Sub2API] active 账号: %d, Sub2API 管理账号: %d",
|
| 833 |
+
len(active_targets),
|
| 834 |
+
len(existing_by_email),
|
| 835 |
+
)
|
| 836 |
+
if group_ids:
|
| 837 |
+
logger.info(
|
| 838 |
+
"[Sub2API] 目标分组: %s", ", ".join(f"{name}#{group_id}" for group_id, name in zip(group_ids, group_names))
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
created = 0
|
| 842 |
+
updated = 0
|
| 843 |
+
deleted = 0
|
| 844 |
+
overwrite_account_settings = SUB2API_OVERWRITE_ACCOUNT_SETTINGS
|
| 845 |
+
proxy_id = None
|
| 846 |
+
proxy_id_resolved = False
|
| 847 |
+
|
| 848 |
+
for email, target in active_targets.items():
|
| 849 |
+
existing = existing_by_email.get(email)
|
| 850 |
+
|
| 851 |
+
if not existing and not proxy_id_resolved:
|
| 852 |
+
proxy_id = _resolve_proxy_id(token)
|
| 853 |
+
proxy_id_resolved = True
|
| 854 |
+
result = _upsert_managed_pool_account(
|
| 855 |
+
token,
|
| 856 |
+
target=target,
|
| 857 |
+
existing=existing,
|
| 858 |
+
group_ids=group_ids,
|
| 859 |
+
group_names=group_names,
|
| 860 |
+
overwrite_account_settings=overwrite_account_settings,
|
| 861 |
+
proxy_id=proxy_id,
|
| 862 |
+
)
|
| 863 |
+
if result["action"] == "created":
|
| 864 |
+
created += 1
|
| 865 |
+
else:
|
| 866 |
+
updated += 1
|
| 867 |
+
|
| 868 |
+
for email, account in existing_by_email.items():
|
| 869 |
+
if email in local_emails and email not in active_targets:
|
| 870 |
+
_delete_account(token, account, label="删除非 active 账号")
|
| 871 |
+
logger.info("[Sub2API] 删除非 active 账号: %s", email)
|
| 872 |
+
deleted += 1
|
| 873 |
+
|
| 874 |
+
final_accounts = _list_openai_oauth_accounts(token)
|
| 875 |
+
final_managed = [item for item in final_accounts if _is_managed_account(item, kind=_KIND_POOL)]
|
| 876 |
+
logger.info(
|
| 877 |
+
"[Sub2API] 同步完成: 创建 %d, 更新 %d, 删除 %d, 远端去重 %d",
|
| 878 |
+
created,
|
| 879 |
+
updated,
|
| 880 |
+
deleted,
|
| 881 |
+
duplicates_deleted,
|
| 882 |
+
)
|
| 883 |
+
logger.info("[Sub2API] Sub2API 中本地管理: %d, 本地 active: %d", len(final_managed), len(active_targets))
|
| 884 |
+
return {
|
| 885 |
+
"created": created,
|
| 886 |
+
"updated": updated,
|
| 887 |
+
"deleted": deleted,
|
| 888 |
+
"remote_duplicates_deleted": duplicates_deleted,
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
def sync_account_to_sub2api(email: str, filepath: str, *, quota_info: dict | None = None):
|
| 893 |
+
auth_path = Path(filepath)
|
| 894 |
+
if not auth_path.exists():
|
| 895 |
+
raise FileNotFoundError(f"账号认证文件不存在: {auth_path}")
|
| 896 |
+
|
| 897 |
+
auth_data = _load_auth_data(auth_path)
|
| 898 |
+
email_hint = (email or "").strip().lower()
|
| 899 |
+
auth_email = (auth_data.get("email") or "").strip().lower()
|
| 900 |
+
target_email = auth_email or email_hint
|
| 901 |
+
if not target_email:
|
| 902 |
+
raise RuntimeError("[Sub2API] 账号认证文件中未找到邮箱")
|
| 903 |
+
if email_hint and auth_email and email_hint != auth_email:
|
| 904 |
+
logger.warning("[Sub2API] 认证文件邮箱与请求邮箱不一致,按认证文件邮箱同步: %s -> %s", email_hint, auth_email)
|
| 905 |
+
|
| 906 |
+
token = _login()
|
| 907 |
+
group_ids, group_names = _resolve_group_binding(token)
|
| 908 |
+
remote_accounts = _list_openai_oauth_accounts(token)
|
| 909 |
+
existing, duplicates_seen = _select_managed_account_by_email(remote_accounts, target_email, kind=_KIND_POOL)
|
| 910 |
+
proxy_id = None if existing else _resolve_proxy_id(token)
|
| 911 |
+
|
| 912 |
+
target = {
|
| 913 |
+
"email": target_email,
|
| 914 |
+
"name": target_email,
|
| 915 |
+
"auth_path": auth_path,
|
| 916 |
+
"auth_data": auth_data,
|
| 917 |
+
"quota_info": quota_info,
|
| 918 |
+
}
|
| 919 |
+
result = _upsert_managed_pool_account(
|
| 920 |
+
token,
|
| 921 |
+
target=target,
|
| 922 |
+
existing=existing,
|
| 923 |
+
group_ids=group_ids,
|
| 924 |
+
group_names=group_names,
|
| 925 |
+
overwrite_account_settings=SUB2API_OVERWRITE_ACCOUNT_SETTINGS,
|
| 926 |
+
proxy_id=proxy_id,
|
| 927 |
+
)
|
| 928 |
+
|
| 929 |
+
remote_auth_name = _remote_auth_file_name(auth_path.name)
|
| 930 |
+
logger.info(
|
| 931 |
+
"[Sub2API] 单凭证同步完成: %s (%s, account_id=%s, duplicates_seen=%d)",
|
| 932 |
+
remote_auth_name,
|
| 933 |
+
result["action"],
|
| 934 |
+
result.get("account_id"),
|
| 935 |
+
duplicates_seen,
|
| 936 |
+
)
|
| 937 |
+
return {
|
| 938 |
+
"uploaded": remote_auth_name,
|
| 939 |
+
"action": result["action"],
|
| 940 |
+
"account_id": result.get("account_id"),
|
| 941 |
+
"remote_duplicates_seen": duplicates_seen,
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
def sync_main_codex_to_sub2api(filepath):
|
| 946 |
+
auth_path = Path(filepath)
|
| 947 |
+
if not auth_path.exists():
|
| 948 |
+
raise FileNotFoundError(f"主号认证文件不存在: {auth_path}")
|
| 949 |
+
|
| 950 |
+
auth_data = _load_auth_data(auth_path)
|
| 951 |
+
email = (auth_data.get("email") or "").strip().lower()
|
| 952 |
+
token = _login()
|
| 953 |
+
group_ids, group_names = _resolve_group_binding(token)
|
| 954 |
+
remote_accounts = _list_openai_oauth_accounts(token)
|
| 955 |
+
existing_by_email, duplicates_deleted = _dedupe_managed_accounts(token, remote_accounts, kind=_KIND_MAIN)
|
| 956 |
+
|
| 957 |
+
desired_credentials = _build_credentials(auth_data)
|
| 958 |
+
desired_extra = _build_extra(email, auth_path.name, kind=_KIND_MAIN)
|
| 959 |
+
_attach_group_metadata(desired_extra, group_ids, group_names)
|
| 960 |
+
name = f"AutoTeam Main | {email}" if email else "AutoTeam Main"
|
| 961 |
+
overwrite_account_settings = SUB2API_OVERWRITE_ACCOUNT_SETTINGS
|
| 962 |
+
|
| 963 |
+
current = existing_by_email.get(email) if email else None
|
| 964 |
+
if current:
|
| 965 |
+
merged_credentials = dict(current.get("credentials") or {})
|
| 966 |
+
merged_credentials.update(desired_credentials)
|
| 967 |
+
merged_extra = dict(current.get("extra") or {})
|
| 968 |
+
merged_extra.update(desired_extra)
|
| 969 |
+
account_settings = None
|
| 970 |
+
if overwrite_account_settings:
|
| 971 |
+
account_settings = _build_account_settings()
|
| 972 |
+
_apply_managed_credentials_settings(merged_credentials)
|
| 973 |
+
_apply_managed_extra_settings(merged_extra)
|
| 974 |
+
_update_account(
|
| 975 |
+
token,
|
| 976 |
+
current,
|
| 977 |
+
credentials=merged_credentials,
|
| 978 |
+
extra=merged_extra,
|
| 979 |
+
name=name,
|
| 980 |
+
status="active",
|
| 981 |
+
group_ids=_merge_group_ids(current, group_ids),
|
| 982 |
+
account_settings=account_settings,
|
| 983 |
+
)
|
| 984 |
+
account_id = current.get("id")
|
| 985 |
+
else:
|
| 986 |
+
_apply_managed_credentials_settings(desired_credentials)
|
| 987 |
+
_apply_managed_extra_settings(desired_extra)
|
| 988 |
+
created = _create_account(
|
| 989 |
+
token,
|
| 990 |
+
name=name,
|
| 991 |
+
credentials=desired_credentials,
|
| 992 |
+
extra=desired_extra,
|
| 993 |
+
label="创建主号账号",
|
| 994 |
+
group_ids=group_ids,
|
| 995 |
+
account_settings=_build_account_settings(),
|
| 996 |
+
)
|
| 997 |
+
account_id = created.get("id") if isinstance(created, dict) else None
|
| 998 |
+
|
| 999 |
+
deleted = []
|
| 1000 |
+
for item in existing_by_email.values():
|
| 1001 |
+
if current and item.get("id") == current.get("id"):
|
| 1002 |
+
continue
|
| 1003 |
+
if email and _managed_email(item) == email:
|
| 1004 |
+
continue
|
| 1005 |
+
_delete_account(token, item, label="删除旧主号账号")
|
| 1006 |
+
deleted.append(item.get("id"))
|
| 1007 |
+
|
| 1008 |
+
remote_auth_name = _remote_auth_file_name(auth_path.name)
|
| 1009 |
+
logger.info(
|
| 1010 |
+
"[Sub2API] 主号 Codex 已同步: %s (account_id=%s, duplicates=%d, deleted_old=%d)",
|
| 1011 |
+
remote_auth_name,
|
| 1012 |
+
account_id,
|
| 1013 |
+
duplicates_deleted,
|
| 1014 |
+
len(deleted),
|
| 1015 |
+
)
|
| 1016 |
+
return {"uploaded": remote_auth_name, "account_id": account_id, "deleted_old": deleted}
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
def delete_main_codex_from_sub2api():
|
| 1020 |
+
token = _login()
|
| 1021 |
+
remote_accounts = _list_openai_oauth_accounts(token)
|
| 1022 |
+
deleted = []
|
| 1023 |
+
|
| 1024 |
+
for item in remote_accounts:
|
| 1025 |
+
if not _is_managed_account(item, kind=_KIND_MAIN):
|
| 1026 |
+
continue
|
| 1027 |
+
_delete_account(token, item, label="删除主号账号")
|
| 1028 |
+
deleted.append(item.get("name") or str(item.get("id")))
|
| 1029 |
+
|
| 1030 |
+
return {"deleted": deleted, "count": len(deleted)}
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def delete_account_from_sub2api(email: str, *, auth_names: list[str] | None = None):
|
| 1034 |
+
token = _login()
|
| 1035 |
+
remote_accounts = _list_openai_oauth_accounts(token)
|
| 1036 |
+
auth_name_set = _remote_auth_file_candidates(auth_names)
|
| 1037 |
+
deleted = []
|
| 1038 |
+
|
| 1039 |
+
for item in remote_accounts:
|
| 1040 |
+
if not _is_managed_account(item, kind=_KIND_POOL):
|
| 1041 |
+
continue
|
| 1042 |
+
item_email = _managed_email(item)
|
| 1043 |
+
item_auth_name = _managed_auth_file(item)
|
| 1044 |
+
if item_email != email.lower() and item_auth_name not in auth_name_set:
|
| 1045 |
+
continue
|
| 1046 |
+
_delete_account(token, item, label="删除账号")
|
| 1047 |
+
deleted.append(item.get("name") or str(item.get("id")))
|
| 1048 |
+
|
| 1049 |
+
return {"deleted": deleted, "count": len(deleted)}
|
src/autoteam/sync_targets.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""统一远端同步目标分发:CPA / Sub2API。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from collections.abc import Mapping
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from autoteam.textio import parse_env_value
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
SYNC_TARGET_CPA = "cpa"
|
| 15 |
+
SYNC_TARGET_SUB2API = "sub2api"
|
| 16 |
+
|
| 17 |
+
_SYNC_TARGET_META = {
|
| 18 |
+
SYNC_TARGET_CPA: {
|
| 19 |
+
"label": "CPA",
|
| 20 |
+
"toggle_key": "SYNC_TARGET_CPA",
|
| 21 |
+
"config_keys": ("CPA_URL", "CPA_KEY"),
|
| 22 |
+
},
|
| 23 |
+
SYNC_TARGET_SUB2API: {
|
| 24 |
+
"label": "Sub2API",
|
| 25 |
+
"toggle_key": "SYNC_TARGET_SUB2API",
|
| 26 |
+
"config_keys": ("SUB2API_URL", "SUB2API_EMAIL", "SUB2API_PASSWORD"),
|
| 27 |
+
},
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _normalize_env(env: Mapping[str, object] | None = None) -> dict[str, str]:
|
| 34 |
+
source = env or os.environ
|
| 35 |
+
return {str(key): "" if value is None else str(value) for key, value in source.items()}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_bool_env(value: object, default: bool = False) -> bool:
|
| 39 |
+
if value is None:
|
| 40 |
+
return default
|
| 41 |
+
text = parse_env_value(str(value))
|
| 42 |
+
if not text:
|
| 43 |
+
return default
|
| 44 |
+
return text.strip().lower() in _TRUE_VALUES
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_sync_target_meta(target: str) -> dict[str, object]:
|
| 48 |
+
try:
|
| 49 |
+
return _SYNC_TARGET_META[target]
|
| 50 |
+
except KeyError as exc:
|
| 51 |
+
raise KeyError(f"未知同步目标: {target}") from exc
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_sync_target_states(env: Mapping[str, object] | None = None) -> dict[str, bool]:
|
| 55 |
+
values = _normalize_env(env)
|
| 56 |
+
states = {}
|
| 57 |
+
for target, meta in _SYNC_TARGET_META.items():
|
| 58 |
+
toggle_key = str(meta["toggle_key"])
|
| 59 |
+
config_keys = tuple(meta["config_keys"])
|
| 60 |
+
raw_toggle = (values.get(toggle_key) or "").strip()
|
| 61 |
+
if raw_toggle:
|
| 62 |
+
states[target] = parse_bool_env(raw_toggle)
|
| 63 |
+
else:
|
| 64 |
+
states[target] = all((values.get(key) or "").strip() for key in config_keys)
|
| 65 |
+
return states
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def is_sync_target_enabled(target: str, env: Mapping[str, object] | None = None) -> bool:
|
| 69 |
+
return get_sync_target_states(env).get(target, False)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_enabled_sync_targets(env: Mapping[str, object] | None = None) -> list[str]:
|
| 73 |
+
states = get_sync_target_states(env)
|
| 74 |
+
return [target for target in _SYNC_TARGET_META if states.get(target)]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_available_sync_targets(env: Mapping[str, object] | None = None) -> list[str]:
|
| 78 |
+
values = _normalize_env(env)
|
| 79 |
+
available = []
|
| 80 |
+
for target, meta in _SYNC_TARGET_META.items():
|
| 81 |
+
if all((values.get(key) or "").strip() for key in tuple(meta["config_keys"])):
|
| 82 |
+
available.append(target)
|
| 83 |
+
return available
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_sync_target_labels(targets: list[str] | None = None) -> list[str]:
|
| 87 |
+
if targets is None:
|
| 88 |
+
targets = list(_SYNC_TARGET_META)
|
| 89 |
+
labels = []
|
| 90 |
+
for target in targets:
|
| 91 |
+
meta = _SYNC_TARGET_META.get(target)
|
| 92 |
+
if meta:
|
| 93 |
+
labels.append(str(meta["label"]))
|
| 94 |
+
return labels
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def describe_sync_targets(targets: list[str] | None = None) -> str:
|
| 98 |
+
labels = get_sync_target_labels(targets)
|
| 99 |
+
if not labels:
|
| 100 |
+
return "未启用远端同步目标"
|
| 101 |
+
return " + ".join(labels)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_missing_target_configs(
|
| 105 |
+
targets: list[str] | None = None, env: Mapping[str, object] | None = None
|
| 106 |
+
) -> list[tuple[str, str]]:
|
| 107 |
+
values = _normalize_env(env)
|
| 108 |
+
missing: list[tuple[str, str]] = []
|
| 109 |
+
for target in targets or []:
|
| 110 |
+
meta = get_sync_target_meta(target)
|
| 111 |
+
for key in tuple(meta["config_keys"]):
|
| 112 |
+
value = (values.get(key) or "").strip()
|
| 113 |
+
if not value:
|
| 114 |
+
missing.append((key, str(meta["label"])))
|
| 115 |
+
return missing
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def sync_to_configured_targets():
|
| 119 |
+
results = {}
|
| 120 |
+
enabled_targets = get_enabled_sync_targets()
|
| 121 |
+
|
| 122 |
+
if SYNC_TARGET_CPA in enabled_targets:
|
| 123 |
+
from autoteam.cpa_sync import sync_to_cpa
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
results[SYNC_TARGET_CPA] = sync_to_cpa()
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
logger.warning("[Sync] CPA 同步失败,保留本地轮换结果: %s", exc)
|
| 129 |
+
results[SYNC_TARGET_CPA] = {"ok": False, "error": str(exc)}
|
| 130 |
+
|
| 131 |
+
if SYNC_TARGET_SUB2API in enabled_targets:
|
| 132 |
+
from autoteam.sub2api_sync import sync_to_sub2api
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
results[SYNC_TARGET_SUB2API] = sync_to_sub2api()
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
logger.warning("[Sync] Sub2API 同步失败,保留本地轮换结果: %s", exc)
|
| 138 |
+
results[SYNC_TARGET_SUB2API] = {"ok": False, "error": str(exc)}
|
| 139 |
+
|
| 140 |
+
return results
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def sync_account_to_configured_targets(email: str, filepath: str):
|
| 144 |
+
"""只把一个已就绪的账号凭证同步到已启用目标,不触发远端清理。"""
|
| 145 |
+
from autoteam.accounts import STATUS_ACTIVE, find_account, is_account_disabled, load_accounts
|
| 146 |
+
|
| 147 |
+
normalized_email = (email or "").strip().lower()
|
| 148 |
+
auth_path = Path(filepath)
|
| 149 |
+
if not normalized_email:
|
| 150 |
+
return {"ok": False, "skipped": True, "reason": "missing_email"}
|
| 151 |
+
if not auth_path.exists():
|
| 152 |
+
logger.warning("[Sync] 新凭证文件不存在,跳过即时同步: %s (%s)", normalized_email, auth_path)
|
| 153 |
+
return {"ok": False, "skipped": True, "reason": "missing_auth_file", "auth_file": str(auth_path)}
|
| 154 |
+
|
| 155 |
+
account = find_account(load_accounts(), normalized_email)
|
| 156 |
+
if not account:
|
| 157 |
+
logger.warning("[Sync] 未找到账号记录,跳过新凭证即时同步: %s", normalized_email)
|
| 158 |
+
return {"ok": False, "skipped": True, "reason": "account_missing", "auth_file": auth_path.name}
|
| 159 |
+
if is_account_disabled(account):
|
| 160 |
+
logger.info("[Sync] 账号已禁用,跳过新凭证即时同步: %s", normalized_email)
|
| 161 |
+
return {"ok": False, "skipped": True, "reason": "account_disabled", "auth_file": auth_path.name}
|
| 162 |
+
if account.get("status") != STATUS_ACTIVE:
|
| 163 |
+
logger.info(
|
| 164 |
+
"[Sync] 账号尚未 active,跳过新凭证即时同步: %s (status=%s)",
|
| 165 |
+
normalized_email,
|
| 166 |
+
account.get("status"),
|
| 167 |
+
)
|
| 168 |
+
return {
|
| 169 |
+
"ok": False,
|
| 170 |
+
"skipped": True,
|
| 171 |
+
"reason": "account_not_active",
|
| 172 |
+
"status": account.get("status"),
|
| 173 |
+
"auth_file": auth_path.name,
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
account_auth = account.get("auth_file")
|
| 177 |
+
if account_auth and Path(account_auth).name != auth_path.name:
|
| 178 |
+
logger.info(
|
| 179 |
+
"[Sync] 请求同步的凭证与账号当前 auth_file 不同,仍按请求文件上传: %s (%s -> %s)",
|
| 180 |
+
normalized_email,
|
| 181 |
+
Path(account_auth).name,
|
| 182 |
+
auth_path.name,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
results = {}
|
| 186 |
+
enabled_targets = get_enabled_sync_targets()
|
| 187 |
+
|
| 188 |
+
if SYNC_TARGET_CPA in enabled_targets:
|
| 189 |
+
from autoteam.cpa_sync import upload_to_cpa
|
| 190 |
+
|
| 191 |
+
try:
|
| 192 |
+
uploaded = upload_to_cpa(auth_path)
|
| 193 |
+
results[SYNC_TARGET_CPA] = {"ok": bool(uploaded), "uploaded": auth_path.name}
|
| 194 |
+
except Exception as exc:
|
| 195 |
+
logger.warning("[Sync] CPA 新凭证即时同步失败,保留本地结果: %s", exc)
|
| 196 |
+
results[SYNC_TARGET_CPA] = {"ok": False, "error": str(exc), "uploaded": auth_path.name}
|
| 197 |
+
|
| 198 |
+
if SYNC_TARGET_SUB2API in enabled_targets:
|
| 199 |
+
from autoteam.sub2api_sync import sync_account_to_sub2api
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
quota_info = account.get("last_quota") if isinstance(account.get("last_quota"), dict) else None
|
| 203 |
+
target_result = sync_account_to_sub2api(normalized_email, str(auth_path), quota_info=quota_info)
|
| 204 |
+
results[SYNC_TARGET_SUB2API] = {"ok": True, **target_result}
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
logger.warning("[Sync] Sub2API 新凭证即时同步失败,保留本地结果: %s", exc)
|
| 207 |
+
results[SYNC_TARGET_SUB2API] = {"ok": False, "error": str(exc), "uploaded": auth_path.name}
|
| 208 |
+
|
| 209 |
+
if not results:
|
| 210 |
+
logger.info("[Sync] 未启用远端同步目标,跳过新凭证即时同步: %s", normalized_email)
|
| 211 |
+
return {"ok": True, "skipped": True, "reason": "no_enabled_targets", "auth_file": auth_path.name}
|
| 212 |
+
|
| 213 |
+
return {
|
| 214 |
+
"ok": all(item.get("ok") for item in results.values()),
|
| 215 |
+
"auth_file": auth_path.name,
|
| 216 |
+
"targets": results,
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def sync_main_codex_to_configured_targets(filepath: str):
|
| 221 |
+
results = {}
|
| 222 |
+
enabled_targets = get_enabled_sync_targets()
|
| 223 |
+
|
| 224 |
+
if SYNC_TARGET_CPA in enabled_targets:
|
| 225 |
+
from autoteam.cpa_sync import sync_main_codex_to_cpa
|
| 226 |
+
|
| 227 |
+
results[SYNC_TARGET_CPA] = sync_main_codex_to_cpa(filepath)
|
| 228 |
+
|
| 229 |
+
if SYNC_TARGET_SUB2API in enabled_targets:
|
| 230 |
+
from autoteam.sub2api_sync import sync_main_codex_to_sub2api
|
| 231 |
+
|
| 232 |
+
results[SYNC_TARGET_SUB2API] = sync_main_codex_to_sub2api(filepath)
|
| 233 |
+
|
| 234 |
+
return results
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def delete_main_codex_from_configured_targets(*, include_disabled: bool = False):
|
| 238 |
+
results = {}
|
| 239 |
+
targets = get_available_sync_targets() if include_disabled else get_enabled_sync_targets()
|
| 240 |
+
|
| 241 |
+
if SYNC_TARGET_CPA in targets:
|
| 242 |
+
from autoteam.cpa_sync import delete_main_codex_from_cpa
|
| 243 |
+
|
| 244 |
+
results[SYNC_TARGET_CPA] = delete_main_codex_from_cpa()
|
| 245 |
+
|
| 246 |
+
if SYNC_TARGET_SUB2API in targets:
|
| 247 |
+
from autoteam.sub2api_sync import delete_main_codex_from_sub2api
|
| 248 |
+
|
| 249 |
+
results[SYNC_TARGET_SUB2API] = delete_main_codex_from_sub2api()
|
| 250 |
+
|
| 251 |
+
return results
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def delete_account_from_configured_targets(
|
| 255 |
+
email: str, *, auth_names: list[str] | None = None, include_disabled: bool = False
|
| 256 |
+
):
|
| 257 |
+
results = {}
|
| 258 |
+
targets = get_available_sync_targets() if include_disabled else get_enabled_sync_targets()
|
| 259 |
+
|
| 260 |
+
if SYNC_TARGET_CPA in targets:
|
| 261 |
+
from autoteam.cpa_sync import delete_from_cpa, list_cpa_files
|
| 262 |
+
|
| 263 |
+
deleted = []
|
| 264 |
+
auth_name_set = set(auth_names or [])
|
| 265 |
+
for item in list_cpa_files():
|
| 266 |
+
item_email = (item.get("email") or "").lower()
|
| 267 |
+
item_name = item.get("name") or ""
|
| 268 |
+
if item_email == email.lower() or item_name in auth_name_set:
|
| 269 |
+
if delete_from_cpa(item_name):
|
| 270 |
+
deleted.append(item_name)
|
| 271 |
+
results[SYNC_TARGET_CPA] = {"deleted": deleted, "count": len(deleted)}
|
| 272 |
+
|
| 273 |
+
if SYNC_TARGET_SUB2API in targets:
|
| 274 |
+
from autoteam.sub2api_sync import delete_account_from_sub2api
|
| 275 |
+
|
| 276 |
+
results[SYNC_TARGET_SUB2API] = delete_account_from_sub2api(email, auth_names=auth_names or [])
|
| 277 |
+
|
| 278 |
+
return results
|
src/autoteam/web/dist/assets/index-BSht6bmF.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/autoteam/web/dist/assets/index-BZ-R3x09.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/autoteam/web/dist/assets/{index-DHotZNsL.css → index-CbNAiyvN.css}
RENAMED
|
@@ -1 +1 @@
|
|
| 1 |
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(79 70 229 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(79 70 229 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-right-0\.5{right:-.125rem}.-top-0\.5{top:-.125rem}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.left-0{left:0}.left-1\/4{left:25%}.right-0{right:0}.right-1\/4{right:25%}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-2{top:.5rem}.top-3{top:.75rem}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-4{margin-left:1rem}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[calc\(100vh-200px\)\]{height:calc(100vh - 200px)}.h-px{height:1px}.max-h-64{max-height:16rem}.max-h-\[80vh\]{max-height:80vh}.min-h-screen{min-height:100vh}.w-0\.5{width:.125rem}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[180px\]{min-width:180px}.min-w-\[72px\]{min-width:72px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-screen-2xl{max-width:1536px}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes pulseDot{0%,to{transform:scale(1);opacity:1}50%{transform:scale(1.6);opacity:.4}}.animate-pulse-dot{animation:pulseDot 1.8s ease-in-out infinite}@keyframes rise{0%{transform:translateY(6px);opacity:0}to{transform:translateY(0);opacity:1}}.animate-rise{animation:rise .36s cubic-bezier(.22,1,.36,1)}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}@keyframes toastIn{0%{transform:translateY(8px) scale(.96);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}.animate-toast-in{animation:toastIn .22s cubic-bezier(.22,1,.36,1)}@keyframes toastOut{0%{transform:translateY(0);opacity:1}to{transform:translateY(-6px);opacity:0}}.animate-toast-out{animation:toastOut .18s ease-in forwards}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-hairline>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 229 229 / var(--tw-divide-opacity, 1))}.justify-self-start{justify-self:start}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-b-full{border-bottom-right-radius:9999px;border-bottom-left-radius:9999px}.rounded-r-full{border-top-right-radius:9999px;border-bottom-right-radius:9999px}.rounded-bl-lg{border-bottom-left-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-amber-400\/30{border-color:#fbbf244d}.border-amber-500\/20{border-color:#f59e0b33}.border-amber-700{--tw-border-opacity: 1;border-color:rgb(180 83 9 / var(--tw-border-opacity, 1))}.border-blue-200{--tw-border-opacity: 1;border-color:rgb(191 219 254 / var(--tw-border-opacity, 1))}.border-current{border-color:currentColor}.border-cyan-200{--tw-border-opacity: 1;border-color:rgb(165 243 252 / var(--tw-border-opacity, 1))}.border-emerald-200{--tw-border-opacity: 1;border-color:rgb(167 243 208 / var(--tw-border-opacity, 1))}.border-emerald-400\/30{border-color:#34d3994d}.border-emerald-500\/20{border-color:#10b98133}.border-hairline{--tw-border-opacity: 1;border-color:rgb(229 229 229 / var(--tw-border-opacity, 1))}.border-indigo-200{--tw-border-opacity: 1;border-color:rgb(199 210 254 / var(--tw-border-opacity, 1))}.border-indigo-500{--tw-border-opacity: 1;border-color:rgb(99 102 241 / var(--tw-border-opacity, 1))}.border-indigo-500\/30{border-color:#6366f14d}.border-lime-200{--tw-border-opacity: 1;border-color:rgb(217 249 157 / var(--tw-border-opacity, 1))}.border-orange-200{--tw-border-opacity: 1;border-color:rgb(254 215 170 / var(--tw-border-opacity, 1))}.border-orange-400\/40{border-color:#fb923c66}.border-orange-500\/30{border-color:#f973164d}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-red-500\/30{border-color:#ef44444d}.border-rose-200{--tw-border-opacity: 1;border-color:rgb(254 205 211 / var(--tw-border-opacity, 1))}.border-rose-400\/30{border-color:#fb71854d}.border-rose-500\/20{border-color:#f43f5e33}.border-rose-500\/30{border-color:#f43f5e4d}.border-sky-200{--tw-border-opacity: 1;border-color:rgb(186 230 253 / var(--tw-border-opacity, 1))}.border-sky-400\/30{border-color:#38bdf84d}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-400\/25{border-color:#94a3b840}.border-stone-300{--tw-border-opacity: 1;border-color:rgb(214 211 209 / var(--tw-border-opacity, 1))}.border-teal-200{--tw-border-opacity: 1;border-color:rgb(153 246 228 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity: 1;border-color:rgb(254 240 138 / var(--tw-border-opacity, 1))}.border-yellow-500\/30{border-color:#eab3084d}.border-t-transparent{border-top-color:transparent}.bg-amber-100{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/\[0\.08\]{background-color:#f59e0b14}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-canvas{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.bg-cyan-50{--tw-bg-opacity: 1;background-color:rgb(236 254 255 / var(--tw-bg-opacity, 1))}.bg-cyan-700{--tw-bg-opacity: 1;background-color:rgb(14 116 144 / var(--tw-bg-opacity, 1))}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-50{--tw-bg-opacity: 1;background-color:rgb(236 253 245 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/\[0\.08\]{background-color:#10b98114}.bg-indigo-50{--tw-bg-opacity: 1;background-color:rgb(238 242 255 / var(--tw-bg-opacity, 1))}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-ink-100{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.bg-ink-400{--tw-bg-opacity: 1;background-color:rgb(163 163 163 / var(--tw-bg-opacity, 1))}.bg-ink-50{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.bg-ink-600\/20{background-color:#52525233}.bg-lime-50{--tw-bg-opacity: 1;background-color:rgb(247 254 231 / var(--tw-bg-opacity, 1))}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-orange-400{--tw-bg-opacity: 1;background-color:rgb(251 146 60 / var(--tw-bg-opacity, 1))}.bg-orange-50{--tw-bg-opacity: 1;background-color:rgb(255 247 237 / var(--tw-bg-opacity, 1))}.bg-orange-500\/\[0\.10\]{background-color:#f973161a}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-700\/\[0\.12\]{background-color:#b91c1c1f}.bg-rose-100{--tw-bg-opacity: 1;background-color:rgb(255 228 230 / var(--tw-bg-opacity, 1))}.bg-rose-400{--tw-bg-opacity: 1;background-color:rgb(251 113 133 / var(--tw-bg-opacity, 1))}.bg-rose-50{--tw-bg-opacity: 1;background-color:rgb(255 241 242 / var(--tw-bg-opacity, 1))}.bg-rose-500{--tw-bg-opacity: 1;background-color:rgb(244 63 94 / var(--tw-bg-opacity, 1))}.bg-rose-500\/\[0\.08\]{background-color:#f43f5e14}.bg-rose-600{--tw-bg-opacity: 1;background-color:rgb(225 29 72 / var(--tw-bg-opacity, 1))}.bg-rose-700\/80{background-color:#be123ccc}.bg-sky-400{--tw-bg-opacity: 1;background-color:rgb(56 189 248 / var(--tw-bg-opacity, 1))}.bg-sky-50{--tw-bg-opacity: 1;background-color:rgb(240 249 255 / var(--tw-bg-opacity, 1))}.bg-sky-500\/\[0\.08\]{background-color:#0ea5e914}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-400{--tw-bg-opacity: 1;background-color:rgb(148 163 184 / var(--tw-bg-opacity, 1))}.bg-slate-50{--tw-bg-opacity: 1;background-color:rgb(248 250 252 / var(--tw-bg-opacity, 1))}.bg-slate-500{--tw-bg-opacity: 1;background-color:rgb(100 116 139 / var(--tw-bg-opacity, 1))}.bg-slate-500\/\[0\.10\]{background-color:#64748b1a}.bg-stone-500{--tw-bg-opacity: 1;background-color:rgb(120 113 108 / var(--tw-bg-opacity, 1))}.bg-stone-500\/\[0\.08\]{background-color:#78716c14}.bg-surface{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-surface-hover{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.bg-surface-hover\/60{background-color:#f5f5f599}.bg-surface\/95{background-color:#fffffff2}.bg-teal-50{--tw-bg-opacity: 1;background-color:rgb(240 253 250 / var(--tw-bg-opacity, 1))}.bg-yellow-400{--tw-bg-opacity: 1;background-color:rgb(250 204 21 / var(--tw-bg-opacity, 1))}.bg-yellow-50{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.bg-yellow-600\/\[0\.10\]{background-color:#ca8a041a}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-amber-400\/15{--tw-gradient-from: rgb(251 191 36 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(251 191 36 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-amber-500\/25{--tw-gradient-from: rgb(245 158 11 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(245 158 11 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-emerald-400\/20{--tw-gradient-from: rgb(52 211 153 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(52 211 153 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-700\/20{--tw-gradient-from: rgb(185 28 28 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(185 28 28 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-rose-500\/15{--tw-gradient-from: rgb(244 63 94 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(244 63 94 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-sky-400\/20{--tw-gradient-from: rgb(56 189 248 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(56 189 248 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-slate-500\/15{--tw-gradient-from: rgb(100 116 139 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(100 116 139 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-stone-200\/70{--tw-gradient-from: rgb(231 229 228 / .7) var(--tw-gradient-from-position);--tw-gradient-to: rgb(231 229 228 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-500\/15{--tw-gradient-from: rgb(234 179 8 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-amber-500\/10{--tw-gradient-to: rgb(245 158 11 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(245 158 11 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-blue-500\/10{--tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(59 130 246 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-emerald-500\/10{--tw-gradient-to: rgb(16 185 129 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(16 185 129 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-indigo-400\/40{--tw-gradient-to: rgb(129 140 248 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(129 140 248 / .4) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-orange-500\/20{--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(249 115 22 / .2) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-red-500\/10{--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(239 68 68 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-rose-700\/15{--tw-gradient-to: rgb(190 18 60 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(190 18 60 / .15) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-stone-100\/50{--tw-gradient-to: rgb(245 245 244 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(245 245 244 / .5) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-yellow-500\/10{--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(234 179 8 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-zinc-500\/10{--tw-gradient-to: rgb(113 113 122 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(113 113 122 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-amber-600\/15{--tw-gradient-to: rgb(217 119 6 / .15) var(--tw-gradient-to-position)}.to-indigo-500\/15{--tw-gradient-to: rgb(99 102 241 / .15) var(--tw-gradient-to-position)}.to-red-800\/20{--tw-gradient-to: rgb(153 27 27 / .2) var(--tw-gradient-to-position)}.to-rose-500\/25{--tw-gradient-to: rgb(244 63 94 / .25) var(--tw-gradient-to-position)}.to-rose-600\/15{--tw-gradient-to: rgb(225 29 72 / .15) var(--tw-gradient-to-position)}.to-slate-100\/60{--tw-gradient-to: rgb(241 245 249 / .6) var(--tw-gradient-to-position)}.to-slate-600\/15{--tw-gradient-to: rgb(71 85 105 / .15) var(--tw-gradient-to-position)}.to-teal-500\/15{--tw-gradient-to: rgb(20 184 166 / .15) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.to-yellow-600\/15{--tw-gradient-to: rgb(202 138 4 / .15) var(--tw-gradient-to-position)}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pb-3{padding-bottom:.75rem}.pt-1{padding-top:.25rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-\[-2px\]{vertical-align:-2px}.font-mono{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-normal{letter-spacing:0em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-widest{letter-spacing:.1em}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-cyan-700{--tw-text-opacity: 1;color:rgb(14 116 144 / var(--tw-text-opacity, 1))}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-emerald-800{--tw-text-opacity: 1;color:rgb(6 95 70 / var(--tw-text-opacity, 1))}.text-emerald-900{--tw-text-opacity: 1;color:rgb(6 78 59 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-indigo-700{--tw-text-opacity: 1;color:rgb(67 56 202 / var(--tw-text-opacity, 1))}.text-ink-400{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.text-ink-500{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity, 1))}.text-ink-600{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.text-ink-700{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.text-ink-800{--tw-text-opacity: 1;color:rgb(38 38 38 / var(--tw-text-opacity, 1))}.text-ink-900{--tw-text-opacity: 1;color:rgb(23 23 23 / var(--tw-text-opacity, 1))}.text-ink-950{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.text-lime-700{--tw-text-opacity: 1;color:rgb(77 124 15 / var(--tw-text-opacity, 1))}.text-orange-200{--tw-text-opacity: 1;color:rgb(254 215 170 / var(--tw-text-opacity, 1))}.text-orange-300{--tw-text-opacity: 1;color:rgb(253 186 116 / var(--tw-text-opacity, 1))}.text-orange-700{--tw-text-opacity: 1;color:rgb(194 65 12 / var(--tw-text-opacity, 1))}.text-orange-800{--tw-text-opacity: 1;color:rgb(154 52 18 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-rose-300{--tw-text-opacity: 1;color:rgb(253 164 175 / var(--tw-text-opacity, 1))}.text-rose-700{--tw-text-opacity: 1;color:rgb(190 18 60 / var(--tw-text-opacity, 1))}.text-rose-800{--tw-text-opacity: 1;color:rgb(159 18 57 / var(--tw-text-opacity, 1))}.text-rose-900{--tw-text-opacity: 1;color:rgb(136 19 55 / var(--tw-text-opacity, 1))}.text-rose-950{--tw-text-opacity: 1;color:rgb(76 5 25 / var(--tw-text-opacity, 1))}.text-sky-300{--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-sky-800{--tw-text-opacity: 1;color:rgb(7 89 133 / var(--tw-text-opacity, 1))}.text-sky-900{--tw-text-opacity: 1;color:rgb(12 74 110 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-slate-950{--tw-text-opacity: 1;color:rgb(2 6 23 / var(--tw-text-opacity, 1))}.text-stone-700{--tw-text-opacity: 1;color:rgb(68 64 60 / var(--tw-text-opacity, 1))}.text-teal-700{--tw-text-opacity: 1;color:rgb(15 118 110 / var(--tw-text-opacity, 1))}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.accent-indigo-500{accent-color:#6366f1}.accent-indigo-600{accent-color:#4f46e5}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow-card{--tw-shadow: 0 1px 2px 0 rgba(0,0,0,.04), 0 1px 3px 0 rgba(0,0,0,.06);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color), 0 1px 3px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-inset{--tw-ring-inset: inset}.ring-amber-600\/25{--tw-ring-color: rgb(217 119 6 / .25)}.ring-emerald-600\/20{--tw-ring-color: rgb(5 150 105 / .2)}.ring-orange-600\/25{--tw-ring-color: rgb(234 88 12 / .25)}.ring-rose-400\/40{--tw-ring-color: rgb(251 113 133 / .4)}.ring-rose-600\/25{--tw-ring-color: rgb(225 29 72 / .25)}.ring-slate-400\/30{--tw-ring-color: rgb(148 163 184 / .3)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-500{transition-duration:.5s}.text-gray-500{color:#737373}.text-emerald-300{color:#047857}.text-amber-300{color:#b45309}.text-rose-300{color:#be123c}.text-orange-200{color:#9a3412}.text-orange-300{color:#c2410c}.text-yellow-200{color:#854d0e}.text-sky-300{color:#0369a1}.text-slate-300{color:#475569}.text-red-300{color:#b91c1c}:root{color-scheme:light;--bg-canvas: #fafafa;--bg-surface: #ffffff;--bg-surface-hover: #f5f5f5;--hairline: #e5e5e5;--hairline-strong: #d4d4d4;--ink-950: #0a0a0a;--ink-700: #404040;--ink-600: #525252;--ink-500: #737373;--ink-400: #a3a3a3;--accent: #4f46e5;--accent-soft: #eef2ff}html,body{font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:"cv02","cv03","cv04","cv11","ss01";-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}body{background-color:var(--bg-canvas);background-image:none;color:var(--ink-700)}.font-mono,code,pre,.tabular{font-variant-numeric:tabular-nums}.glass{background:var(--bg-surface);-webkit-backdrop-filter:none;backdrop-filter:none;border:1px solid var(--hairline);box-shadow:0 1px 2px #0000000a,0 1px 3px #0000000f}.glass-soft{background:var(--bg-surface);border:1px solid var(--hairline);box-shadow:0 1px 2px #00000008}.shimmer-bg{background:linear-gradient(90deg,#00000008,#00000014,#00000008);background-size:200% 100%;animation:shimmer 2.4s ease-in-out infinite}.row-hoverable{position:relative;transition:background-color .18s ease,transform .18s ease}.row-hoverable:before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:linear-gradient(180deg,transparent,rgba(79,70,229,.5),transparent);opacity:0;transition:opacity .22s ease}.row-hoverable:hover:before{opacity:1}.row-hoverable:hover{background-color:var(--bg-surface-hover)}@keyframes graceUrgent{0%,to{box-shadow:0 0 #e11d4800}50%{box-shadow:0 0 0 4px #e11d482e}}.grace-urgent{animation:graceUrgent 2.2s ease-in-out infinite}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#0000001f;border-radius:8px;border:2px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:hover{background:#0003;background-clip:padding-box;border:2px solid transparent}#app{position:relative;z-index:1}.lift-hover{transition:transform .18s cubic-bezier(.22,1,.36,1),box-shadow .18s ease}.lift-hover:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px -2px #00000014,0 2px 4px -2px #0000000a}.lift-hover:active:not(:disabled){transform:translateY(0)}.focus-ring:focus-visible{outline:none;box-shadow:0 0 0 2px #4f46e566,0 0 0 4px #4f46e526}.page-enter-from{opacity:0;transform:translateY(4px)}.page-enter-active{transition:opacity .18s cubic-bezier(.22,1,.36,1),transform .18s cubic-bezier(.22,1,.36,1)}.page-enter-to,.page-leave-from{opacity:1;transform:translateY(0)}.page-leave-active{transition:opacity .12s ease-in,transform .12s ease-in}.page-leave-to{opacity:0;transform:translateY(-2px)}.text-on-accent{color:#fff!important}.placeholder\:text-ink-400::-moz-placeholder{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.placeholder\:text-ink-400::placeholder{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.hover\:border-hairline-strong:hover{--tw-border-opacity: 1;border-color:rgb(212 212 212 / var(--tw-border-opacity, 1))}.hover\:border-indigo-300:hover{--tw-border-opacity: 1;border-color:rgb(165 180 252 / var(--tw-border-opacity, 1))}.hover\:border-rose-300:hover{--tw-border-opacity: 1;border-color:rgb(253 164 175 / var(--tw-border-opacity, 1))}.hover\:bg-amber-100:hover{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.hover\:bg-cyan-100:hover{--tw-bg-opacity: 1;background-color:rgb(207 250 254 / var(--tw-bg-opacity, 1))}.hover\:bg-cyan-600:hover{--tw-bg-opacity: 1;background-color:rgb(8 145 178 / var(--tw-bg-opacity, 1))}.hover\:bg-emerald-100:hover{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.hover\:bg-hairline:hover{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-100:hover{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-500:hover{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.hover\:bg-ink-100:hover{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.hover\:bg-ink-50:hover{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.hover\:bg-lime-100:hover{--tw-bg-opacity: 1;background-color:rgb(236 252 203 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-100:hover{--tw-bg-opacity: 1;background-color:rgb(255 228 230 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-50:hover{--tw-bg-opacity: 1;background-color:rgb(255 241 242 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-700:hover{--tw-bg-opacity: 1;background-color:rgb(190 18 60 / var(--tw-bg-opacity, 1))}.hover\:bg-sky-100:hover{--tw-bg-opacity: 1;background-color:rgb(224 242 254 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-100:hover{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.hover\:bg-teal-100:hover{--tw-bg-opacity: 1;background-color:rgb(204 251 241 / var(--tw-bg-opacity, 1))}.hover\:text-ink-700:hover{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.hover\:text-ink-950:hover{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.hover\:text-rose-700:hover{--tw-text-opacity: 1;color:rgb(190 18 60 / var(--tw-text-opacity, 1))}.hover\:text-rose-800:hover{--tw-text-opacity: 1;color:rgb(159 18 57 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-ring-accent:hover{--tw-shadow: 0 0 0 3px rgba(79, 70, 229, .15);--tw-shadow-colored: 0 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-cyan-500:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.focus\:border-indigo-500:focus{--tw-border-opacity: 1;border-color:rgb(99 102 241 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-indigo-700:active{--tw-bg-opacity: 1;background-color:rgb(67 56 202 / var(--tw-bg-opacity, 1))}.active\:bg-ink-200:active{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-hairline:disabled{--tw-border-opacity: 1;border-color:rgb(229 229 229 / var(--tw-border-opacity, 1))}.disabled\:bg-ink-100:disabled{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.disabled\:text-ink-400:disabled{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:hover\:bg-ink-100:hover:disabled{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto_auto\]{grid-template-columns:minmax(0,1fr) auto auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-self-end{justify-self:end}}@media(min-width:768px){.md\:col-span-2{grid-column:span 2 / span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-\[600px\]{height:600px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-4{padding:1rem}.md\:p-6{padding:1.5rem}.md\:pb-6{padding-bottom:1.5rem}}@media(min-width:1024px){.lg\:w-auto{width:auto}.lg\:max-w-xs{max-width:20rem}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:p-5{padding:1.25rem}.lg\:p-6{padding:1.5rem}}
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(79 70 229 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(79 70 229 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-right-0\.5{right:-.125rem}.-top-0\.5{top:-.125rem}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.left-0{left:0}.left-1\/4{left:25%}.right-0{right:0}.right-1\/4{right:25%}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-2{top:.5rem}.top-3{top:.75rem}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-4{margin-left:1rem}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[calc\(100vh-200px\)\]{height:calc(100vh - 200px)}.h-px{height:1px}.max-h-64{max-height:16rem}.max-h-\[80vh\]{max-height:80vh}.min-h-screen{min-height:100vh}.w-0\.5{width:.125rem}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[180px\]{min-width:180px}.min-w-\[72px\]{min-width:72px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-screen-2xl{max-width:1536px}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes pulseDot{0%,to{transform:scale(1);opacity:1}50%{transform:scale(1.6);opacity:.4}}.animate-pulse-dot{animation:pulseDot 1.8s ease-in-out infinite}@keyframes rise{0%{transform:translateY(6px);opacity:0}to{transform:translateY(0);opacity:1}}.animate-rise{animation:rise .36s cubic-bezier(.22,1,.36,1)}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}@keyframes toastIn{0%{transform:translateY(8px) scale(.96);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}.animate-toast-in{animation:toastIn .22s cubic-bezier(.22,1,.36,1)}@keyframes toastOut{0%{transform:translateY(0);opacity:1}to{transform:translateY(-6px);opacity:0}}.animate-toast-out{animation:toastOut .18s ease-in forwards}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-hairline>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 229 229 / var(--tw-divide-opacity, 1))}.justify-self-start{justify-self:start}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-b-full{border-bottom-right-radius:9999px;border-bottom-left-radius:9999px}.rounded-r-full{border-top-right-radius:9999px;border-bottom-right-radius:9999px}.rounded-bl-lg{border-bottom-left-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-amber-400\/30{border-color:#fbbf244d}.border-amber-500\/20{border-color:#f59e0b33}.border-amber-700{--tw-border-opacity: 1;border-color:rgb(180 83 9 / var(--tw-border-opacity, 1))}.border-blue-200{--tw-border-opacity: 1;border-color:rgb(191 219 254 / var(--tw-border-opacity, 1))}.border-current{border-color:currentColor}.border-cyan-200{--tw-border-opacity: 1;border-color:rgb(165 243 252 / var(--tw-border-opacity, 1))}.border-emerald-200{--tw-border-opacity: 1;border-color:rgb(167 243 208 / var(--tw-border-opacity, 1))}.border-emerald-400\/30{border-color:#34d3994d}.border-emerald-500\/20{border-color:#10b98133}.border-hairline{--tw-border-opacity: 1;border-color:rgb(229 229 229 / var(--tw-border-opacity, 1))}.border-indigo-200{--tw-border-opacity: 1;border-color:rgb(199 210 254 / var(--tw-border-opacity, 1))}.border-indigo-500{--tw-border-opacity: 1;border-color:rgb(99 102 241 / var(--tw-border-opacity, 1))}.border-indigo-500\/30{border-color:#6366f14d}.border-lime-200{--tw-border-opacity: 1;border-color:rgb(217 249 157 / var(--tw-border-opacity, 1))}.border-orange-200{--tw-border-opacity: 1;border-color:rgb(254 215 170 / var(--tw-border-opacity, 1))}.border-orange-400\/40{border-color:#fb923c66}.border-orange-500\/30{border-color:#f973164d}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-red-500\/30{border-color:#ef44444d}.border-rose-200{--tw-border-opacity: 1;border-color:rgb(254 205 211 / var(--tw-border-opacity, 1))}.border-rose-400\/30{border-color:#fb71854d}.border-rose-500\/20{border-color:#f43f5e33}.border-rose-500\/30{border-color:#f43f5e4d}.border-sky-200{--tw-border-opacity: 1;border-color:rgb(186 230 253 / var(--tw-border-opacity, 1))}.border-sky-400\/30{border-color:#38bdf84d}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-400\/25{border-color:#94a3b840}.border-stone-300{--tw-border-opacity: 1;border-color:rgb(214 211 209 / var(--tw-border-opacity, 1))}.border-teal-200{--tw-border-opacity: 1;border-color:rgb(153 246 228 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity: 1;border-color:rgb(254 240 138 / var(--tw-border-opacity, 1))}.border-yellow-500\/30{border-color:#eab3084d}.border-t-transparent{border-top-color:transparent}.bg-amber-100{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/\[0\.08\]{background-color:#f59e0b14}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-canvas{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.bg-cyan-50{--tw-bg-opacity: 1;background-color:rgb(236 254 255 / var(--tw-bg-opacity, 1))}.bg-cyan-700{--tw-bg-opacity: 1;background-color:rgb(14 116 144 / var(--tw-bg-opacity, 1))}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-50{--tw-bg-opacity: 1;background-color:rgb(236 253 245 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/\[0\.08\]{background-color:#10b98114}.bg-indigo-50{--tw-bg-opacity: 1;background-color:rgb(238 242 255 / var(--tw-bg-opacity, 1))}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-ink-100{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.bg-ink-400{--tw-bg-opacity: 1;background-color:rgb(163 163 163 / var(--tw-bg-opacity, 1))}.bg-ink-50{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.bg-ink-600\/20{background-color:#52525233}.bg-lime-50{--tw-bg-opacity: 1;background-color:rgb(247 254 231 / var(--tw-bg-opacity, 1))}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-orange-400{--tw-bg-opacity: 1;background-color:rgb(251 146 60 / var(--tw-bg-opacity, 1))}.bg-orange-50{--tw-bg-opacity: 1;background-color:rgb(255 247 237 / var(--tw-bg-opacity, 1))}.bg-orange-500\/\[0\.10\]{background-color:#f973161a}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-700\/\[0\.12\]{background-color:#b91c1c1f}.bg-rose-100{--tw-bg-opacity: 1;background-color:rgb(255 228 230 / var(--tw-bg-opacity, 1))}.bg-rose-400{--tw-bg-opacity: 1;background-color:rgb(251 113 133 / var(--tw-bg-opacity, 1))}.bg-rose-50{--tw-bg-opacity: 1;background-color:rgb(255 241 242 / var(--tw-bg-opacity, 1))}.bg-rose-500{--tw-bg-opacity: 1;background-color:rgb(244 63 94 / var(--tw-bg-opacity, 1))}.bg-rose-500\/\[0\.08\]{background-color:#f43f5e14}.bg-rose-600{--tw-bg-opacity: 1;background-color:rgb(225 29 72 / var(--tw-bg-opacity, 1))}.bg-rose-700\/80{background-color:#be123ccc}.bg-sky-400{--tw-bg-opacity: 1;background-color:rgb(56 189 248 / var(--tw-bg-opacity, 1))}.bg-sky-50{--tw-bg-opacity: 1;background-color:rgb(240 249 255 / var(--tw-bg-opacity, 1))}.bg-sky-500\/\[0\.08\]{background-color:#0ea5e914}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-400{--tw-bg-opacity: 1;background-color:rgb(148 163 184 / var(--tw-bg-opacity, 1))}.bg-slate-50{--tw-bg-opacity: 1;background-color:rgb(248 250 252 / var(--tw-bg-opacity, 1))}.bg-slate-500{--tw-bg-opacity: 1;background-color:rgb(100 116 139 / var(--tw-bg-opacity, 1))}.bg-slate-500\/\[0\.10\]{background-color:#64748b1a}.bg-stone-500{--tw-bg-opacity: 1;background-color:rgb(120 113 108 / var(--tw-bg-opacity, 1))}.bg-stone-500\/\[0\.08\]{background-color:#78716c14}.bg-surface{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-surface-hover{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.bg-surface-hover\/60{background-color:#f5f5f599}.bg-surface\/95{background-color:#fffffff2}.bg-teal-50{--tw-bg-opacity: 1;background-color:rgb(240 253 250 / var(--tw-bg-opacity, 1))}.bg-yellow-400{--tw-bg-opacity: 1;background-color:rgb(250 204 21 / var(--tw-bg-opacity, 1))}.bg-yellow-50{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.bg-yellow-600\/\[0\.10\]{background-color:#ca8a041a}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-amber-400\/15{--tw-gradient-from: rgb(251 191 36 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(251 191 36 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-amber-500\/25{--tw-gradient-from: rgb(245 158 11 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(245 158 11 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-emerald-400\/20{--tw-gradient-from: rgb(52 211 153 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(52 211 153 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-700\/20{--tw-gradient-from: rgb(185 28 28 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(185 28 28 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-rose-500\/15{--tw-gradient-from: rgb(244 63 94 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(244 63 94 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-sky-400\/20{--tw-gradient-from: rgb(56 189 248 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(56 189 248 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-slate-500\/15{--tw-gradient-from: rgb(100 116 139 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(100 116 139 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-stone-200\/70{--tw-gradient-from: rgb(231 229 228 / .7) var(--tw-gradient-from-position);--tw-gradient-to: rgb(231 229 228 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-500\/15{--tw-gradient-from: rgb(234 179 8 / .15) var(--tw-gradient-from-position);--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-amber-500\/10{--tw-gradient-to: rgb(245 158 11 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(245 158 11 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-blue-500\/10{--tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(59 130 246 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-emerald-500\/10{--tw-gradient-to: rgb(16 185 129 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(16 185 129 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-indigo-400\/40{--tw-gradient-to: rgb(129 140 248 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(129 140 248 / .4) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-orange-500\/20{--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(249 115 22 / .2) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-red-500\/10{--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(239 68 68 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-rose-700\/15{--tw-gradient-to: rgb(190 18 60 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(190 18 60 / .15) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-stone-100\/50{--tw-gradient-to: rgb(245 245 244 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(245 245 244 / .5) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-yellow-500\/10{--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(234 179 8 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-zinc-500\/10{--tw-gradient-to: rgb(113 113 122 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(113 113 122 / .1) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-amber-600\/15{--tw-gradient-to: rgb(217 119 6 / .15) var(--tw-gradient-to-position)}.to-indigo-500\/15{--tw-gradient-to: rgb(99 102 241 / .15) var(--tw-gradient-to-position)}.to-red-800\/20{--tw-gradient-to: rgb(153 27 27 / .2) var(--tw-gradient-to-position)}.to-rose-500\/25{--tw-gradient-to: rgb(244 63 94 / .25) var(--tw-gradient-to-position)}.to-rose-600\/15{--tw-gradient-to: rgb(225 29 72 / .15) var(--tw-gradient-to-position)}.to-slate-100\/60{--tw-gradient-to: rgb(241 245 249 / .6) var(--tw-gradient-to-position)}.to-slate-600\/15{--tw-gradient-to: rgb(71 85 105 / .15) var(--tw-gradient-to-position)}.to-teal-500\/15{--tw-gradient-to: rgb(20 184 166 / .15) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.to-yellow-600\/15{--tw-gradient-to: rgb(202 138 4 / .15) var(--tw-gradient-to-position)}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pb-3{padding-bottom:.75rem}.pt-1{padding-top:.25rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-\[-2px\]{vertical-align:-2px}.font-mono{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-normal{letter-spacing:0em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-widest{letter-spacing:.1em}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-cyan-700{--tw-text-opacity: 1;color:rgb(14 116 144 / var(--tw-text-opacity, 1))}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-emerald-800{--tw-text-opacity: 1;color:rgb(6 95 70 / var(--tw-text-opacity, 1))}.text-emerald-900{--tw-text-opacity: 1;color:rgb(6 78 59 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-indigo-700{--tw-text-opacity: 1;color:rgb(67 56 202 / var(--tw-text-opacity, 1))}.text-ink-400{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.text-ink-500{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity, 1))}.text-ink-600{--tw-text-opacity: 1;color:rgb(82 82 82 / var(--tw-text-opacity, 1))}.text-ink-700{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.text-ink-800{--tw-text-opacity: 1;color:rgb(38 38 38 / var(--tw-text-opacity, 1))}.text-ink-900{--tw-text-opacity: 1;color:rgb(23 23 23 / var(--tw-text-opacity, 1))}.text-ink-950{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.text-lime-700{--tw-text-opacity: 1;color:rgb(77 124 15 / var(--tw-text-opacity, 1))}.text-orange-200{--tw-text-opacity: 1;color:rgb(254 215 170 / var(--tw-text-opacity, 1))}.text-orange-300{--tw-text-opacity: 1;color:rgb(253 186 116 / var(--tw-text-opacity, 1))}.text-orange-700{--tw-text-opacity: 1;color:rgb(194 65 12 / var(--tw-text-opacity, 1))}.text-orange-800{--tw-text-opacity: 1;color:rgb(154 52 18 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-rose-300{--tw-text-opacity: 1;color:rgb(253 164 175 / var(--tw-text-opacity, 1))}.text-rose-700{--tw-text-opacity: 1;color:rgb(190 18 60 / var(--tw-text-opacity, 1))}.text-rose-800{--tw-text-opacity: 1;color:rgb(159 18 57 / var(--tw-text-opacity, 1))}.text-rose-900{--tw-text-opacity: 1;color:rgb(136 19 55 / var(--tw-text-opacity, 1))}.text-rose-950{--tw-text-opacity: 1;color:rgb(76 5 25 / var(--tw-text-opacity, 1))}.text-sky-300{--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-sky-800{--tw-text-opacity: 1;color:rgb(7 89 133 / var(--tw-text-opacity, 1))}.text-sky-900{--tw-text-opacity: 1;color:rgb(12 74 110 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-slate-950{--tw-text-opacity: 1;color:rgb(2 6 23 / var(--tw-text-opacity, 1))}.text-stone-700{--tw-text-opacity: 1;color:rgb(68 64 60 / var(--tw-text-opacity, 1))}.text-teal-700{--tw-text-opacity: 1;color:rgb(15 118 110 / var(--tw-text-opacity, 1))}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.accent-indigo-500{accent-color:#6366f1}.accent-indigo-600{accent-color:#4f46e5}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow-card{--tw-shadow: 0 1px 2px 0 rgba(0,0,0,.04), 0 1px 3px 0 rgba(0,0,0,.06);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color), 0 1px 3px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-inset{--tw-ring-inset: inset}.ring-amber-600\/25{--tw-ring-color: rgb(217 119 6 / .25)}.ring-emerald-600\/20{--tw-ring-color: rgb(5 150 105 / .2)}.ring-orange-600\/25{--tw-ring-color: rgb(234 88 12 / .25)}.ring-rose-400\/40{--tw-ring-color: rgb(251 113 133 / .4)}.ring-rose-600\/25{--tw-ring-color: rgb(225 29 72 / .25)}.ring-slate-400\/30{--tw-ring-color: rgb(148 163 184 / .3)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-500{transition-duration:.5s}.text-gray-500{color:#737373}.text-emerald-300{color:#047857}.text-amber-300{color:#b45309}.text-rose-300{color:#be123c}.text-orange-200{color:#9a3412}.text-orange-300{color:#c2410c}.text-yellow-200{color:#854d0e}.text-sky-300{color:#0369a1}.text-slate-300{color:#475569}.text-red-300{color:#b91c1c}:root{color-scheme:light;--bg-canvas: #fafafa;--bg-surface: #ffffff;--bg-surface-hover: #f5f5f5;--hairline: #e5e5e5;--hairline-strong: #d4d4d4;--ink-950: #0a0a0a;--ink-700: #404040;--ink-600: #525252;--ink-500: #737373;--ink-400: #a3a3a3;--accent: #4f46e5;--accent-soft: #eef2ff}html,body{font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:"cv02","cv03","cv04","cv11","ss01";-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}body{background-color:var(--bg-canvas);background-image:none;color:var(--ink-700)}.font-mono,code,pre,.tabular{font-variant-numeric:tabular-nums}.glass{background:var(--bg-surface);-webkit-backdrop-filter:none;backdrop-filter:none;border:1px solid var(--hairline);box-shadow:0 1px 2px #0000000a,0 1px 3px #0000000f}.glass-soft{background:var(--bg-surface);border:1px solid var(--hairline);box-shadow:0 1px 2px #00000008}.shimmer-bg{background:linear-gradient(90deg,#00000008,#00000014,#00000008);background-size:200% 100%;animation:shimmer 2.4s ease-in-out infinite}.row-hoverable{position:relative;transition:background-color .18s ease,transform .18s ease}.row-hoverable:before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:linear-gradient(180deg,transparent,rgba(79,70,229,.5),transparent);opacity:0;transition:opacity .22s ease}.row-hoverable:hover:before{opacity:1}.row-hoverable:hover{background-color:var(--bg-surface-hover)}@keyframes graceUrgent{0%,to{box-shadow:0 0 #e11d4800}50%{box-shadow:0 0 0 4px #e11d482e}}.grace-urgent{animation:graceUrgent 2.2s ease-in-out infinite}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#0000001f;border-radius:8px;border:2px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:hover{background:#0003;background-clip:padding-box;border:2px solid transparent}#app{position:relative;z-index:1}.lift-hover{transition:transform .18s cubic-bezier(.22,1,.36,1),box-shadow .18s ease}.lift-hover:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px -2px #00000014,0 2px 4px -2px #0000000a}.lift-hover:active:not(:disabled){transform:translateY(0)}.focus-ring:focus-visible{outline:none;box-shadow:0 0 0 2px #4f46e566,0 0 0 4px #4f46e526}.page-enter-from{opacity:0;transform:translateY(4px)}.page-enter-active{transition:opacity .18s cubic-bezier(.22,1,.36,1),transform .18s cubic-bezier(.22,1,.36,1)}.page-enter-to,.page-leave-from{opacity:1;transform:translateY(0)}.page-leave-active{transition:opacity .12s ease-in,transform .12s ease-in}.page-leave-to{opacity:0;transform:translateY(-2px)}.text-on-accent{color:#fff!important}.placeholder\:text-ink-400::-moz-placeholder{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.placeholder\:text-ink-400::placeholder{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.hover\:border-hairline-strong:hover{--tw-border-opacity: 1;border-color:rgb(212 212 212 / var(--tw-border-opacity, 1))}.hover\:border-indigo-300:hover{--tw-border-opacity: 1;border-color:rgb(165 180 252 / var(--tw-border-opacity, 1))}.hover\:border-rose-300:hover{--tw-border-opacity: 1;border-color:rgb(253 164 175 / var(--tw-border-opacity, 1))}.hover\:bg-amber-100:hover{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.hover\:bg-cyan-100:hover{--tw-bg-opacity: 1;background-color:rgb(207 250 254 / var(--tw-bg-opacity, 1))}.hover\:bg-cyan-600:hover{--tw-bg-opacity: 1;background-color:rgb(8 145 178 / var(--tw-bg-opacity, 1))}.hover\:bg-emerald-100:hover{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.hover\:bg-hairline:hover{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-100:hover{--tw-bg-opacity: 1;background-color:rgb(224 231 255 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-500:hover{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.hover\:bg-ink-100:hover{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.hover\:bg-ink-50:hover{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity, 1))}.hover\:bg-lime-100:hover{--tw-bg-opacity: 1;background-color:rgb(236 252 203 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-100:hover{--tw-bg-opacity: 1;background-color:rgb(255 228 230 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-50:hover{--tw-bg-opacity: 1;background-color:rgb(255 241 242 / var(--tw-bg-opacity, 1))}.hover\:bg-rose-700:hover{--tw-bg-opacity: 1;background-color:rgb(190 18 60 / var(--tw-bg-opacity, 1))}.hover\:bg-sky-100:hover{--tw-bg-opacity: 1;background-color:rgb(224 242 254 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-100:hover{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.hover\:bg-teal-100:hover{--tw-bg-opacity: 1;background-color:rgb(204 251 241 / var(--tw-bg-opacity, 1))}.hover\:text-ink-700:hover{--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity, 1))}.hover\:text-ink-950:hover{--tw-text-opacity: 1;color:rgb(10 10 10 / var(--tw-text-opacity, 1))}.hover\:text-rose-700:hover{--tw-text-opacity: 1;color:rgb(190 18 60 / var(--tw-text-opacity, 1))}.hover\:text-rose-800:hover{--tw-text-opacity: 1;color:rgb(159 18 57 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-ring-accent:hover{--tw-shadow: 0 0 0 3px rgba(79, 70, 229, .15);--tw-shadow-colored: 0 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-cyan-500:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.focus\:border-indigo-500:focus{--tw-border-opacity: 1;border-color:rgb(99 102 241 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-indigo-700:active{--tw-bg-opacity: 1;background-color:rgb(67 56 202 / var(--tw-bg-opacity, 1))}.active\:bg-ink-200:active{--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-hairline:disabled{--tw-border-opacity: 1;border-color:rgb(229 229 229 / var(--tw-border-opacity, 1))}.disabled\:bg-ink-100:disabled{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}.disabled\:text-ink-400:disabled{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:hover\:bg-ink-100:hover:disabled{--tw-bg-opacity: 1;background-color:rgb(245 245 245 / var(--tw-bg-opacity, 1))}@media(min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto_auto\]{grid-template-columns:minmax(0,1fr) auto auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-self-end{justify-self:end}}@media(min-width:768px){.md\:col-span-2{grid-column:span 2 / span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-\[600px\]{height:600px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:p-4{padding:1rem}.md\:p-6{padding:1.5rem}.md\:pb-6{padding-bottom:1.5rem}}@media(min-width:1024px){.lg\:w-auto{width:auto}.lg\:max-w-xs{max-width:20rem}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:p-5{padding:1.25rem}.lg\:p-6{padding:1.5rem}}
|
src/autoteam/web/dist/index.html
CHANGED
|
@@ -7,8 +7,8 @@
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 10 |
-
<script type="module" crossorigin src="/assets/index-
|
| 11 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 12 |
</head>
|
| 13 |
<body class="bg-canvas text-ink-700 min-h-screen">
|
| 14 |
<div id="app"></div>
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 10 |
+
<script type="module" crossorigin src="/assets/index-BSht6bmF.js"></script>
|
| 11 |
+
<link rel="stylesheet" crossorigin href="/assets/index-CbNAiyvN.css">
|
| 12 |
</head>
|
| 13 |
<body class="bg-canvas text-ink-700 min-h-screen">
|
| 14 |
<div id="app"></div>
|
tests/unit/test_accounts.py
CHANGED
|
@@ -15,6 +15,7 @@ def test_add_and_update_account_persists_data(tmp_path, monkeypatch):
|
|
| 15 |
assert created[0]["email"] == "user@example.com"
|
| 16 |
assert created[0]["cloudmail_account_id"] == 123
|
| 17 |
assert created[0]["status"] == accounts.STATUS_PENDING
|
|
|
|
| 18 |
|
| 19 |
updated = accounts.update_account("user@example.com", status=accounts.STATUS_ACTIVE, auth_file="auth.json")
|
| 20 |
|
|
@@ -32,6 +33,7 @@ def test_get_active_accounts_excludes_main_account(tmp_path, monkeypatch):
|
|
| 32 |
[
|
| 33 |
{"email": "owner@example.com", "status": accounts.STATUS_ACTIVE},
|
| 34 |
{"email": "member@example.com", "status": accounts.STATUS_ACTIVE},
|
|
|
|
| 35 |
{"email": "standby@example.com", "status": accounts.STATUS_STANDBY},
|
| 36 |
]
|
| 37 |
)
|
|
@@ -73,6 +75,13 @@ def test_get_standby_accounts_orders_recovered_first_and_skips_main_account(tmp_
|
|
| 73 |
"quota_resets_at": None,
|
| 74 |
"quota_exhausted_at": None,
|
| 75 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
]
|
| 77 |
)
|
| 78 |
|
|
@@ -87,3 +96,18 @@ def test_get_standby_accounts_orders_recovered_first_and_skips_main_account(tmp_
|
|
| 87 |
assert standby[1]["_quota_recovered"] is True
|
| 88 |
assert standby[2]["_quota_recovered"] is False
|
| 89 |
assert accounts.get_next_reusable_account()["email"] == "always@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
assert created[0]["email"] == "user@example.com"
|
| 16 |
assert created[0]["cloudmail_account_id"] == 123
|
| 17 |
assert created[0]["status"] == accounts.STATUS_PENDING
|
| 18 |
+
assert created[0]["disabled"] is False
|
| 19 |
|
| 20 |
updated = accounts.update_account("user@example.com", status=accounts.STATUS_ACTIVE, auth_file="auth.json")
|
| 21 |
|
|
|
|
| 33 |
[
|
| 34 |
{"email": "owner@example.com", "status": accounts.STATUS_ACTIVE},
|
| 35 |
{"email": "member@example.com", "status": accounts.STATUS_ACTIVE},
|
| 36 |
+
{"email": "disabled@example.com", "status": accounts.STATUS_ACTIVE, "disabled": True},
|
| 37 |
{"email": "standby@example.com", "status": accounts.STATUS_STANDBY},
|
| 38 |
]
|
| 39 |
)
|
|
|
|
| 75 |
"quota_resets_at": None,
|
| 76 |
"quota_exhausted_at": None,
|
| 77 |
},
|
| 78 |
+
{
|
| 79 |
+
"email": "disabled@example.com",
|
| 80 |
+
"status": accounts.STATUS_STANDBY,
|
| 81 |
+
"quota_resets_at": None,
|
| 82 |
+
"quota_exhausted_at": None,
|
| 83 |
+
"disabled": True,
|
| 84 |
+
},
|
| 85 |
]
|
| 86 |
)
|
| 87 |
|
|
|
|
| 96 |
assert standby[1]["_quota_recovered"] is True
|
| 97 |
assert standby[2]["_quota_recovered"] is False
|
| 98 |
assert accounts.get_next_reusable_account()["email"] == "always@example.com"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_load_accounts_normalizes_disabled_field(tmp_path, monkeypatch):
|
| 102 |
+
accounts_file = tmp_path / "accounts.json"
|
| 103 |
+
monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
|
| 104 |
+
|
| 105 |
+
accounts_file.write_text(
|
| 106 |
+
'[{"email":"legacy@example.com","status":"standby"},{"email":"off@example.com","status":"active","disabled":1}]',
|
| 107 |
+
encoding="utf-8",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
loaded = accounts.load_accounts()
|
| 111 |
+
|
| 112 |
+
assert loaded[0]["disabled"] is False
|
| 113 |
+
assert loaded[1]["disabled"] is True
|
tests/unit/test_api_playwright_cleanup.py
CHANGED
|
@@ -42,6 +42,48 @@ def test_launch_browser_stops_playwright_when_browser_launch_fails(tmp_path, mon
|
|
| 42 |
assert client.page is None
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def test_chatgpt_stop_closes_page_context_browser_and_playwright():
|
| 46 |
calls = []
|
| 47 |
|
|
@@ -153,6 +195,7 @@ def test_api_fetch_browser_fallback_cleans_partial_browser_session():
|
|
| 153 |
|
| 154 |
def test_complete_registration_closes_page_context_browser_when_invite_registration_raises(monkeypatch):
|
| 155 |
calls = []
|
|
|
|
| 156 |
|
| 157 |
class FakeClosable:
|
| 158 |
def __init__(self, name):
|
|
@@ -172,7 +215,8 @@ def test_complete_registration_closes_page_context_browser_when_invite_registrat
|
|
| 172 |
def __init__(self):
|
| 173 |
super().__init__("browser")
|
| 174 |
|
| 175 |
-
def new_context(self, **
|
|
|
|
| 176 |
return FakeContext()
|
| 177 |
|
| 178 |
class FakeChromium:
|
|
@@ -193,6 +237,7 @@ def test_complete_registration_closes_page_context_browser_when_invite_registrat
|
|
| 193 |
raise RuntimeError("registration crashed")
|
| 194 |
|
| 195 |
monkeypatch.setattr(manager, "sync_playwright", lambda: FakeSyncPlaywright())
|
|
|
|
| 196 |
monkeypatch.setattr(invite, "register_with_invite", fail_register)
|
| 197 |
|
| 198 |
with pytest.raises(RuntimeError, match="registration crashed"):
|
|
@@ -204,10 +249,12 @@ def test_complete_registration_closes_page_context_browser_when_invite_registrat
|
|
| 204 |
)
|
| 205 |
|
| 206 |
assert calls == ["close:page", "close:context", "close:browser"]
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
def test_register_direct_once_cleans_browser_context_on_unhandled_page_error(monkeypatch):
|
| 210 |
calls = []
|
|
|
|
| 211 |
|
| 212 |
class FakePage:
|
| 213 |
url = "about:blank"
|
|
@@ -226,7 +273,8 @@ def test_register_direct_once_cleans_browser_context_on_unhandled_page_error(mon
|
|
| 226 |
calls.append("close:context")
|
| 227 |
|
| 228 |
class FakeBrowser:
|
| 229 |
-
def new_context(self, **
|
|
|
|
| 230 |
return FakeContext()
|
| 231 |
|
| 232 |
def close(self):
|
|
@@ -247,11 +295,13 @@ def test_register_direct_once_cleans_browser_context_on_unhandled_page_error(mon
|
|
| 247 |
return False
|
| 248 |
|
| 249 |
monkeypatch.setattr(manager, "sync_playwright", lambda: FakeSyncPlaywright())
|
|
|
|
| 250 |
|
| 251 |
with pytest.raises(RuntimeError, match="navigation exploded"):
|
| 252 |
manager._register_direct_once(object(), "child@example.com", "password")
|
| 253 |
|
| 254 |
assert calls == ["close:page", "close:context", "close:browser"]
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
def test_registration_and_oauth_paths_use_unified_playwright_cleanup():
|
|
@@ -261,6 +311,13 @@ def test_registration_and_oauth_paths_use_unified_playwright_cleanup():
|
|
| 261 |
assert "browser.close()" not in inspect.getsource(codex_auth.login_codex_via_browser)
|
| 262 |
assert "close_playwright_objects" in inspect.getsource(manager._register_direct_once)
|
| 263 |
assert "close_playwright_objects" in inspect.getsource(codex_auth.login_codex_via_browser)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
def test_session_codex_auth_flow_stops_chatgpt_when_page_start_fails(monkeypatch):
|
|
|
|
| 42 |
assert client.page is None
|
| 43 |
|
| 44 |
|
| 45 |
+
def test_launch_browser_uses_unified_context_options(monkeypatch):
|
| 46 |
+
captured = {}
|
| 47 |
+
|
| 48 |
+
class FakePage:
|
| 49 |
+
def close(self):
|
| 50 |
+
captured["closed_page"] = True
|
| 51 |
+
|
| 52 |
+
class FakeContext:
|
| 53 |
+
def close(self):
|
| 54 |
+
captured["closed_context"] = True
|
| 55 |
+
|
| 56 |
+
def new_page(self):
|
| 57 |
+
return FakePage()
|
| 58 |
+
|
| 59 |
+
class FakePlaywright:
|
| 60 |
+
def __init__(self):
|
| 61 |
+
self.chromium = self
|
| 62 |
+
|
| 63 |
+
def launch(self, **_kwargs):
|
| 64 |
+
return self
|
| 65 |
+
|
| 66 |
+
def new_context(self, **kwargs):
|
| 67 |
+
captured["kwargs"] = kwargs
|
| 68 |
+
return FakeContext()
|
| 69 |
+
|
| 70 |
+
def stop(self):
|
| 71 |
+
captured["stopped"] = True
|
| 72 |
+
|
| 73 |
+
class FakeSyncPlaywright:
|
| 74 |
+
def start(self):
|
| 75 |
+
return FakePlaywright()
|
| 76 |
+
|
| 77 |
+
monkeypatch.setattr(chatgpt_api, "sync_playwright", lambda: FakeSyncPlaywright())
|
| 78 |
+
monkeypatch.setattr(chatgpt_api, "get_playwright_launch_options", lambda: {})
|
| 79 |
+
monkeypatch.setattr(chatgpt_api, "get_playwright_context_options", lambda: {"viewport": {"width": 100, "height": 200}})
|
| 80 |
+
|
| 81 |
+
client = chatgpt_api.ChatGPTTeamAPI()
|
| 82 |
+
client._launch_browser()
|
| 83 |
+
|
| 84 |
+
assert captured["kwargs"] == {"viewport": {"width": 100, "height": 200}}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
def test_chatgpt_stop_closes_page_context_browser_and_playwright():
|
| 88 |
calls = []
|
| 89 |
|
|
|
|
| 195 |
|
| 196 |
def test_complete_registration_closes_page_context_browser_when_invite_registration_raises(monkeypatch):
|
| 197 |
calls = []
|
| 198 |
+
captured = {}
|
| 199 |
|
| 200 |
class FakeClosable:
|
| 201 |
def __init__(self, name):
|
|
|
|
| 215 |
def __init__(self):
|
| 216 |
super().__init__("browser")
|
| 217 |
|
| 218 |
+
def new_context(self, **kwargs):
|
| 219 |
+
captured["kwargs"] = kwargs
|
| 220 |
return FakeContext()
|
| 221 |
|
| 222 |
class FakeChromium:
|
|
|
|
| 237 |
raise RuntimeError("registration crashed")
|
| 238 |
|
| 239 |
monkeypatch.setattr(manager, "sync_playwright", lambda: FakeSyncPlaywright())
|
| 240 |
+
monkeypatch.setattr(manager, "get_playwright_context_options", lambda: {"locale": "zh-CN"})
|
| 241 |
monkeypatch.setattr(invite, "register_with_invite", fail_register)
|
| 242 |
|
| 243 |
with pytest.raises(RuntimeError, match="registration crashed"):
|
|
|
|
| 249 |
)
|
| 250 |
|
| 251 |
assert calls == ["close:page", "close:context", "close:browser"]
|
| 252 |
+
assert captured["kwargs"] == {"locale": "zh-CN"}
|
| 253 |
|
| 254 |
|
| 255 |
def test_register_direct_once_cleans_browser_context_on_unhandled_page_error(monkeypatch):
|
| 256 |
calls = []
|
| 257 |
+
captured = {}
|
| 258 |
|
| 259 |
class FakePage:
|
| 260 |
url = "about:blank"
|
|
|
|
| 273 |
calls.append("close:context")
|
| 274 |
|
| 275 |
class FakeBrowser:
|
| 276 |
+
def new_context(self, **kwargs):
|
| 277 |
+
captured["kwargs"] = kwargs
|
| 278 |
return FakeContext()
|
| 279 |
|
| 280 |
def close(self):
|
|
|
|
| 295 |
return False
|
| 296 |
|
| 297 |
monkeypatch.setattr(manager, "sync_playwright", lambda: FakeSyncPlaywright())
|
| 298 |
+
monkeypatch.setattr(manager, "get_playwright_context_options", lambda: {"timezone_id": "Asia/Shanghai"})
|
| 299 |
|
| 300 |
with pytest.raises(RuntimeError, match="navigation exploded"):
|
| 301 |
manager._register_direct_once(object(), "child@example.com", "password")
|
| 302 |
|
| 303 |
assert calls == ["close:page", "close:context", "close:browser"]
|
| 304 |
+
assert captured["kwargs"] == {"timezone_id": "Asia/Shanghai"}
|
| 305 |
|
| 306 |
|
| 307 |
def test_registration_and_oauth_paths_use_unified_playwright_cleanup():
|
|
|
|
| 311 |
assert "browser.close()" not in inspect.getsource(codex_auth.login_codex_via_browser)
|
| 312 |
assert "close_playwright_objects" in inspect.getsource(manager._register_direct_once)
|
| 313 |
assert "close_playwright_objects" in inspect.getsource(codex_auth.login_codex_via_browser)
|
| 314 |
+
assert "get_playwright_context_options()" in inspect.getsource(chatgpt_api.ChatGPTTeamAPI._launch_browser)
|
| 315 |
+
assert "get_playwright_context_options()" in inspect.getsource(invite.run)
|
| 316 |
+
assert "get_playwright_context_options()" in inspect.getsource(codex_auth.login_codex_via_browser)
|
| 317 |
+
assert "get_playwright_context_options()" in inspect.getsource(manager._complete_registration)
|
| 318 |
+
assert "get_playwright_context_options()" in inspect.getsource(manager._register_direct_once)
|
| 319 |
+
assert "viewport={\"width\": 1280, \"height\": 800}" not in inspect.getsource(chatgpt_api.ChatGPTTeamAPI._launch_browser)
|
| 320 |
+
assert "user_agent=\"Mozilla/5.0" not in inspect.getsource(chatgpt_api.ChatGPTTeamAPI._launch_browser)
|
| 321 |
|
| 322 |
|
| 323 |
def test_session_codex_auth_flow_stops_chatgpt_when_page_start_fails(monkeypatch):
|
tests/unit/test_cpa_sync.py
CHANGED
|
@@ -77,6 +77,130 @@ def test_sync_to_cpa_skips_disabled_accounts_and_keeps_protected_remote(monkeypa
|
|
| 77 |
assert uploaded == [enabled_auth.name]
|
| 78 |
assert deleted == []
|
| 79 |
assert result["disabled_skipped"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
assert result["delete_guard"]["skipped_protected"] == 1
|
| 81 |
|
| 82 |
|
|
|
|
| 77 |
assert uploaded == [enabled_auth.name]
|
| 78 |
assert deleted == []
|
| 79 |
assert result["disabled_skipped"] == 1
|
| 80 |
+
assert result["delete_guard"]["allow_remote_delete"] is False
|
| 81 |
+
assert result["delete_guard"]["skipped_remote_delete"] == 1
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_sync_to_cpa_allows_remote_delete_when_active_pool_is_stable(monkeypatch, tmp_path):
|
| 85 |
+
first_auth = tmp_path / "codex-first@example.com-team-a.json"
|
| 86 |
+
second_auth = tmp_path / "codex-second@example.com-team-b.json"
|
| 87 |
+
stale_auth = tmp_path / "codex-stale@example.com-team-c.json"
|
| 88 |
+
first_auth.write_text('{"access_token":"token-first"}', encoding="utf-8")
|
| 89 |
+
second_auth.write_text('{"access_token":"token-second"}', encoding="utf-8")
|
| 90 |
+
stale_auth.write_text('{"access_token":"token-stale"}', encoding="utf-8")
|
| 91 |
+
|
| 92 |
+
monkeypatch.setattr(
|
| 93 |
+
"autoteam.accounts.load_accounts",
|
| 94 |
+
lambda: [
|
| 95 |
+
{
|
| 96 |
+
"email": "first@example.com",
|
| 97 |
+
"status": "active",
|
| 98 |
+
"auth_file": str(first_auth),
|
| 99 |
+
"disabled": False,
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"email": "second@example.com",
|
| 103 |
+
"status": "active",
|
| 104 |
+
"auth_file": str(second_auth),
|
| 105 |
+
"disabled": False,
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"email": "stale@example.com",
|
| 109 |
+
"status": "standby",
|
| 110 |
+
"auth_file": "",
|
| 111 |
+
"disabled": False,
|
| 112 |
+
},
|
| 113 |
+
],
|
| 114 |
+
)
|
| 115 |
+
monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
|
| 116 |
+
monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
|
| 117 |
+
monkeypatch.setattr(
|
| 118 |
+
cpa_sync,
|
| 119 |
+
"list_cpa_files",
|
| 120 |
+
lambda: [
|
| 121 |
+
{"name": first_auth.name, "email": "first@example.com"},
|
| 122 |
+
{"name": second_auth.name, "email": "second@example.com"},
|
| 123 |
+
{"name": stale_auth.name, "email": "stale@example.com"},
|
| 124 |
+
],
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
uploaded = []
|
| 128 |
+
deleted = []
|
| 129 |
+
monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("ok", {}))
|
| 130 |
+
monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
|
| 131 |
+
monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
|
| 132 |
+
|
| 133 |
+
result = cpa_sync.sync_to_cpa()
|
| 134 |
+
|
| 135 |
+
assert uploaded == [first_auth.name, second_auth.name]
|
| 136 |
+
assert deleted == [stale_auth.name]
|
| 137 |
+
assert result["delete_guard"]["allow_remote_delete"] is True
|
| 138 |
+
assert result["delete_guard"]["skipped_remote_delete"] == 0
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_sync_to_cpa_preserves_credential_seat_when_remote_delete_is_allowed(monkeypatch, tmp_path):
|
| 142 |
+
active_auth = tmp_path / "codex-active@example.com-team-a.json"
|
| 143 |
+
second_active_auth = tmp_path / "codex-second-active@example.com-team-c.json"
|
| 144 |
+
protected_auth = tmp_path / "codex-protected@example.com-team-b.json"
|
| 145 |
+
active_auth.write_text('{"access_token":"token-active"}', encoding="utf-8")
|
| 146 |
+
second_active_auth.write_text('{"access_token":"token-second-active"}', encoding="utf-8")
|
| 147 |
+
protected_auth.write_text('{"access_token":"token-protected"}', encoding="utf-8")
|
| 148 |
+
|
| 149 |
+
monkeypatch.setattr(
|
| 150 |
+
"autoteam.accounts.load_accounts",
|
| 151 |
+
lambda: [
|
| 152 |
+
{
|
| 153 |
+
"email": "active@example.com",
|
| 154 |
+
"status": "active",
|
| 155 |
+
"auth_file": str(active_auth),
|
| 156 |
+
"disabled": False,
|
| 157 |
+
"mail_provider": "cloudflare_temp_email",
|
| 158 |
+
"mail_account_id": 1,
|
| 159 |
+
"cloudmail_account_id": None,
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"email": "second-active@example.com",
|
| 163 |
+
"status": "active",
|
| 164 |
+
"auth_file": str(second_active_auth),
|
| 165 |
+
"disabled": False,
|
| 166 |
+
"mail_provider": "cloudflare_temp_email",
|
| 167 |
+
"mail_account_id": 2,
|
| 168 |
+
"cloudmail_account_id": None,
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"email": "protected@example.com",
|
| 172 |
+
"status": "auth_invalid",
|
| 173 |
+
"auth_file": str(protected_auth),
|
| 174 |
+
"disabled": False,
|
| 175 |
+
"mail_provider": "",
|
| 176 |
+
"mail_account_id": None,
|
| 177 |
+
"cloudmail_account_id": None,
|
| 178 |
+
},
|
| 179 |
+
],
|
| 180 |
+
)
|
| 181 |
+
monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
|
| 182 |
+
monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
|
| 183 |
+
monkeypatch.setattr(
|
| 184 |
+
cpa_sync,
|
| 185 |
+
"list_cpa_files",
|
| 186 |
+
lambda: [
|
| 187 |
+
{"name": active_auth.name, "email": "active@example.com"},
|
| 188 |
+
{"name": second_active_auth.name, "email": "second-active@example.com"},
|
| 189 |
+
{"name": protected_auth.name, "email": "protected@example.com"},
|
| 190 |
+
],
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
uploaded = []
|
| 194 |
+
deleted = []
|
| 195 |
+
monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("ok", {}))
|
| 196 |
+
monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
|
| 197 |
+
monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
|
| 198 |
+
|
| 199 |
+
result = cpa_sync.sync_to_cpa()
|
| 200 |
+
|
| 201 |
+
assert uploaded == [active_auth.name, second_active_auth.name]
|
| 202 |
+
assert deleted == []
|
| 203 |
+
assert result["delete_guard"]["allow_remote_delete"] is True
|
| 204 |
assert result["delete_guard"]["skipped_protected"] == 1
|
| 205 |
|
| 206 |
|
tests/unit/test_sub2api_sync.py
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from autoteam import sub2api_sync
|
| 8 |
+
from autoteam.codex_auth import CODEX_CLIENT_ID
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _jwt(payload: dict) -> str:
|
| 12 |
+
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
|
| 13 |
+
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
|
| 14 |
+
return f"{header}.{body}."
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_build_credentials_matches_openai_oauth_shape():
|
| 18 |
+
expires_iso = "2026-04-25T05:44:19Z"
|
| 19 |
+
id_token = _jwt(
|
| 20 |
+
{
|
| 21 |
+
"aud": [CODEX_CLIENT_ID],
|
| 22 |
+
"email": "tmp@example.com",
|
| 23 |
+
"https://api.openai.com/auth": {
|
| 24 |
+
"chatgpt_account_id": "acct-1",
|
| 25 |
+
"chatgpt_user_id": "user-1",
|
| 26 |
+
"chatgpt_plan_type": "team",
|
| 27 |
+
"chatgpt_subscription_active_until": "2026-05-05T15:55:38+00:00",
|
| 28 |
+
"organizations": [{"id": "org-1", "is_default": True}],
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
credentials = sub2api_sync._build_credentials(
|
| 34 |
+
{
|
| 35 |
+
"access_token": "at-1",
|
| 36 |
+
"refresh_token": "rt-1",
|
| 37 |
+
"id_token": id_token,
|
| 38 |
+
"expired": expires_iso,
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
assert credentials == {
|
| 43 |
+
"access_token": "at-1",
|
| 44 |
+
"expires_at": int(datetime.fromisoformat(expires_iso.replace("Z", "+00:00")).timestamp()),
|
| 45 |
+
"refresh_token": "rt-1",
|
| 46 |
+
"id_token": id_token,
|
| 47 |
+
"client_id": CODEX_CLIENT_ID,
|
| 48 |
+
"email": "tmp@example.com",
|
| 49 |
+
"chatgpt_account_id": "acct-1",
|
| 50 |
+
"chatgpt_user_id": "user-1",
|
| 51 |
+
"organization_id": "org-1",
|
| 52 |
+
"plan_type": "team",
|
| 53 |
+
"subscription_expires_at": "2026-05-05T15:55:38+00:00",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_build_managed_model_mapping_uses_identity_mapping():
|
| 58 |
+
assert sub2api_sync._build_managed_model_mapping("gpt-5.4, gpt-5.4-mini") == {
|
| 59 |
+
"gpt-5.4": "gpt-5.4",
|
| 60 |
+
"gpt-5.4-mini": "gpt-5.4-mini",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_build_managed_model_mapping_returns_none_when_blank():
|
| 65 |
+
assert sub2api_sync._build_managed_model_mapping("") is None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_apply_managed_extra_settings_supports_ws_mode_and_passthrough(monkeypatch):
|
| 69 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_WS_MODE", "ctx_pool")
|
| 70 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_PASSTHROUGH", True)
|
| 71 |
+
|
| 72 |
+
extra = {"openai_oauth_passthrough": False}
|
| 73 |
+
sub2api_sync._apply_managed_extra_settings(extra)
|
| 74 |
+
|
| 75 |
+
assert extra["openai_oauth_responses_websockets_v2_mode"] == "ctx_pool"
|
| 76 |
+
assert extra["openai_oauth_responses_websockets_v2_enabled"] is True
|
| 77 |
+
assert extra["openai_passthrough"] is True
|
| 78 |
+
assert extra["openai_oauth_passthrough"] is False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_apply_managed_extra_settings_removes_passthrough_when_disabled(monkeypatch):
|
| 82 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_WS_MODE", "off")
|
| 83 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_PASSTHROUGH", False)
|
| 84 |
+
|
| 85 |
+
extra = {"openai_passthrough": True, "openai_oauth_passthrough": True}
|
| 86 |
+
sub2api_sync._apply_managed_extra_settings(extra)
|
| 87 |
+
|
| 88 |
+
assert extra["openai_oauth_responses_websockets_v2_mode"] == "off"
|
| 89 |
+
assert extra["openai_oauth_responses_websockets_v2_enabled"] is False
|
| 90 |
+
assert "openai_passthrough" not in extra
|
| 91 |
+
assert "openai_oauth_passthrough" not in extra
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_build_extra_includes_codex_usage_snapshot(monkeypatch):
|
| 95 |
+
monkeypatch.setattr(sub2api_sync.time, "time", lambda: 1_700_000_000)
|
| 96 |
+
|
| 97 |
+
extra = sub2api_sync._build_extra(
|
| 98 |
+
"tmp@example.com",
|
| 99 |
+
"codex-tmp@example.com-team-123.json",
|
| 100 |
+
kind="pool",
|
| 101 |
+
quota_info={
|
| 102 |
+
"primary_pct": 42,
|
| 103 |
+
"primary_resets_at": 1_700_003_600,
|
| 104 |
+
"weekly_pct": 88,
|
| 105 |
+
"weekly_resets_at": 1_700_086_400,
|
| 106 |
+
},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
assert extra["autoteam_managed"] is True
|
| 110 |
+
assert extra["autoteam_kind"] == "pool"
|
| 111 |
+
assert extra["autoteam_email"] == "tmp@example.com"
|
| 112 |
+
assert extra["autoteam_auth_file"] == "sub2api-codex-tmp@example.com-team-123.json"
|
| 113 |
+
assert extra["autoteam_source"] == "autoteam"
|
| 114 |
+
assert extra["email"] == "tmp@example.com"
|
| 115 |
+
assert extra["codex_5h_used_percent"] == 42
|
| 116 |
+
assert extra["codex_5h_reset_after_seconds"] == 3600
|
| 117 |
+
assert extra["codex_5h_reset_at"] == sub2api_sync._to_local_iso(1_700_003_600)
|
| 118 |
+
assert extra["codex_7d_used_percent"] == 88
|
| 119 |
+
assert extra["codex_7d_reset_after_seconds"] == 86_400
|
| 120 |
+
assert extra["codex_7d_reset_at"] == sub2api_sync._to_local_iso(1_700_086_400)
|
| 121 |
+
assert extra["codex_primary_used_percent"] == 42
|
| 122 |
+
assert extra["codex_secondary_used_percent"] == 88
|
| 123 |
+
assert extra["codex_usage_updated_at"] == datetime.fromtimestamp(
|
| 124 |
+
1_700_000_000, timezone.utc
|
| 125 |
+
).astimezone().isoformat(timespec="seconds")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_attach_group_metadata_records_autoteam_group_binding():
|
| 129 |
+
extra = sub2api_sync._build_extra("tmp@example.com", "codex-tmp@example.com-team-123.json", kind="pool")
|
| 130 |
+
sub2api_sync._attach_group_metadata(extra, [7], ["Team Pool"])
|
| 131 |
+
|
| 132 |
+
assert extra["autoteam_sub2api_group_ids"] == [7]
|
| 133 |
+
assert extra["autoteam_sub2api_group_names"] == ["Team Pool"]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_resolve_group_binding_supports_name_and_id(monkeypatch):
|
| 137 |
+
monkeypatch.setattr(
|
| 138 |
+
sub2api_sync,
|
| 139 |
+
"_list_openai_groups",
|
| 140 |
+
lambda token: [
|
| 141 |
+
{"id": 7, "name": "Team Pool", "platform": "openai"},
|
| 142 |
+
],
|
| 143 |
+
)
|
| 144 |
+
monkeypatch.setattr(
|
| 145 |
+
sub2api_sync,
|
| 146 |
+
"_get_group_by_id",
|
| 147 |
+
lambda token, group_id: {"id": group_id, "name": f"Group-{group_id}", "platform": "openai"},
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
group_ids, group_names = sub2api_sync._resolve_group_binding("token", "Team Pool, 9")
|
| 151 |
+
|
| 152 |
+
assert group_ids == [7, 9]
|
| 153 |
+
assert group_names == ["Team Pool", "Group-9"]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_resolve_proxy_id_supports_name_and_id(monkeypatch):
|
| 157 |
+
monkeypatch.setattr(
|
| 158 |
+
sub2api_sync,
|
| 159 |
+
"_list_proxies",
|
| 160 |
+
lambda token: [
|
| 161 |
+
{"id": 42, "name": "Residential Pool"},
|
| 162 |
+
],
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
assert sub2api_sync._resolve_proxy_id("token", "") is None
|
| 166 |
+
assert sub2api_sync._resolve_proxy_id("token", "42") == 42
|
| 167 |
+
assert sub2api_sync._resolve_proxy_id("token", "residential pool") == 42
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@pytest.mark.parametrize("proxy_spec", ["0", "-1"])
|
| 171 |
+
def test_resolve_proxy_id_rejects_invalid_numeric_values(proxy_spec):
|
| 172 |
+
with pytest.raises(RuntimeError, match="代理 ID 必须是正整数"):
|
| 173 |
+
sub2api_sync._resolve_proxy_id("token", proxy_spec)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_resolve_proxy_id_reports_unknown_name(monkeypatch):
|
| 177 |
+
monkeypatch.setattr(sub2api_sync, "_list_proxies", lambda token: [{"id": 42, "name": "Residential Pool"}])
|
| 178 |
+
|
| 179 |
+
with pytest.raises(RuntimeError, match="未找到代理"):
|
| 180 |
+
sub2api_sync._resolve_proxy_id("token", "Missing Proxy")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_create_account_includes_proxy_id_only_when_provided(monkeypatch):
|
| 184 |
+
payloads = []
|
| 185 |
+
|
| 186 |
+
def fake_request(method, path, **kwargs):
|
| 187 |
+
payloads.append(kwargs["json"])
|
| 188 |
+
return {"id": len(payloads)}
|
| 189 |
+
|
| 190 |
+
monkeypatch.setattr(sub2api_sync, "_request", fake_request)
|
| 191 |
+
|
| 192 |
+
sub2api_sync._create_account(
|
| 193 |
+
"token",
|
| 194 |
+
name="with-proxy",
|
| 195 |
+
credentials={"access_token": "at-1"},
|
| 196 |
+
extra={},
|
| 197 |
+
label="创建账号",
|
| 198 |
+
proxy_id=42,
|
| 199 |
+
)
|
| 200 |
+
sub2api_sync._create_account(
|
| 201 |
+
"token",
|
| 202 |
+
name="without-proxy",
|
| 203 |
+
credentials={"access_token": "at-1"},
|
| 204 |
+
extra={},
|
| 205 |
+
label="创建账号",
|
| 206 |
+
proxy_id=None,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
assert payloads[0]["proxy_id"] == 42
|
| 210 |
+
assert "proxy_id" not in payloads[1]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_merge_group_ids_preserves_manual_groups_and_replaces_previous_managed_group():
|
| 214 |
+
account = {
|
| 215 |
+
"group_ids": [11, 21],
|
| 216 |
+
"extra": {
|
| 217 |
+
"autoteam_sub2api_group_ids": [21],
|
| 218 |
+
},
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
assert sub2api_sync._merge_group_ids(account, [22]) == [11, 22]
|
| 222 |
+
assert sub2api_sync._merge_group_ids(account, []) == [11]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_remote_auth_file_candidates_include_legacy_and_prefixed_names():
|
| 226 |
+
assert sub2api_sync._remote_auth_file_candidates(["codex-a.json"]) == {
|
| 227 |
+
"codex-a.json",
|
| 228 |
+
"sub2api-codex-a.json",
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_sync_account_to_sub2api_creates_single_account_without_remote_cleanup(monkeypatch, tmp_path):
|
| 233 |
+
auth_file = tmp_path / "codex-tmp@example.com-team-abc.json"
|
| 234 |
+
auth_file.write_text(json.dumps({"email": "tmp@example.com", "access_token": "at-1"}), encoding="utf-8")
|
| 235 |
+
created_payloads = []
|
| 236 |
+
deleted = []
|
| 237 |
+
|
| 238 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 239 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([7], ["Team Pool"]))
|
| 240 |
+
monkeypatch.setattr(
|
| 241 |
+
sub2api_sync,
|
| 242 |
+
"_list_openai_oauth_accounts",
|
| 243 |
+
lambda token: [
|
| 244 |
+
{
|
| 245 |
+
"id": 99,
|
| 246 |
+
"name": "other@example.com",
|
| 247 |
+
"extra": {
|
| 248 |
+
"autoteam_source": "autoteam",
|
| 249 |
+
"autoteam_kind": "pool",
|
| 250 |
+
"autoteam_email": "other@example.com",
|
| 251 |
+
},
|
| 252 |
+
}
|
| 253 |
+
],
|
| 254 |
+
)
|
| 255 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_proxy_id", lambda token: 42)
|
| 256 |
+
monkeypatch.setattr(sub2api_sync, "_delete_account", lambda *args, **kwargs: deleted.append((args, kwargs)) or True)
|
| 257 |
+
monkeypatch.setattr(
|
| 258 |
+
sub2api_sync,
|
| 259 |
+
"_create_account",
|
| 260 |
+
lambda token, **kwargs: created_payloads.append(kwargs) or {"id": 12},
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
result = sub2api_sync.sync_account_to_sub2api("tmp@example.com", str(auth_file), quota_info={"primary_pct": 12})
|
| 264 |
+
|
| 265 |
+
assert result == {
|
| 266 |
+
"uploaded": f"sub2api-{auth_file.name}",
|
| 267 |
+
"action": "created",
|
| 268 |
+
"account_id": 12,
|
| 269 |
+
"remote_duplicates_seen": 0,
|
| 270 |
+
}
|
| 271 |
+
assert deleted == []
|
| 272 |
+
assert created_payloads[0]["name"] == "tmp@example.com"
|
| 273 |
+
assert created_payloads[0]["credentials"]["access_token"] == "at-1"
|
| 274 |
+
assert created_payloads[0]["extra"]["autoteam_email"] == "tmp@example.com"
|
| 275 |
+
assert created_payloads[0]["extra"]["codex_5h_used_percent"] == 12
|
| 276 |
+
assert created_payloads[0]["group_ids"] == [7]
|
| 277 |
+
assert created_payloads[0]["proxy_id"] == 42
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def test_sync_to_sub2api_preserves_existing_manual_settings_when_overwrite_disabled(monkeypatch, tmp_path):
|
| 281 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OVERWRITE_ACCOUNT_SETTINGS", False)
|
| 282 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PROXY", "Residential Pool")
|
| 283 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 284 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([7], ["Team Pool"]))
|
| 285 |
+
monkeypatch.setattr(
|
| 286 |
+
sub2api_sync,
|
| 287 |
+
"_resolve_proxy_id",
|
| 288 |
+
lambda token: (_ for _ in ()).throw(AssertionError("proxy should not be resolved without create")),
|
| 289 |
+
)
|
| 290 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 291 |
+
monkeypatch.setattr(
|
| 292 |
+
sub2api_sync,
|
| 293 |
+
"_dedupe_managed_accounts",
|
| 294 |
+
lambda token, items, *, kind: (
|
| 295 |
+
{
|
| 296 |
+
"tmp@example.com": {
|
| 297 |
+
"id": 12,
|
| 298 |
+
"status": "disabled",
|
| 299 |
+
"credentials": {"model_mapping": {"manual-model": "manual-model"}},
|
| 300 |
+
"extra": {
|
| 301 |
+
"openai_oauth_responses_websockets_v2_mode": "passthrough",
|
| 302 |
+
"openai_oauth_responses_websockets_v2_enabled": True,
|
| 303 |
+
"openai_passthrough": True,
|
| 304 |
+
},
|
| 305 |
+
"group_ids": [99, 21],
|
| 306 |
+
}
|
| 307 |
+
},
|
| 308 |
+
0,
|
| 309 |
+
),
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
auth_path = tmp_path / "codex-tmp@example.com-team-123.json"
|
| 313 |
+
auth_path.write_text("{}", encoding="utf-8")
|
| 314 |
+
|
| 315 |
+
monkeypatch.setattr(
|
| 316 |
+
"autoteam.accounts.load_accounts",
|
| 317 |
+
lambda: [
|
| 318 |
+
{
|
| 319 |
+
"email": "tmp@example.com",
|
| 320 |
+
"status": "active",
|
| 321 |
+
"auth_file": str(auth_path),
|
| 322 |
+
"last_quota": {"primary_pct": 10, "weekly_pct": 20},
|
| 323 |
+
}
|
| 324 |
+
],
|
| 325 |
+
)
|
| 326 |
+
monkeypatch.setattr(
|
| 327 |
+
sub2api_sync,
|
| 328 |
+
"_load_auth_data",
|
| 329 |
+
lambda path: {
|
| 330 |
+
"email": "tmp@example.com",
|
| 331 |
+
"access_token": "at-1",
|
| 332 |
+
"refresh_token": "rt-1",
|
| 333 |
+
},
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
captured = {}
|
| 337 |
+
|
| 338 |
+
def fake_update_account(token, account, **kwargs):
|
| 339 |
+
captured.update(kwargs)
|
| 340 |
+
return {"ok": True}
|
| 341 |
+
|
| 342 |
+
monkeypatch.setattr(sub2api_sync, "_update_account", fake_update_account)
|
| 343 |
+
|
| 344 |
+
sub2api_sync.sync_to_sub2api()
|
| 345 |
+
|
| 346 |
+
assert captured["credentials"]["model_mapping"] == {"manual-model": "manual-model"}
|
| 347 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_mode"] == "passthrough"
|
| 348 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_enabled"] is True
|
| 349 |
+
assert captured["extra"]["openai_passthrough"] is True
|
| 350 |
+
assert captured["account_settings"] is None
|
| 351 |
+
assert "proxy_id" not in captured
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def test_sync_to_sub2api_overwrites_managed_settings_when_enabled(monkeypatch, tmp_path):
|
| 355 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OVERWRITE_ACCOUNT_SETTINGS", True)
|
| 356 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_CONCURRENCY", 12)
|
| 357 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PRIORITY", 3)
|
| 358 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_RATE_MULTIPLIER", 1.5)
|
| 359 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_AUTO_PAUSE_ON_EXPIRED", False)
|
| 360 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_WS_MODE", "ctx_pool")
|
| 361 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_PASSTHROUGH", False)
|
| 362 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_MODEL_WHITELIST", "gpt-5.4,gpt-5.4-mini")
|
| 363 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 364 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([], []))
|
| 365 |
+
monkeypatch.setattr(
|
| 366 |
+
sub2api_sync,
|
| 367 |
+
"_resolve_proxy_id",
|
| 368 |
+
lambda token: (_ for _ in ()).throw(AssertionError("proxy should not be resolved without create")),
|
| 369 |
+
)
|
| 370 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 371 |
+
monkeypatch.setattr(
|
| 372 |
+
sub2api_sync,
|
| 373 |
+
"_dedupe_managed_accounts",
|
| 374 |
+
lambda token, items, *, kind: (
|
| 375 |
+
{
|
| 376 |
+
"tmp@example.com": {
|
| 377 |
+
"id": 12,
|
| 378 |
+
"status": "disabled",
|
| 379 |
+
"credentials": {"model_mapping": {"manual-model": "manual-model"}},
|
| 380 |
+
"extra": {
|
| 381 |
+
"openai_oauth_responses_websockets_v2_mode": "off",
|
| 382 |
+
"openai_oauth_responses_websockets_v2_enabled": False,
|
| 383 |
+
"openai_passthrough": True,
|
| 384 |
+
"openai_oauth_passthrough": True,
|
| 385 |
+
},
|
| 386 |
+
"group_ids": [],
|
| 387 |
+
}
|
| 388 |
+
},
|
| 389 |
+
0,
|
| 390 |
+
),
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
auth_path = tmp_path / "codex-tmp@example.com-team-123.json"
|
| 394 |
+
auth_path.write_text("{}", encoding="utf-8")
|
| 395 |
+
|
| 396 |
+
monkeypatch.setattr(
|
| 397 |
+
"autoteam.accounts.load_accounts",
|
| 398 |
+
lambda: [
|
| 399 |
+
{
|
| 400 |
+
"email": "tmp@example.com",
|
| 401 |
+
"status": "active",
|
| 402 |
+
"auth_file": str(auth_path),
|
| 403 |
+
"last_quota": None,
|
| 404 |
+
}
|
| 405 |
+
],
|
| 406 |
+
)
|
| 407 |
+
monkeypatch.setattr(
|
| 408 |
+
sub2api_sync,
|
| 409 |
+
"_load_auth_data",
|
| 410 |
+
lambda path: {
|
| 411 |
+
"email": "tmp@example.com",
|
| 412 |
+
"access_token": "at-1",
|
| 413 |
+
"refresh_token": "rt-1",
|
| 414 |
+
},
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
captured = {}
|
| 418 |
+
|
| 419 |
+
def fake_update_account(token, account, **kwargs):
|
| 420 |
+
captured.update(kwargs)
|
| 421 |
+
return {"ok": True}
|
| 422 |
+
|
| 423 |
+
monkeypatch.setattr(sub2api_sync, "_update_account", fake_update_account)
|
| 424 |
+
|
| 425 |
+
sub2api_sync.sync_to_sub2api()
|
| 426 |
+
|
| 427 |
+
assert captured["account_settings"] == {
|
| 428 |
+
"concurrency": 12,
|
| 429 |
+
"priority": 3,
|
| 430 |
+
"rate_multiplier": 1.5,
|
| 431 |
+
"auto_pause_on_expired": False,
|
| 432 |
+
}
|
| 433 |
+
assert captured["credentials"]["model_mapping"] == {
|
| 434 |
+
"gpt-5.4": "gpt-5.4",
|
| 435 |
+
"gpt-5.4-mini": "gpt-5.4-mini",
|
| 436 |
+
}
|
| 437 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_mode"] == "ctx_pool"
|
| 438 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_enabled"] is True
|
| 439 |
+
assert "openai_passthrough" not in captured["extra"]
|
| 440 |
+
assert "openai_oauth_passthrough" not in captured["extra"]
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def test_sync_to_sub2api_does_not_resolve_proxy_when_only_deleting_non_active_accounts(monkeypatch):
|
| 444 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PROXY", "Missing Proxy")
|
| 445 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 446 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([], []))
|
| 447 |
+
monkeypatch.setattr(
|
| 448 |
+
sub2api_sync,
|
| 449 |
+
"_resolve_proxy_id",
|
| 450 |
+
lambda token: (_ for _ in ()).throw(AssertionError("proxy should not be resolved without create")),
|
| 451 |
+
)
|
| 452 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 453 |
+
monkeypatch.setattr(
|
| 454 |
+
"autoteam.accounts.load_accounts",
|
| 455 |
+
lambda: [
|
| 456 |
+
{
|
| 457 |
+
"email": "tmp@example.com",
|
| 458 |
+
"status": "standby",
|
| 459 |
+
"auth_file": "",
|
| 460 |
+
"last_quota": None,
|
| 461 |
+
}
|
| 462 |
+
],
|
| 463 |
+
)
|
| 464 |
+
monkeypatch.setattr(
|
| 465 |
+
sub2api_sync,
|
| 466 |
+
"_dedupe_managed_accounts",
|
| 467 |
+
lambda token, items, *, kind: (
|
| 468 |
+
{
|
| 469 |
+
"tmp@example.com": {
|
| 470 |
+
"id": 12,
|
| 471 |
+
"status": "active",
|
| 472 |
+
"credentials": {"email": "tmp@example.com"},
|
| 473 |
+
"extra": {},
|
| 474 |
+
"group_ids": [],
|
| 475 |
+
}
|
| 476 |
+
},
|
| 477 |
+
0,
|
| 478 |
+
),
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
deleted = []
|
| 482 |
+
|
| 483 |
+
def fake_delete_account(token, account, **kwargs):
|
| 484 |
+
deleted.append((account["id"], kwargs["label"]))
|
| 485 |
+
return {"ok": True}
|
| 486 |
+
|
| 487 |
+
monkeypatch.setattr(sub2api_sync, "_delete_account", fake_delete_account)
|
| 488 |
+
|
| 489 |
+
result = sub2api_sync.sync_to_sub2api()
|
| 490 |
+
|
| 491 |
+
assert result["deleted"] == 1
|
| 492 |
+
assert deleted == [(12, "删除非 active 账号")]
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def test_sync_to_sub2api_skips_disabled_local_accounts(monkeypatch, tmp_path):
|
| 496 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 497 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([], []))
|
| 498 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 499 |
+
monkeypatch.setattr(
|
| 500 |
+
sub2api_sync,
|
| 501 |
+
"_dedupe_managed_accounts",
|
| 502 |
+
lambda token, items, *, kind: (
|
| 503 |
+
{
|
| 504 |
+
"disabled@example.com": {
|
| 505 |
+
"id": 12,
|
| 506 |
+
"status": "active",
|
| 507 |
+
"credentials": {"email": "disabled@example.com"},
|
| 508 |
+
"extra": {},
|
| 509 |
+
"group_ids": [],
|
| 510 |
+
}
|
| 511 |
+
},
|
| 512 |
+
0,
|
| 513 |
+
),
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
enabled_auth = tmp_path / "codex-enabled@example.com-team-a.json"
|
| 517 |
+
disabled_auth = tmp_path / "codex-disabled@example.com-team-b.json"
|
| 518 |
+
enabled_auth.write_text("{}", encoding="utf-8")
|
| 519 |
+
disabled_auth.write_text("{}", encoding="utf-8")
|
| 520 |
+
|
| 521 |
+
monkeypatch.setattr(
|
| 522 |
+
"autoteam.accounts.load_accounts",
|
| 523 |
+
lambda: [
|
| 524 |
+
{"email": "enabled@example.com", "status": "active", "auth_file": str(enabled_auth), "disabled": False},
|
| 525 |
+
{"email": "disabled@example.com", "status": "active", "auth_file": str(disabled_auth), "disabled": True},
|
| 526 |
+
],
|
| 527 |
+
)
|
| 528 |
+
monkeypatch.setattr(
|
| 529 |
+
sub2api_sync,
|
| 530 |
+
"_load_auth_data",
|
| 531 |
+
lambda path: {
|
| 532 |
+
"email": "enabled@example.com" if "enabled@" in str(path) else "disabled@example.com",
|
| 533 |
+
"access_token": "at-1",
|
| 534 |
+
"refresh_token": "rt-1",
|
| 535 |
+
},
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
created = []
|
| 539 |
+
updated = []
|
| 540 |
+
deleted = []
|
| 541 |
+
monkeypatch.setattr(
|
| 542 |
+
sub2api_sync,
|
| 543 |
+
"_create_account",
|
| 544 |
+
lambda token, **kwargs: created.append(kwargs["name"]) or {"id": 99},
|
| 545 |
+
)
|
| 546 |
+
monkeypatch.setattr(
|
| 547 |
+
sub2api_sync,
|
| 548 |
+
"_update_account",
|
| 549 |
+
lambda token, account, **kwargs: updated.append(account["id"]) or {"ok": True},
|
| 550 |
+
)
|
| 551 |
+
monkeypatch.setattr(
|
| 552 |
+
sub2api_sync,
|
| 553 |
+
"_delete_account",
|
| 554 |
+
lambda token, account, **kwargs: deleted.append(account["id"]) or {"ok": True},
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
result = sub2api_sync.sync_to_sub2api()
|
| 558 |
+
|
| 559 |
+
assert result["created"] == 1
|
| 560 |
+
assert result["updated"] == 0
|
| 561 |
+
assert result["deleted"] == 1
|
| 562 |
+
assert created == ["enabled@example.com"]
|
| 563 |
+
assert updated == []
|
| 564 |
+
assert deleted == [12]
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def test_sync_to_sub2api_resolves_proxy_name_for_new_pool_accounts(monkeypatch, tmp_path):
|
| 568 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PROXY", "Residential Pool")
|
| 569 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 570 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([], []))
|
| 571 |
+
monkeypatch.setattr(sub2api_sync, "_list_proxies", lambda token: [{"id": 42, "name": "Residential Pool"}])
|
| 572 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 573 |
+
monkeypatch.setattr(sub2api_sync, "_dedupe_managed_accounts", lambda token, items, *, kind: ({}, 0))
|
| 574 |
+
|
| 575 |
+
auth_path = tmp_path / "codex-tmp@example.com-team-123.json"
|
| 576 |
+
auth_path.write_text("{}", encoding="utf-8")
|
| 577 |
+
monkeypatch.setattr(
|
| 578 |
+
"autoteam.accounts.load_accounts",
|
| 579 |
+
lambda: [
|
| 580 |
+
{
|
| 581 |
+
"email": "tmp@example.com",
|
| 582 |
+
"status": "active",
|
| 583 |
+
"auth_file": str(auth_path),
|
| 584 |
+
"last_quota": None,
|
| 585 |
+
}
|
| 586 |
+
],
|
| 587 |
+
)
|
| 588 |
+
monkeypatch.setattr(
|
| 589 |
+
sub2api_sync,
|
| 590 |
+
"_load_auth_data",
|
| 591 |
+
lambda path: {
|
| 592 |
+
"email": "tmp@example.com",
|
| 593 |
+
"access_token": "at-1",
|
| 594 |
+
"refresh_token": "rt-1",
|
| 595 |
+
},
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
captured = {}
|
| 599 |
+
|
| 600 |
+
def fake_create_account(token, **kwargs):
|
| 601 |
+
captured.update(kwargs)
|
| 602 |
+
return {"id": 99}
|
| 603 |
+
|
| 604 |
+
monkeypatch.setattr(sub2api_sync, "_create_account", fake_create_account)
|
| 605 |
+
|
| 606 |
+
result = sub2api_sync.sync_to_sub2api()
|
| 607 |
+
|
| 608 |
+
assert result["created"] == 1
|
| 609 |
+
assert captured["proxy_id"] == 42
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def test_sync_main_codex_to_sub2api_creates_account_with_managed_defaults(monkeypatch, tmp_path):
|
| 613 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PROXY", "Residential Pool")
|
| 614 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_CONCURRENCY", 8)
|
| 615 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_PRIORITY", 2)
|
| 616 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_RATE_MULTIPLIER", 2.5)
|
| 617 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_AUTO_PAUSE_ON_EXPIRED", True)
|
| 618 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_MODEL_WHITELIST", "gpt-5.4")
|
| 619 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_WS_MODE", "passthrough")
|
| 620 |
+
monkeypatch.setattr(sub2api_sync, "SUB2API_OPENAI_PASSTHROUGH", True)
|
| 621 |
+
monkeypatch.setattr(sub2api_sync, "_login", lambda: "token")
|
| 622 |
+
monkeypatch.setattr(sub2api_sync, "_resolve_group_binding", lambda token: ([7], ["Team Pool"]))
|
| 623 |
+
monkeypatch.setattr(sub2api_sync, "_list_openai_oauth_accounts", lambda token: [])
|
| 624 |
+
monkeypatch.setattr(sub2api_sync, "_dedupe_managed_accounts", lambda token, items, *, kind: ({}, 0))
|
| 625 |
+
|
| 626 |
+
auth_path = tmp_path / "main.json"
|
| 627 |
+
auth_path.write_text("{}", encoding="utf-8")
|
| 628 |
+
monkeypatch.setattr(
|
| 629 |
+
sub2api_sync,
|
| 630 |
+
"_load_auth_data",
|
| 631 |
+
lambda path: {
|
| 632 |
+
"email": "main@example.com",
|
| 633 |
+
"access_token": "at-1",
|
| 634 |
+
"refresh_token": "rt-1",
|
| 635 |
+
},
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
captured = {}
|
| 639 |
+
|
| 640 |
+
def fake_create_account(token, **kwargs):
|
| 641 |
+
captured.update(kwargs)
|
| 642 |
+
return {"id": 99}
|
| 643 |
+
|
| 644 |
+
monkeypatch.setattr(sub2api_sync, "_create_account", fake_create_account)
|
| 645 |
+
|
| 646 |
+
result = sub2api_sync.sync_main_codex_to_sub2api(str(auth_path))
|
| 647 |
+
|
| 648 |
+
assert result["account_id"] == 99
|
| 649 |
+
assert captured["account_settings"] == {
|
| 650 |
+
"concurrency": 8,
|
| 651 |
+
"priority": 2,
|
| 652 |
+
"rate_multiplier": 2.5,
|
| 653 |
+
"auto_pause_on_expired": True,
|
| 654 |
+
}
|
| 655 |
+
assert captured["credentials"]["model_mapping"] == {"gpt-5.4": "gpt-5.4"}
|
| 656 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_mode"] == "passthrough"
|
| 657 |
+
assert captured["extra"]["openai_oauth_responses_websockets_v2_enabled"] is True
|
| 658 |
+
assert captured["extra"]["openai_passthrough"] is True
|
| 659 |
+
assert "proxy_id" not in captured
|
tests/unit/test_sync_targets.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from autoteam import sync_targets
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_get_sync_target_states_uses_implicit_config_presence():
|
| 7 |
+
env = {
|
| 8 |
+
"CPA_URL": "http://127.0.0.1:8317",
|
| 9 |
+
"CPA_KEY": "key-1",
|
| 10 |
+
"SUB2API_URL": "http://sub2api.local",
|
| 11 |
+
"SUB2API_EMAIL": "admin@example.com",
|
| 12 |
+
"SUB2API_PASSWORD": "secret",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
assert sync_targets.get_sync_target_states(env) == {
|
| 16 |
+
"cpa": True,
|
| 17 |
+
"sub2api": True,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_get_sync_target_states_respects_explicit_toggle_override():
|
| 22 |
+
env = {
|
| 23 |
+
"SYNC_TARGET_CPA": "false",
|
| 24 |
+
"CPA_URL": "http://127.0.0.1:8317",
|
| 25 |
+
"CPA_KEY": "key-1",
|
| 26 |
+
"SYNC_TARGET_SUB2API": "true",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
assert sync_targets.get_sync_target_states(env) == {
|
| 30 |
+
"cpa": False,
|
| 31 |
+
"sub2api": True,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_describe_sync_targets_formats_labels():
|
| 36 |
+
assert sync_targets.describe_sync_targets(["cpa"]) == "CPA"
|
| 37 |
+
assert sync_targets.describe_sync_targets(["cpa", "sub2api"]) == "CPA + Sub2API"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_sync_account_to_configured_targets_uploads_one_active_auth(monkeypatch, tmp_path):
|
| 41 |
+
auth_file = tmp_path / "codex-user@example.com-team-a.json"
|
| 42 |
+
auth_file.write_text('{"access_token":"token"}', encoding="utf-8")
|
| 43 |
+
|
| 44 |
+
monkeypatch.setattr(
|
| 45 |
+
"autoteam.accounts.load_accounts",
|
| 46 |
+
lambda: [
|
| 47 |
+
{
|
| 48 |
+
"email": "user@example.com",
|
| 49 |
+
"status": "active",
|
| 50 |
+
"auth_file": str(auth_file),
|
| 51 |
+
"disabled": False,
|
| 52 |
+
"last_quota": {"primary_pct": 12},
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
)
|
| 56 |
+
monkeypatch.setattr(
|
| 57 |
+
sync_targets,
|
| 58 |
+
"get_enabled_sync_targets",
|
| 59 |
+
lambda: [sync_targets.SYNC_TARGET_CPA, sync_targets.SYNC_TARGET_SUB2API],
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
cpa_uploads = []
|
| 63 |
+
sub2_uploads = []
|
| 64 |
+
monkeypatch.setattr("autoteam.cpa_sync.upload_to_cpa", lambda path: cpa_uploads.append(Path(path).name) or True)
|
| 65 |
+
monkeypatch.setattr(
|
| 66 |
+
"autoteam.sub2api_sync.sync_account_to_sub2api",
|
| 67 |
+
lambda email, filepath, *, quota_info=None: sub2_uploads.append(
|
| 68 |
+
(email, Path(filepath).name, quota_info)
|
| 69 |
+
)
|
| 70 |
+
or {"uploaded": f"sub2api-{Path(filepath).name}", "action": "created", "account_id": 12},
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
result = sync_targets.sync_account_to_configured_targets("USER@example.com", str(auth_file))
|
| 74 |
+
|
| 75 |
+
assert result["ok"] is True
|
| 76 |
+
assert cpa_uploads == [auth_file.name]
|
| 77 |
+
assert sub2_uploads == [("user@example.com", auth_file.name, {"primary_pct": 12})]
|
| 78 |
+
assert result["targets"]["cpa"]["uploaded"] == auth_file.name
|
| 79 |
+
assert result["targets"]["sub2api"]["uploaded"] == f"sub2api-{auth_file.name}"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_sync_account_to_configured_targets_skips_non_active_auth(monkeypatch, tmp_path):
|
| 83 |
+
auth_file = tmp_path / "codex-user@example.com-team-a.json"
|
| 84 |
+
auth_file.write_text('{"access_token":"token"}', encoding="utf-8")
|
| 85 |
+
|
| 86 |
+
monkeypatch.setattr(
|
| 87 |
+
"autoteam.accounts.load_accounts",
|
| 88 |
+
lambda: [{"email": "user@example.com", "status": "standby", "auth_file": str(auth_file)}],
|
| 89 |
+
)
|
| 90 |
+
monkeypatch.setattr(
|
| 91 |
+
sync_targets,
|
| 92 |
+
"get_enabled_sync_targets",
|
| 93 |
+
lambda: [sync_targets.SYNC_TARGET_CPA, sync_targets.SYNC_TARGET_SUB2API],
|
| 94 |
+
)
|
| 95 |
+
monkeypatch.setattr(
|
| 96 |
+
"autoteam.cpa_sync.upload_to_cpa",
|
| 97 |
+
lambda _path: (_ for _ in ()).throw(AssertionError("inactive account must not upload to CPA")),
|
| 98 |
+
)
|
| 99 |
+
monkeypatch.setattr(
|
| 100 |
+
"autoteam.sub2api_sync.sync_account_to_sub2api",
|
| 101 |
+
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
| 102 |
+
AssertionError("inactive account must not upload to Sub2API")
|
| 103 |
+
),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
result = sync_targets.sync_account_to_configured_targets("user@example.com", str(auth_file))
|
| 107 |
+
|
| 108 |
+
assert result["ok"] is False
|
| 109 |
+
assert result["skipped"] is True
|
| 110 |
+
assert result["reason"] == "account_not_active"
|
web/src/App.vue
CHANGED
|
@@ -4,15 +4,13 @@
|
|
| 4 |
|
| 5 |
<!-- 登录页 -->
|
| 6 |
<div v-else-if="!authenticated" class="min-h-screen flex items-center justify-center px-4">
|
| 7 |
-
<div class="glass rounded-
|
| 8 |
-
<div
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
<
|
| 12 |
-
<h1 class="text-2xl font-extrabold text-white mb-1 tracking-tight">AutoTeam</h1>
|
| 13 |
-
<p class="text-sm text-gray-400 mb-6">输入管理 API Key 进入控制台</p>
|
| 14 |
<div v-if="authError"
|
| 15 |
-
class="mb-4 px-3 py-2.5 rounded-lg text-sm bg-rose-
|
| 16 |
{{ authError }}
|
| 17 |
</div>
|
| 18 |
<input
|
|
@@ -20,8 +18,8 @@
|
|
| 20 |
type="password"
|
| 21 |
placeholder="API Key"
|
| 22 |
@keyup.enter="doLogin"
|
| 23 |
-
class="w-full px-3.5 py-2.5 bg-
|
| 24 |
-
font-mono placeholder:text-
|
| 25 |
<AtButton variant="primary" class="w-full" :loading="authLoading" :disabled="!inputKey" @click="doLogin">
|
| 26 |
{{ authLoading ? '验证中…' : '进入控制台' }}
|
| 27 |
</AtButton>
|
|
@@ -39,8 +37,8 @@
|
|
| 39 |
<div class="flex-1 p-4 md:p-6 overflow-y-auto pb-20 md:pb-6 max-w-screen-2xl mx-auto w-full">
|
| 40 |
<!-- 任务执行中提示 -->
|
| 41 |
<div v-if="busyTask"
|
| 42 |
-
class="flex items-center gap-2.5 text-sm text-amber-
|
| 43 |
-
<span class="animate-spin inline-block w-3.5 h-3.5 border-2 border-amber-
|
| 44 |
<span class="font-medium">
|
| 45 |
{{ busyTask.command === 'admin-login'
|
| 46 |
? '管理员登录中...'
|
|
@@ -54,20 +52,24 @@
|
|
| 54 |
<Transition name="page" mode="out-in">
|
| 55 |
<Dashboard v-if="currentPage === 'dashboard'" key="dashboard"
|
| 56 |
:status="status" :loading="loading" :running-task="busyTask" :admin-status="adminStatus"
|
| 57 |
-
:
|
|
|
|
|
|
|
| 58 |
|
| 59 |
<TeamMembers v-else-if="currentPage === 'team'" key="team" />
|
| 60 |
|
| 61 |
<PoolPage v-else-if="currentPage === 'pool'" key="pool"
|
| 62 |
:running-task="busyTask" :admin-status="adminStatus" :master-health="masterHealth" :status="status"
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
<SyncPage v-else-if="currentPage === 'sync'" key="sync"
|
| 66 |
:running-task="busyTask" :admin-status="adminStatus"
|
| 67 |
-
|
|
|
|
| 68 |
|
| 69 |
<OAuthPage v-else-if="currentPage === 'oauth'" key="oauth"
|
| 70 |
-
:manual-account-status="manualAccountStatus" @refresh="
|
| 71 |
|
| 72 |
<TaskHistoryPage v-else-if="currentPage === 'tasks'" key="tasks"
|
| 73 |
:tasks="tasks" />
|
|
@@ -77,7 +79,7 @@
|
|
| 77 |
<Settings v-else-if="currentPage === 'settings'" key="settings"
|
| 78 |
:admin-status="adminStatus" :codex-status="codexStatus"
|
| 79 |
:master-health="masterHealth" :status="status"
|
| 80 |
-
@refresh="
|
| 81 |
</Transition>
|
| 82 |
</div>
|
| 83 |
|
|
@@ -86,8 +88,9 @@
|
|
| 86 |
</template>
|
| 87 |
|
| 88 |
<script setup>
|
| 89 |
-
import {
|
| 90 |
import { api, setApiKey, clearApiKey } from './api.js'
|
|
|
|
| 91 |
import SetupPage from './components/SetupPage.vue'
|
| 92 |
import Sidebar from './components/Sidebar.vue'
|
| 93 |
import Dashboard from './components/Dashboard.vue'
|
|
@@ -109,27 +112,23 @@ const authError = ref('')
|
|
| 109 |
const inputKey = ref('')
|
| 110 |
const currentPage = ref('dashboard')
|
| 111 |
|
| 112 |
-
const
|
| 113 |
-
const
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
// Round 9 — master-health 提到 App 级,4 个页面共享同一份(避免每页各刷各的)
|
| 120 |
const masterHealth = ref(null)
|
| 121 |
const masterHealthLoading = ref(false)
|
| 122 |
-
const busyTask = computed(() => {
|
| 123 |
-
if (adminStatus.value?.login_in_progress) {
|
| 124 |
-
return { command: 'admin-login' }
|
| 125 |
-
}
|
| 126 |
-
if (codexStatus.value?.in_progress) {
|
| 127 |
-
return { command: 'main-codex-sync' }
|
| 128 |
-
}
|
| 129 |
-
return runningTask.value
|
| 130 |
-
})
|
| 131 |
-
|
| 132 |
-
let pollTimer = null
|
| 133 |
|
| 134 |
async function checkAuth() {
|
| 135 |
try {
|
|
@@ -161,7 +160,6 @@ async function doLogin() {
|
|
| 161 |
} else {
|
| 162 |
inputKey.value = ''
|
| 163 |
refresh()
|
| 164 |
-
startPolling(600000)
|
| 165 |
}
|
| 166 |
} catch (e) {
|
| 167 |
clearApiKey()
|
|
@@ -174,34 +172,10 @@ async function doLogin() {
|
|
| 174 |
function doLogout() {
|
| 175 |
clearApiKey()
|
| 176 |
authenticated.value = false
|
| 177 |
-
stopPolling()
|
| 178 |
}
|
| 179 |
|
| 180 |
async function refresh() {
|
| 181 |
-
|
| 182 |
-
try {
|
| 183 |
-
const [s, t, admin, codex, manualAccount] = await Promise.all([
|
| 184 |
-
api.getStatus(),
|
| 185 |
-
api.getTasks(),
|
| 186 |
-
api.getAdminStatus(),
|
| 187 |
-
api.getMainCodexStatus(),
|
| 188 |
-
api.getManualAccountStatus(),
|
| 189 |
-
])
|
| 190 |
-
status.value = s
|
| 191 |
-
tasks.value = t
|
| 192 |
-
adminStatus.value = admin
|
| 193 |
-
codexStatus.value = codex
|
| 194 |
-
manualAccountStatus.value = manualAccount
|
| 195 |
-
runningTask.value = t.find(t => t.status === 'running' || t.status === 'pending') || null
|
| 196 |
-
} catch (e) {
|
| 197 |
-
if (e.status === 401) {
|
| 198 |
-
authenticated.value = false
|
| 199 |
-
return
|
| 200 |
-
}
|
| 201 |
-
console.error('刷新失败:', e)
|
| 202 |
-
} finally {
|
| 203 |
-
loading.value = false
|
| 204 |
-
}
|
| 205 |
}
|
| 206 |
|
| 207 |
async function reloadMasterHealth(forceRefresh = false) {
|
|
@@ -210,37 +184,22 @@ async function reloadMasterHealth(forceRefresh = false) {
|
|
| 210 |
try {
|
| 211 |
masterHealth.value = await api.getMasterHealth(!!forceRefresh)
|
| 212 |
} catch (e) {
|
| 213 |
-
|
| 214 |
} finally {
|
| 215 |
masterHealthLoading.value = false
|
| 216 |
}
|
| 217 |
}
|
| 218 |
|
| 219 |
function onTaskStarted() {
|
| 220 |
-
|
| 221 |
-
refresh()
|
| 222 |
}
|
| 223 |
|
| 224 |
function onAdminProgress() {
|
| 225 |
-
|
| 226 |
-
refresh()
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
function startPolling(interval = 600000) {
|
| 230 |
-
stopPolling()
|
| 231 |
-
pollTimer = setInterval(async () => {
|
| 232 |
-
await refresh()
|
| 233 |
-
if (!busyTask.value && interval < 600000) {
|
| 234 |
-
startPolling(600000)
|
| 235 |
-
}
|
| 236 |
-
}, interval)
|
| 237 |
}
|
| 238 |
|
| 239 |
-
function
|
| 240 |
-
|
| 241 |
-
clearInterval(pollTimer)
|
| 242 |
-
pollTimer = null
|
| 243 |
-
}
|
| 244 |
}
|
| 245 |
|
| 246 |
async function checkSetup() {
|
|
@@ -257,7 +216,6 @@ function onSetupDone() {
|
|
| 257 |
checkAuth().then(ok => {
|
| 258 |
if (ok) {
|
| 259 |
refresh()
|
| 260 |
-
startPolling(600000)
|
| 261 |
}
|
| 262 |
})
|
| 263 |
}
|
|
@@ -279,11 +237,6 @@ onMounted(async () => {
|
|
| 279 |
const ok = await checkAuth()
|
| 280 |
if (ok) {
|
| 281 |
refresh()
|
| 282 |
-
startPolling(600000)
|
| 283 |
}
|
| 284 |
})
|
| 285 |
-
|
| 286 |
-
onUnmounted(() => {
|
| 287 |
-
stopPolling()
|
| 288 |
-
})
|
| 289 |
</script>
|
|
|
|
| 4 |
|
| 5 |
<!-- 登录页 -->
|
| 6 |
<div v-else-if="!authenticated" class="min-h-screen flex items-center justify-center px-4">
|
| 7 |
+
<div class="glass rounded-lg p-8 w-full max-w-sm">
|
| 8 |
+
<div>
|
| 9 |
+
<div class="text-[10px] uppercase tracking-[0.3em] text-ink-400 mb-1">Account Operations</div>
|
| 10 |
+
<h1 class="text-2xl font-extrabold text-ink-950 mb-1 tracking-tight">AutoTeam</h1>
|
| 11 |
+
<p class="text-sm text-ink-500 mb-6">输入管理 API Key 进入控制台</p>
|
|
|
|
|
|
|
| 12 |
<div v-if="authError"
|
| 13 |
+
class="mb-4 px-3 py-2.5 rounded-lg text-sm bg-rose-50 text-rose-700 border border-rose-200">
|
| 14 |
{{ authError }}
|
| 15 |
</div>
|
| 16 |
<input
|
|
|
|
| 18 |
type="password"
|
| 19 |
placeholder="API Key"
|
| 20 |
@keyup.enter="doLogin"
|
| 21 |
+
class="w-full px-3.5 py-2.5 bg-surface border border-hairline rounded-lg text-sm text-ink-950
|
| 22 |
+
font-mono placeholder:text-ink-400 focus-ring mb-4 transition" />
|
| 23 |
<AtButton variant="primary" class="w-full" :loading="authLoading" :disabled="!inputKey" @click="doLogin">
|
| 24 |
{{ authLoading ? '验证中…' : '进入控制台' }}
|
| 25 |
</AtButton>
|
|
|
|
| 37 |
<div class="flex-1 p-4 md:p-6 overflow-y-auto pb-20 md:pb-6 max-w-screen-2xl mx-auto w-full">
|
| 38 |
<!-- 任务执行中提示 -->
|
| 39 |
<div v-if="busyTask"
|
| 40 |
+
class="flex items-center gap-2.5 text-sm text-amber-800 mb-4 px-3 py-2 rounded-lg border border-amber-200 bg-amber-50 w-fit animate-rise">
|
| 41 |
+
<span class="animate-spin inline-block w-3.5 h-3.5 border-2 border-amber-700 border-t-transparent rounded-full"></span>
|
| 42 |
<span class="font-medium">
|
| 43 |
{{ busyTask.command === 'admin-login'
|
| 44 |
? '管理员登录中...'
|
|
|
|
| 52 |
<Transition name="page" mode="out-in">
|
| 53 |
<Dashboard v-if="currentPage === 'dashboard'" key="dashboard"
|
| 54 |
:status="status" :loading="loading" :running-task="busyTask" :admin-status="adminStatus"
|
| 55 |
+
:register-failures="registerFailures" :register-failures-loading="registerFailuresLoading"
|
| 56 |
+
:register-failures-unavailable="registerFailuresUnavailable"
|
| 57 |
+
:master-health="masterHealth" @refresh="onActionRefresh" @reload-master-health="reloadMasterHealth" />
|
| 58 |
|
| 59 |
<TeamMembers v-else-if="currentPage === 'team'" key="team" />
|
| 60 |
|
| 61 |
<PoolPage v-else-if="currentPage === 'pool'" key="pool"
|
| 62 |
:running-task="busyTask" :admin-status="adminStatus" :master-health="masterHealth" :status="status"
|
| 63 |
+
:rotate-stream="rotateStream"
|
| 64 |
+
@task-started="onTaskStarted" @refresh="onActionRefresh" @reload-master-health="reloadMasterHealth" />
|
| 65 |
|
| 66 |
<SyncPage v-else-if="currentPage === 'sync'" key="sync"
|
| 67 |
:running-task="busyTask" :admin-status="adminStatus"
|
| 68 |
+
:rotate-stream="rotateStream"
|
| 69 |
+
@task-started="onTaskStarted" @refresh="onActionRefresh" />
|
| 70 |
|
| 71 |
<OAuthPage v-else-if="currentPage === 'oauth'" key="oauth"
|
| 72 |
+
:manual-account-status="manualAccountStatus" @refresh="onActionRefresh" @progress="onAdminProgress" />
|
| 73 |
|
| 74 |
<TaskHistoryPage v-else-if="currentPage === 'tasks'" key="tasks"
|
| 75 |
:tasks="tasks" />
|
|
|
|
| 79 |
<Settings v-else-if="currentPage === 'settings'" key="settings"
|
| 80 |
:admin-status="adminStatus" :codex-status="codexStatus"
|
| 81 |
:master-health="masterHealth" :status="status"
|
| 82 |
+
@refresh="onActionRefresh" @admin-progress="onAdminProgress" @reload-master-health="reloadMasterHealth" />
|
| 83 |
</Transition>
|
| 84 |
</div>
|
| 85 |
|
|
|
|
| 88 |
</template>
|
| 89 |
|
| 90 |
<script setup>
|
| 91 |
+
import { ref, onMounted, watch } from 'vue'
|
| 92 |
import { api, setApiKey, clearApiKey } from './api.js'
|
| 93 |
+
import { useAppState } from './composables/useAppState.js'
|
| 94 |
import SetupPage from './components/SetupPage.vue'
|
| 95 |
import Sidebar from './components/Sidebar.vue'
|
| 96 |
import Dashboard from './components/Dashboard.vue'
|
|
|
|
| 112 |
const inputKey = ref('')
|
| 113 |
const currentPage = ref('dashboard')
|
| 114 |
|
| 115 |
+
const appState = useAppState(authenticated)
|
| 116 |
+
const {
|
| 117 |
+
status,
|
| 118 |
+
adminStatus,
|
| 119 |
+
codexStatus,
|
| 120 |
+
manualAccountStatus,
|
| 121 |
+
registerFailures,
|
| 122 |
+
registerFailuresLoading,
|
| 123 |
+
registerFailuresUnavailable,
|
| 124 |
+
tasks,
|
| 125 |
+
loading,
|
| 126 |
+
busyTask,
|
| 127 |
+
rotateStream,
|
| 128 |
+
} = appState
|
| 129 |
// Round 9 — master-health 提到 App 级,4 个页面共享同一份(避免每页各刷各的)
|
| 130 |
const masterHealth = ref(null)
|
| 131 |
const masterHealthLoading = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
async function checkAuth() {
|
| 134 |
try {
|
|
|
|
| 160 |
} else {
|
| 161 |
inputKey.value = ''
|
| 162 |
refresh()
|
|
|
|
| 163 |
}
|
| 164 |
} catch (e) {
|
| 165 |
clearApiKey()
|
|
|
|
| 172 |
function doLogout() {
|
| 173 |
clearApiKey()
|
| 174 |
authenticated.value = false
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
async function refresh() {
|
| 178 |
+
await appState.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
|
| 181 |
async function reloadMasterHealth(forceRefresh = false) {
|
|
|
|
| 184 |
try {
|
| 185 |
masterHealth.value = await api.getMasterHealth(!!forceRefresh)
|
| 186 |
} catch (e) {
|
| 187 |
+
masterHealth.value = null
|
| 188 |
} finally {
|
| 189 |
masterHealthLoading.value = false
|
| 190 |
}
|
| 191 |
}
|
| 192 |
|
| 193 |
function onTaskStarted() {
|
| 194 |
+
appState.notifyActionStarted()
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
function onAdminProgress() {
|
| 198 |
+
appState.notifyActionStarted()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
}
|
| 200 |
|
| 201 |
+
function onActionRefresh() {
|
| 202 |
+
appState.notifyActionStarted()
|
|
|
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
async function checkSetup() {
|
|
|
|
| 216 |
checkAuth().then(ok => {
|
| 217 |
if (ok) {
|
| 218 |
refresh()
|
|
|
|
| 219 |
}
|
| 220 |
})
|
| 221 |
}
|
|
|
|
| 237 |
const ok = await checkAuth()
|
| 238 |
if (ok) {
|
| 239 |
refresh()
|
|
|
|
| 240 |
}
|
| 241 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
</script>
|
web/src/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
const BASE = '/api'
|
| 2 |
|
| 3 |
-
function getApiKey() {
|
| 4 |
return localStorage.getItem('autoteam_api_key') || ''
|
| 5 |
}
|
| 6 |
|
|
@@ -52,6 +52,10 @@ export const api = {
|
|
| 52 |
getAccounts: () => request('GET', '/accounts'),
|
| 53 |
getActiveAccounts: () => request('GET', '/accounts/active'),
|
| 54 |
getStandbyAccounts: () => request('GET', '/accounts/standby'),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
deleteAccount: (email) => request('DELETE', `/accounts/${encodeURIComponent(email)}`),
|
| 56 |
deleteAccountsBatch: (emails, continueOnError = true) => request('POST', '/accounts/delete-batch', { emails, continue_on_error: continueOnError }),
|
| 57 |
loginAccount: (email) => request('POST', '/accounts/login', { email }),
|
|
|
|
| 1 |
const BASE = '/api'
|
| 2 |
|
| 3 |
+
export function getApiKey() {
|
| 4 |
return localStorage.getItem('autoteam_api_key') || ''
|
| 5 |
}
|
| 6 |
|
|
|
|
| 52 |
getAccounts: () => request('GET', '/accounts'),
|
| 53 |
getActiveAccounts: () => request('GET', '/accounts/active'),
|
| 54 |
getStandbyAccounts: () => request('GET', '/accounts/standby'),
|
| 55 |
+
bulkDisableAccounts: (emails) => request('POST', '/accounts/bulk/disable', { emails }),
|
| 56 |
+
bulkEnableAccounts: (emails) => request('POST', '/accounts/bulk/enable', { emails }),
|
| 57 |
+
disableAccount: (email) => request('POST', `/accounts/${encodeURIComponent(email)}/disable`),
|
| 58 |
+
enableAccount: (email) => request('POST', `/accounts/${encodeURIComponent(email)}/enable`),
|
| 59 |
deleteAccount: (email) => request('DELETE', `/accounts/${encodeURIComponent(email)}`),
|
| 60 |
deleteAccountsBatch: (emails, continueOnError = true) => request('POST', '/accounts/delete-batch', { emails, continue_on_error: continueOnError }),
|
| 61 |
loginAccount: (email) => request('POST', '/accounts/login', { email }),
|
web/src/components/AtButton.vue
CHANGED
|
@@ -10,7 +10,7 @@
|
|
| 10 |
:disabled="disabled || loading"
|
| 11 |
@click="onClick"
|
| 12 |
:class="classes"
|
| 13 |
-
class="relative inline-flex items-center justify-center gap-2 font-medium rounded-
|
| 14 |
lift-hover focus-ring select-none whitespace-nowrap
|
| 15 |
disabled:cursor-not-allowed disabled:opacity-50">
|
| 16 |
<span v-if="loading"
|
|
@@ -63,20 +63,20 @@ const sizeClasses = {
|
|
| 63 |
|
| 64 |
const variantClasses = computed(() => {
|
| 65 |
if (props.variant === 'primary') {
|
| 66 |
-
// round-12 F1 — text-on-accent 绕过
|
| 67 |
return [
|
| 68 |
'text-on-accent border border-indigo-500/30',
|
| 69 |
-
'bg-
|
| 70 |
'shadow-card hover:shadow-ring-accent',
|
| 71 |
-
'hover:
|
| 72 |
-
'active:
|
| 73 |
].join(' ')
|
| 74 |
}
|
| 75 |
if (props.variant === 'danger') {
|
| 76 |
if (confirming.value) {
|
| 77 |
return [
|
| 78 |
'text-on-accent border border-rose-500/30',
|
| 79 |
-
'bg-
|
| 80 |
'shadow-card ring-2 ring-rose-400/40',
|
| 81 |
].join(' ')
|
| 82 |
}
|
|
|
|
| 10 |
:disabled="disabled || loading"
|
| 11 |
@click="onClick"
|
| 12 |
:class="classes"
|
| 13 |
+
class="relative inline-flex items-center justify-center gap-2 font-medium rounded-lg
|
| 14 |
lift-hover focus-ring select-none whitespace-nowrap
|
| 15 |
disabled:cursor-not-allowed disabled:opacity-50">
|
| 16 |
<span v-if="loading"
|
|
|
|
| 63 |
|
| 64 |
const variantClasses = computed(() => {
|
| 65 |
if (props.variant === 'primary') {
|
| 66 |
+
// round-12 F1 — text-on-accent 绕过旧深色兼容层,保留高对比前景
|
| 67 |
return [
|
| 68 |
'text-on-accent border border-indigo-500/30',
|
| 69 |
+
'bg-indigo-600',
|
| 70 |
'shadow-card hover:shadow-ring-accent',
|
| 71 |
+
'hover:bg-indigo-500',
|
| 72 |
+
'active:bg-indigo-700',
|
| 73 |
].join(' ')
|
| 74 |
}
|
| 75 |
if (props.variant === 'danger') {
|
| 76 |
if (confirming.value) {
|
| 77 |
return [
|
| 78 |
'text-on-accent border border-rose-500/30',
|
| 79 |
+
'bg-rose-600',
|
| 80 |
'shadow-card ring-2 ring-rose-400/40',
|
| 81 |
].join(' ')
|
| 82 |
}
|
web/src/components/Dashboard.vue
CHANGED
|
@@ -14,12 +14,10 @@
|
|
| 14 |
:min-grace-until="minGraceUntil" />
|
| 15 |
|
| 16 |
<!-- 状态分布卡片(色彩区分明显) -->
|
| 17 |
-
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-
|
| 18 |
<div v-for="card in cards" :key="card.label"
|
| 19 |
-
class="glass-soft rounded-
|
| 20 |
-
<div class="
|
| 21 |
-
:style="{ background: card.glow }"></div>
|
| 22 |
-
<div class="text-[10px] uppercase tracking-widest text-gray-500 font-semibold flex items-center gap-1.5">
|
| 23 |
<span class="w-1 h-1 rounded-full" :style="{ background: card.dot }"></span>
|
| 24 |
{{ card.label }}
|
| 25 |
</div>
|
|
@@ -28,18 +26,30 @@
|
|
| 28 |
</div>
|
| 29 |
|
| 30 |
<!-- 账号表格 -->
|
| 31 |
-
<div class="glass rounded-
|
| 32 |
-
<div class="px-5 py-4 border-b border-
|
| 33 |
<div>
|
| 34 |
-
<h2 class="text-base font-bold text-
|
| 35 |
-
<p class="text-[11px] text-
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
<div class="flex items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
<AtButton v-if="selectedEmails.length"
|
| 39 |
variant="danger" size="sm" :loading="batchDeleting" :disabled="actionDisabled"
|
| 40 |
confirm @click="batchDelete">
|
| 41 |
<template #icon>
|
| 42 |
-
<
|
| 43 |
</template>
|
| 44 |
{{ batchDeleting ? `批量删除 ${batchProgress}` : `批量删除 (${selectedEmails.length})` }}
|
| 45 |
</AtButton>
|
|
@@ -48,25 +58,50 @@
|
|
| 48 |
</AtButton>
|
| 49 |
<AtButton variant="secondary" size="sm" :loading="syncing" @click="syncAccounts">
|
| 50 |
<template #icon>
|
| 51 |
-
<
|
| 52 |
</template>
|
| 53 |
{{ syncing ? '同步中…' : '同步账号' }}
|
| 54 |
</AtButton>
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
|
| 58 |
-
<div v-if="message" class="mx-5 mt-4 px-4 py-2.5 rounded-
|
| 59 |
{{ message }}
|
| 60 |
</div>
|
| 61 |
<div v-if="!adminReady"
|
| 62 |
-
class="mx-5 mt-4 px-4 py-2.5 rounded-
|
| 63 |
请先在「设置」页完成管理员登录后,才能操作账号。
|
| 64 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
<div class="overflow-x-auto">
|
| 67 |
<table class="w-full text-sm">
|
| 68 |
<thead>
|
| 69 |
-
<tr class="text-
|
| 70 |
<th class="px-3 py-3 font-semibold w-8">
|
| 71 |
<input
|
| 72 |
type="checkbox"
|
|
@@ -75,7 +110,7 @@
|
|
| 75 |
@change="toggleSelectAll"
|
| 76 |
:disabled="!selectableEmails.length"
|
| 77 |
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5"
|
| 78 |
-
title="全选/取消全选(主号除外)" />
|
| 79 |
</th>
|
| 80 |
<th class="px-3 py-3 font-semibold">#</th>
|
| 81 |
<th class="px-4 py-3 font-semibold">邮箱</th>
|
|
@@ -89,9 +124,9 @@
|
|
| 89 |
</tr>
|
| 90 |
</thead>
|
| 91 |
<tbody>
|
| 92 |
-
<tr v-for="(acc, i) in
|
| 93 |
-
class="row-hoverable border-b border-
|
| 94 |
-
:class="isSelected(acc.email) ? 'bg-indigo-
|
| 95 |
<td class="px-3 py-3.5">
|
| 96 |
<input
|
| 97 |
v-if="!acc.is_main_account"
|
|
@@ -100,15 +135,15 @@
|
|
| 100 |
@change="toggleSelect(acc.email)"
|
| 101 |
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5" />
|
| 102 |
</td>
|
| 103 |
-
<td class="px-3 py-3.5 text-
|
| 104 |
<td class="px-4 py-3.5">
|
| 105 |
<div class="flex items-center gap-2">
|
| 106 |
<span v-if="acc.is_main_account"
|
| 107 |
class="text-[9px] uppercase tracking-widest font-bold px-1.5 py-0.5 rounded
|
| 108 |
-
bg-
|
| 109 |
Master
|
| 110 |
</span>
|
| 111 |
-
<span class="font-mono text-[12px] text-
|
| 112 |
</div>
|
| 113 |
</td>
|
| 114 |
<td class="px-4 py-3.5">
|
|
@@ -123,13 +158,13 @@
|
|
| 123 |
<td class="px-4 py-3.5 text-right font-mono tabular text-[12px]" :class="pctColor(quota(acc, 'weekly'))">
|
| 124 |
{{ quotaPct(acc, 'weekly') }}
|
| 125 |
</td>
|
| 126 |
-
<td class="px-4 py-3.5 text-
|
| 127 |
-
<td class="px-4 py-3.5 text-
|
| 128 |
<td class="px-4 py-3.5 text-right">
|
| 129 |
<div class="flex items-center justify-end gap-1.5 flex-wrap">
|
| 130 |
<span v-if="acc.status === 'personal' && !acc.auth_file"
|
| 131 |
class="inline-block px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wide
|
| 132 |
-
bg-amber-
|
| 133 |
title="未拿到 Codex auth_file,请点击补登录">
|
| 134 |
缺认证
|
| 135 |
</span>
|
|
@@ -149,22 +184,29 @@
|
|
| 149 |
title="立即调 cheap_codex_smoke + check_codex_quota,绕过 30min 节流">
|
| 150 |
立即探活
|
| 151 |
</AtButton>
|
| 152 |
-
<AtButton v-if="!acc.is_main_account && acc.
|
| 153 |
variant="secondary" size="sm"
|
| 154 |
:loading="actionEmail === acc.email && actionType === 'kick'"
|
| 155 |
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'kick')"
|
| 156 |
@click="kickAccount(acc.email)">
|
| 157 |
移出
|
| 158 |
</AtButton>
|
| 159 |
-
<AtButton v-if="acc.
|
| 160 |
variant="ghost" size="sm"
|
| 161 |
:disabled="actionEmail === acc.email"
|
| 162 |
@click="exportCodexAuth(acc.email)">
|
| 163 |
<template #icon>
|
| 164 |
-
<
|
| 165 |
</template>
|
| 166 |
导出
|
| 167 |
</AtButton>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
<AtButton v-if="!acc.is_main_account"
|
| 169 |
variant="danger" size="sm" confirm
|
| 170 |
:loading="actionEmail === acc.email && actionType === 'delete'"
|
|
@@ -180,11 +222,11 @@
|
|
| 180 |
</div>
|
| 181 |
|
| 182 |
<!-- 注册失败明细 -->
|
| 183 |
-
<div class="border-t border-
|
| 184 |
<div class="flex items-center justify-between mb-3">
|
| 185 |
<div>
|
| 186 |
-
<h2 class="text-sm font-bold text-
|
| 187 |
-
<div class="text-[11px] text-
|
| 188 |
</div>
|
| 189 |
<AtButton variant="ghost" size="sm" :loading="failuresLoading" @click="loadFailures">
|
| 190 |
刷新
|
|
@@ -192,13 +234,17 @@
|
|
| 192 |
</div>
|
| 193 |
<div v-if="failuresCounts && Object.keys(failuresCounts).length" class="flex flex-wrap gap-1.5 mb-3 text-[11px]">
|
| 194 |
<span v-for="(cnt, cat) in failuresCounts" :key="cat"
|
| 195 |
-
class="px-2 py-0.5 rounded-md border bg-
|
| 196 |
-
{{ cat }}: <span class="text-rose-
|
| 197 |
</span>
|
| 198 |
</div>
|
| 199 |
-
<div class="overflow-x-auto rounded-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
<table class="w-full text-sm">
|
| 201 |
-
<thead class="text-[10px] uppercase tracking-widest text-
|
| 202 |
<tr>
|
| 203 |
<th class="text-left px-3 py-2 font-semibold">时间</th>
|
| 204 |
<th class="text-left px-3 py-2 font-semibold">邮箱</th>
|
|
@@ -207,19 +253,19 @@
|
|
| 207 |
<th class="text-left px-3 py-2 font-semibold">附加</th>
|
| 208 |
</tr>
|
| 209 |
</thead>
|
| 210 |
-
<tbody class="divide-y divide-
|
| 211 |
<tr v-if="!failuresItems.length">
|
| 212 |
-
<td class="px-3 py-4 text-
|
| 213 |
</tr>
|
| 214 |
-
<tr v-for="(f, idx) in failuresItems" :key="idx" class="hover:bg-
|
| 215 |
-
<td class="px-3 py-2 text-
|
| 216 |
-
<td class="px-3 py-2 text-
|
| 217 |
<td class="px-3 py-2">
|
| 218 |
<span class="px-1.5 py-0.5 rounded-md border text-[10px] font-semibold uppercase tracking-wide"
|
| 219 |
:class="failureCategoryClass(f.category)">{{ f.category }}</span>
|
| 220 |
</td>
|
| 221 |
-
<td class="px-3 py-2 text-
|
| 222 |
-
<td class="px-3 py-2 text-
|
| 223 |
</tr>
|
| 224 |
</tbody>
|
| 225 |
</table>
|
|
@@ -228,36 +274,38 @@
|
|
| 228 |
|
| 229 |
<!-- Codex 认证导出弹窗 -->
|
| 230 |
<div v-if="exportData"
|
| 231 |
-
class="fixed inset-0 bg-
|
| 232 |
@click.self="exportData = null">
|
| 233 |
-
<div class="glass rounded-
|
| 234 |
-
<div class="px-5 py-4 border-b border-
|
| 235 |
-
<h3 class="text-
|
| 236 |
-
<button @click="exportData = null" class="text-
|
|
|
|
|
|
|
| 237 |
</div>
|
| 238 |
<div class="p-5 space-y-3 overflow-y-auto flex-1">
|
| 239 |
-
<div class="px-3 py-2.5 bg-amber-
|
| 240 |
<div class="font-semibold">使用步骤:</div>
|
| 241 |
-
<ol class="list-decimal list-inside space-y-1 text-xs text-amber-
|
| 242 |
<li>退出当前 Codex CLI 会话</li>
|
| 243 |
-
<li>删除旧文件:<code class="bg-
|
| 244 |
-
<li>将下方内容保存到 <code class="bg-
|
| 245 |
<li>重新启动 Codex CLI</li>
|
| 246 |
</ol>
|
| 247 |
-
<div class="text-xs text-amber-
|
| 248 |
</div>
|
| 249 |
<div class="relative">
|
| 250 |
-
<pre class="bg-
|
| 251 |
<AtButton :variant="copied ? 'primary' : 'secondary'" size="sm"
|
| 252 |
class="absolute top-2 right-2" @click="copyExport">
|
| 253 |
{{ copied ? '已复制' : '复制' }}
|
| 254 |
</AtButton>
|
| 255 |
</div>
|
| 256 |
</div>
|
| 257 |
-
<div class="px-5 py-4 border-t border-
|
| 258 |
<AtButton variant="primary" @click="downloadExport">
|
| 259 |
<template #icon>
|
| 260 |
-
<
|
| 261 |
</template>
|
| 262 |
下载 auth.json
|
| 263 |
</AtButton>
|
|
@@ -271,20 +319,21 @@
|
|
| 271 |
<!-- Loading skeleton -->
|
| 272 |
<div v-else-if="loading" class="space-y-4">
|
| 273 |
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
| 274 |
-
<div v-for="i in 4" :key="i" class="glass-soft rounded-
|
| 275 |
</div>
|
| 276 |
-
<div class="glass-soft rounded-
|
| 277 |
</div>
|
| 278 |
</template>
|
| 279 |
|
| 280 |
<script setup>
|
| 281 |
-
import { computed,
|
| 282 |
import { api } from '../api.js'
|
| 283 |
import StatusBadge from './StatusBadge.vue'
|
| 284 |
import UsabilityCell from './UsabilityCell.vue'
|
| 285 |
import MasterHealthBanner from './MasterHealthBanner.vue'
|
| 286 |
import PoolHealthCard from './PoolHealthCard.vue'
|
| 287 |
import AtButton from './AtButton.vue'
|
|
|
|
| 288 |
import {
|
| 289 |
quotaRemainingPct as _qr,
|
| 290 |
quotaPctText,
|
|
@@ -301,6 +350,9 @@ const props = defineProps({
|
|
| 301 |
runningTask: Object,
|
| 302 |
adminStatus: { type: Object, default: null },
|
| 303 |
masterHealth: { type: Object, default: null },
|
|
|
|
|
|
|
|
|
|
| 304 |
})
|
| 305 |
const emit = defineEmits(['refresh', 'reload-master-health'])
|
| 306 |
|
|
@@ -312,28 +364,44 @@ const exportData = ref(null)
|
|
| 312 |
const copied = ref(false)
|
| 313 |
const messageClass = ref('')
|
| 314 |
const masterHealthBusy = ref(false)
|
|
|
|
|
|
|
| 315 |
|
| 316 |
// 批量删除选中态:按邮箱(小写)保存,便于跨刷新复用
|
| 317 |
const selectedSet = ref(new Set())
|
| 318 |
const batchDeleting = ref(false)
|
| 319 |
const batchProgress = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
async function loadFailures() {
|
| 327 |
-
failuresLoading.value = true
|
| 328 |
-
try {
|
| 329 |
-
const r = await api.getRegisterFailures(50)
|
| 330 |
-
failuresItems.value = r.items || []
|
| 331 |
-
failuresCounts.value = r.counts || {}
|
| 332 |
-
} catch (e) {
|
| 333 |
-
console.error('loadFailures', e)
|
| 334 |
-
} finally {
|
| 335 |
-
failuresLoading.value = false
|
| 336 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
}
|
| 338 |
|
| 339 |
function fmtTs(ts) {
|
|
@@ -345,15 +413,15 @@ function fmtTs(ts) {
|
|
| 345 |
|
| 346 |
function failureCategoryClass(cat) {
|
| 347 |
const map = {
|
| 348 |
-
phone_blocked: 'bg-rose-
|
| 349 |
-
duplicate_exhausted: 'bg-orange-
|
| 350 |
-
register_failed: 'bg-amber-
|
| 351 |
-
oauth_failed: 'bg-
|
| 352 |
-
kick_failed: 'bg-yellow-
|
| 353 |
-
exception: 'bg-red-
|
| 354 |
-
master_subscription_degraded: 'bg-orange-
|
| 355 |
}
|
| 356 |
-
return map[cat] || 'bg-
|
| 357 |
}
|
| 358 |
|
| 359 |
function fmtFailureExtra(f) {
|
|
@@ -365,20 +433,25 @@ function fmtFailureExtra(f) {
|
|
| 365 |
return parts.join(' ') || '-'
|
| 366 |
}
|
| 367 |
|
| 368 |
-
onMounted(loadFailures)
|
| 369 |
-
watch(() => props.runningTask, (cur, prev) => {
|
| 370 |
-
if (prev && !cur) loadFailures()
|
| 371 |
-
})
|
| 372 |
-
|
| 373 |
const adminReady = computed(() => !!props.adminStatus?.configured)
|
| 374 |
-
const actionDisabled = computed(() => !!props.runningTask || !adminReady.value)
|
| 375 |
|
| 376 |
const selectableEmails = computed(() =>
|
| 377 |
-
|
| 378 |
)
|
| 379 |
const selectedEmails = computed(() =>
|
| 380 |
selectableEmails.value.filter(e => selectedSet.value.has(e.toLowerCase()))
|
| 381 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
const allSelectableChecked = computed(() =>
|
| 383 |
selectableEmails.value.length > 0 && selectedEmails.value.length === selectableEmails.value.length
|
| 384 |
)
|
|
@@ -405,7 +478,7 @@ function clearSelection() { selectedSet.value = new Set() }
|
|
| 405 |
// Round 9 — 派生 minGraceUntil:从所有 GRACE 子号取最早到期者,banner/PoolHealthCard 共用
|
| 406 |
const minGraceUntil = computed(() => {
|
| 407 |
let min = null
|
| 408 |
-
for (const acc of
|
| 409 |
if (acc.status === 'degraded_grace' && typeof acc.grace_until === 'number') {
|
| 410 |
if (min === null || acc.grace_until < min) min = acc.grace_until
|
| 411 |
}
|
|
@@ -416,13 +489,14 @@ const minGraceUntil = computed(() => {
|
|
| 416 |
const cards = computed(() => {
|
| 417 |
if (!props.status) return []
|
| 418 |
const s = props.status.summary || {}
|
| 419 |
-
const dg = s.degraded_grace ??
|
| 420 |
return [
|
| 421 |
-
{ label: 'Active', value: s.active || 0, color: 'text-emerald-
|
| 422 |
-
{ label: 'Standby', value: s.standby || 0, color: 'text-amber-
|
| 423 |
-
{ label: 'Grace', value: dg, color: 'text-orange-
|
| 424 |
-
{ label: 'Personal', value: s.personal || 0, color: 'text-
|
| 425 |
-
{ label: '
|
|
|
|
| 426 |
]
|
| 427 |
})
|
| 428 |
|
|
@@ -491,6 +565,7 @@ async function syncAccounts() {
|
|
| 491 |
|
| 492 |
function canLogin(acc) {
|
| 493 |
if (acc.is_main_account) return false
|
|
|
|
| 494 |
if (acc.status === 'active') return false
|
| 495 |
if (acc.status === 'personal' && acc.auth_file) return false
|
| 496 |
if (acc.status === 'degraded_grace') return false // grace 期内仍可用,无需补登录
|
|
@@ -500,6 +575,7 @@ function canLogin(acc) {
|
|
| 500 |
// (主号有自己的"立即重测"按钮在 MasterHealthBanner;orphan/pending 无 token 探不动)
|
| 501 |
function canProbe(acc) {
|
| 502 |
if (acc.is_main_account) return false
|
|
|
|
| 503 |
if (!acc.auth_file) return false
|
| 504 |
if (acc.status === 'pending' || acc.status === 'orphan') return false
|
| 505 |
return true
|
|
@@ -568,6 +644,30 @@ async function kickAccount(email) {
|
|
| 568 |
}
|
| 569 |
}
|
| 570 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
async function removeAccount(email) {
|
| 572 |
if (actionDisabled.value) return
|
| 573 |
// 二次确认已在 AtButton(confirm) 内做了第一次,这里再用 native confirm 提示破坏性操作细节
|
|
@@ -585,6 +685,52 @@ async function removeAccount(email) {
|
|
| 585 |
}
|
| 586 |
}
|
| 587 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
async function batchDelete() {
|
| 589 |
if (actionDisabled.value || batchDeleting.value) return
|
| 590 |
const emails = selectedEmails.value
|
|
|
|
| 14 |
:min-grace-until="minGraceUntil" />
|
| 15 |
|
| 16 |
<!-- 状态分布卡片(色彩区分明显) -->
|
| 17 |
+
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
|
| 18 |
<div v-for="card in cards" :key="card.label"
|
| 19 |
+
class="glass-soft rounded-lg p-3.5 lift-hover relative overflow-hidden">
|
| 20 |
+
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold flex items-center gap-1.5">
|
|
|
|
|
|
|
| 21 |
<span class="w-1 h-1 rounded-full" :style="{ background: card.dot }"></span>
|
| 22 |
{{ card.label }}
|
| 23 |
</div>
|
|
|
|
| 26 |
</div>
|
| 27 |
|
| 28 |
<!-- 账号表格 -->
|
| 29 |
+
<div class="glass rounded-lg overflow-hidden">
|
| 30 |
+
<div class="px-5 py-4 border-b border-hairline flex items-center justify-between gap-3 flex-wrap">
|
| 31 |
<div>
|
| 32 |
+
<h2 class="text-base font-bold text-ink-950 tracking-tight">账号列表</h2>
|
| 33 |
+
<p class="text-[11px] text-ink-500 mt-0.5">
|
| 34 |
+
{{ totalAccounts }} 个账号 · 每页 {{ ACCOUNT_PAGE_SIZE }} 条 · 实时配额来自 quota_cache
|
| 35 |
+
</p>
|
| 36 |
</div>
|
| 37 |
<div class="flex items-center gap-2">
|
| 38 |
+
<AtButton v-if="selectedDisableEmails.length"
|
| 39 |
+
variant="secondary" size="sm" :loading="bulkToggling" :disabled="actionDisabled"
|
| 40 |
+
@click="bulkDisableSelected">
|
| 41 |
+
禁用自动化 ({{ selectedDisableEmails.length }})
|
| 42 |
+
</AtButton>
|
| 43 |
+
<AtButton v-if="selectedEnableEmails.length"
|
| 44 |
+
variant="primary" size="sm" :loading="bulkToggling" :disabled="actionDisabled"
|
| 45 |
+
@click="bulkEnableSelected">
|
| 46 |
+
启用自动化 ({{ selectedEnableEmails.length }})
|
| 47 |
+
</AtButton>
|
| 48 |
<AtButton v-if="selectedEmails.length"
|
| 49 |
variant="danger" size="sm" :loading="batchDeleting" :disabled="actionDisabled"
|
| 50 |
confirm @click="batchDelete">
|
| 51 |
<template #icon>
|
| 52 |
+
<Trash2 class="w-3.5 h-3.5" :stroke-width="2" />
|
| 53 |
</template>
|
| 54 |
{{ batchDeleting ? `批量删除 ${batchProgress}` : `批量删除 (${selectedEmails.length})` }}
|
| 55 |
</AtButton>
|
|
|
|
| 58 |
</AtButton>
|
| 59 |
<AtButton variant="secondary" size="sm" :loading="syncing" @click="syncAccounts">
|
| 60 |
<template #icon>
|
| 61 |
+
<RefreshCw class="w-3.5 h-3.5" :stroke-width="2" />
|
| 62 |
</template>
|
| 63 |
{{ syncing ? '同步中…' : '同步账号' }}
|
| 64 |
</AtButton>
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
|
| 68 |
+
<div v-if="message" class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border animate-rise" :class="messageClass">
|
| 69 |
{{ message }}
|
| 70 |
</div>
|
| 71 |
<div v-if="!adminReady"
|
| 72 |
+
class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border bg-amber-50 text-amber-800 border-amber-200">
|
| 73 |
请先在「设置」页完成管理员登录后,才能操作账号。
|
| 74 |
</div>
|
| 75 |
+
<div v-if="hiddenAccountCount"
|
| 76 |
+
class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border bg-sky-50 text-sky-800 border-sky-200">
|
| 77 |
+
当前仅渲染第 {{ accountPageStart + 1 }}-{{ accountPageEnd }} 个账号,另有 {{ hiddenAccountCount }} 个账号在其它页,以控制页面资源占用。
|
| 78 |
+
</div>
|
| 79 |
+
<div v-if="totalAccountPages > 1"
|
| 80 |
+
class="mx-5 mt-4 px-3 py-2 rounded-lg border border-hairline bg-ink-50 flex items-center justify-between gap-3 flex-wrap">
|
| 81 |
+
<div class="text-xs text-ink-600">
|
| 82 |
+
显示 {{ accountPageStart + 1 }}-{{ accountPageEnd }} / {{ totalAccounts }}
|
| 83 |
+
</div>
|
| 84 |
+
<div class="flex items-center gap-2">
|
| 85 |
+
<AtButton variant="ghost" size="sm" :disabled="accountPage <= 1" @click="goAccountPage(accountPage - 1)">
|
| 86 |
+
<template #icon>
|
| 87 |
+
<ChevronLeft class="w-3.5 h-3.5" :stroke-width="2" />
|
| 88 |
+
</template>
|
| 89 |
+
上一页
|
| 90 |
+
</AtButton>
|
| 91 |
+
<span class="text-xs text-ink-600 tabular">第 {{ accountPage }} / {{ totalAccountPages }} 页</span>
|
| 92 |
+
<AtButton variant="ghost" size="sm" :disabled="accountPage >= totalAccountPages" @click="goAccountPage(accountPage + 1)">
|
| 93 |
+
<template #icon>
|
| 94 |
+
<ChevronRight class="w-3.5 h-3.5" :stroke-width="2" />
|
| 95 |
+
</template>
|
| 96 |
+
下一页
|
| 97 |
+
</AtButton>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
|
| 101 |
<div class="overflow-x-auto">
|
| 102 |
<table class="w-full text-sm">
|
| 103 |
<thead>
|
| 104 |
+
<tr class="text-ink-500 text-left border-b border-hairline text-[10px] uppercase tracking-widest">
|
| 105 |
<th class="px-3 py-3 font-semibold w-8">
|
| 106 |
<input
|
| 107 |
type="checkbox"
|
|
|
|
| 110 |
@change="toggleSelectAll"
|
| 111 |
:disabled="!selectableEmails.length"
|
| 112 |
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5"
|
| 113 |
+
title="全选/取消全选本页账号(主号除外)" />
|
| 114 |
</th>
|
| 115 |
<th class="px-3 py-3 font-semibold">#</th>
|
| 116 |
<th class="px-4 py-3 font-semibold">邮箱</th>
|
|
|
|
| 124 |
</tr>
|
| 125 |
</thead>
|
| 126 |
<tbody>
|
| 127 |
+
<tr v-for="(acc, i) in visibleAccounts" :key="acc.email"
|
| 128 |
+
class="row-hoverable border-b border-hairline hover:bg-ink-50 group"
|
| 129 |
+
:class="[isSelected(acc.email) ? 'bg-indigo-50' : '', acc.disabled ? 'opacity-75' : '']">
|
| 130 |
<td class="px-3 py-3.5">
|
| 131 |
<input
|
| 132 |
v-if="!acc.is_main_account"
|
|
|
|
| 135 |
@change="toggleSelect(acc.email)"
|
| 136 |
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5" />
|
| 137 |
</td>
|
| 138 |
+
<td class="px-3 py-3.5 text-ink-400 font-mono text-xs">{{ String(accountPageStart + i + 1).padStart(2, '0') }}</td>
|
| 139 |
<td class="px-4 py-3.5">
|
| 140 |
<div class="flex items-center gap-2">
|
| 141 |
<span v-if="acc.is_main_account"
|
| 142 |
class="text-[9px] uppercase tracking-widest font-bold px-1.5 py-0.5 rounded
|
| 143 |
+
bg-indigo-50 text-indigo-700 border border-indigo-200">
|
| 144 |
Master
|
| 145 |
</span>
|
| 146 |
+
<span class="font-mono text-[12px] text-ink-900">{{ acc.email }}</span>
|
| 147 |
</div>
|
| 148 |
</td>
|
| 149 |
<td class="px-4 py-3.5">
|
|
|
|
| 158 |
<td class="px-4 py-3.5 text-right font-mono tabular text-[12px]" :class="pctColor(quota(acc, 'weekly'))">
|
| 159 |
{{ quotaPct(acc, 'weekly') }}
|
| 160 |
</td>
|
| 161 |
+
<td class="px-4 py-3.5 text-ink-500 font-mono text-[11px]">{{ quotaReset(acc, 'primary') }}</td>
|
| 162 |
+
<td class="px-4 py-3.5 text-ink-500 font-mono text-[11px]">{{ quotaReset(acc, 'weekly') }}</td>
|
| 163 |
<td class="px-4 py-3.5 text-right">
|
| 164 |
<div class="flex items-center justify-end gap-1.5 flex-wrap">
|
| 165 |
<span v-if="acc.status === 'personal' && !acc.auth_file"
|
| 166 |
class="inline-block px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wide
|
| 167 |
+
bg-amber-50 text-amber-800 border border-amber-200"
|
| 168 |
title="未拿到 Codex auth_file,请点击补登录">
|
| 169 |
缺认证
|
| 170 |
</span>
|
|
|
|
| 184 |
title="立即调 cheap_codex_smoke + check_codex_quota,绕过 30min 节流">
|
| 185 |
立即探活
|
| 186 |
</AtButton>
|
| 187 |
+
<AtButton v-if="!acc.is_main_account && !acc.disabled && acc.raw_status === 'active'"
|
| 188 |
variant="secondary" size="sm"
|
| 189 |
:loading="actionEmail === acc.email && actionType === 'kick'"
|
| 190 |
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'kick')"
|
| 191 |
@click="kickAccount(acc.email)">
|
| 192 |
移出
|
| 193 |
</AtButton>
|
| 194 |
+
<AtButton v-if="acc.raw_status === 'active' || acc.raw_status === 'personal' || acc.is_main_account"
|
| 195 |
variant="ghost" size="sm"
|
| 196 |
:disabled="actionEmail === acc.email"
|
| 197 |
@click="exportCodexAuth(acc.email)">
|
| 198 |
<template #icon>
|
| 199 |
+
<Download class="w-3.5 h-3.5" :stroke-width="2" />
|
| 200 |
</template>
|
| 201 |
导出
|
| 202 |
</AtButton>
|
| 203 |
+
<AtButton v-if="!acc.is_main_account"
|
| 204 |
+
:variant="acc.disabled ? 'primary' : 'secondary'" size="sm"
|
| 205 |
+
:loading="actionEmail === acc.email && actionType === (acc.disabled ? 'enable' : 'disable')"
|
| 206 |
+
:disabled="actionDisabled || (actionEmail === acc.email && !['enable', 'disable'].includes(actionType))"
|
| 207 |
+
@click="toggleAccountDisabled(acc)">
|
| 208 |
+
{{ acc.disabled ? '启用' : '禁用' }}
|
| 209 |
+
</AtButton>
|
| 210 |
<AtButton v-if="!acc.is_main_account"
|
| 211 |
variant="danger" size="sm" confirm
|
| 212 |
:loading="actionEmail === acc.email && actionType === 'delete'"
|
|
|
|
| 222 |
</div>
|
| 223 |
|
| 224 |
<!-- 注册失败明细 -->
|
| 225 |
+
<div class="border-t border-hairline p-5 bg-ink-50">
|
| 226 |
<div class="flex items-center justify-between mb-3">
|
| 227 |
<div>
|
| 228 |
+
<h2 class="text-sm font-bold text-ink-950 tracking-tight">注册失败明细</h2>
|
| 229 |
+
<div class="text-[11px] text-ink-500 mt-0.5">未能入池的注册尝试 (add-phone / duplicate / OAuth 失败 等)</div>
|
| 230 |
</div>
|
| 231 |
<AtButton variant="ghost" size="sm" :loading="failuresLoading" @click="loadFailures">
|
| 232 |
刷新
|
|
|
|
| 234 |
</div>
|
| 235 |
<div v-if="failuresCounts && Object.keys(failuresCounts).length" class="flex flex-wrap gap-1.5 mb-3 text-[11px]">
|
| 236 |
<span v-for="(cnt, cat) in failuresCounts" :key="cat"
|
| 237 |
+
class="px-2 py-0.5 rounded-md border bg-surface border-hairline text-ink-700">
|
| 238 |
+
{{ cat }}: <span class="text-rose-700 font-mono ml-0.5 tabular">{{ cnt }}</span>
|
| 239 |
</span>
|
| 240 |
</div>
|
| 241 |
+
<div class="overflow-x-auto rounded-lg border border-hairline bg-surface">
|
| 242 |
+
<div v-if="failuresUnavailable"
|
| 243 |
+
class="px-3 py-3 text-xs text-amber-800 bg-amber-50 border-b border-amber-200">
|
| 244 |
+
{{ failuresUnavailable }}
|
| 245 |
+
</div>
|
| 246 |
<table class="w-full text-sm">
|
| 247 |
+
<thead class="text-[10px] uppercase tracking-widest text-ink-500 border-b border-hairline bg-ink-50">
|
| 248 |
<tr>
|
| 249 |
<th class="text-left px-3 py-2 font-semibold">时间</th>
|
| 250 |
<th class="text-left px-3 py-2 font-semibold">邮箱</th>
|
|
|
|
| 253 |
<th class="text-left px-3 py-2 font-semibold">附加</th>
|
| 254 |
</tr>
|
| 255 |
</thead>
|
| 256 |
+
<tbody class="divide-y divide-hairline text-xs">
|
| 257 |
<tr v-if="!failuresItems.length">
|
| 258 |
+
<td class="px-3 py-4 text-ink-400 italic" colspan="5">暂无失败记录</td>
|
| 259 |
</tr>
|
| 260 |
+
<tr v-for="(f, idx) in failuresItems" :key="idx" class="hover:bg-ink-50">
|
| 261 |
+
<td class="px-3 py-2 text-ink-500 font-mono">{{ fmtTs(f.timestamp) }}</td>
|
| 262 |
+
<td class="px-3 py-2 text-ink-700 font-mono">{{ f.email || '-' }}</td>
|
| 263 |
<td class="px-3 py-2">
|
| 264 |
<span class="px-1.5 py-0.5 rounded-md border text-[10px] font-semibold uppercase tracking-wide"
|
| 265 |
:class="failureCategoryClass(f.category)">{{ f.category }}</span>
|
| 266 |
</td>
|
| 267 |
+
<td class="px-3 py-2 text-ink-600">{{ f.reason }}</td>
|
| 268 |
+
<td class="px-3 py-2 text-ink-400 font-mono text-[10px]">{{ fmtFailureExtra(f) }}</td>
|
| 269 |
</tr>
|
| 270 |
</tbody>
|
| 271 |
</table>
|
|
|
|
| 274 |
|
| 275 |
<!-- Codex 认证导出弹窗 -->
|
| 276 |
<div v-if="exportData"
|
| 277 |
+
class="fixed inset-0 bg-ink-600/20 z-50 flex items-center justify-center p-4 animate-rise"
|
| 278 |
@click.self="exportData = null">
|
| 279 |
+
<div class="glass rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col">
|
| 280 |
+
<div class="px-5 py-4 border-b border-hairline flex items-center justify-between">
|
| 281 |
+
<h3 class="text-ink-950 font-bold tracking-tight">Codex CLI 认证文件</h3>
|
| 282 |
+
<button @click="exportData = null" class="text-ink-500 hover:text-ink-950 transition focus-ring rounded-lg p-1">
|
| 283 |
+
<X class="w-4 h-4" :stroke-width="2" />
|
| 284 |
+
</button>
|
| 285 |
</div>
|
| 286 |
<div class="p-5 space-y-3 overflow-y-auto flex-1">
|
| 287 |
+
<div class="px-3 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 space-y-2">
|
| 288 |
<div class="font-semibold">使用步骤:</div>
|
| 289 |
+
<ol class="list-decimal list-inside space-y-1 text-xs text-amber-700">
|
| 290 |
<li>退出当前 Codex CLI 会话</li>
|
| 291 |
+
<li>删除旧文件:<code class="bg-amber-50 border border-amber-200 px-1 rounded">rm ~/.codex/auth.json</code></li>
|
| 292 |
+
<li>将下方内容保存到 <code class="bg-amber-50 border border-amber-200 px-1 rounded">~/.codex/auth.json</code>(Windows: <code class="bg-amber-50 border border-amber-200 px-1 rounded">%APPDATA%\codex\auth.json</code>)</li>
|
| 293 |
<li>重新启动 Codex CLI</li>
|
| 294 |
</ol>
|
| 295 |
+
<div class="text-xs text-amber-700">导出后 Codex CLI 直连 OpenAI,不走 CPA 代理,响应更快。</div>
|
| 296 |
</div>
|
| 297 |
<div class="relative">
|
| 298 |
+
<pre class="bg-ink-50 border border-hairline rounded-lg p-4 text-xs font-mono text-ink-800 overflow-x-auto whitespace-pre">{{ exportJson }}</pre>
|
| 299 |
<AtButton :variant="copied ? 'primary' : 'secondary'" size="sm"
|
| 300 |
class="absolute top-2 right-2" @click="copyExport">
|
| 301 |
{{ copied ? '已复制' : '复制' }}
|
| 302 |
</AtButton>
|
| 303 |
</div>
|
| 304 |
</div>
|
| 305 |
+
<div class="px-5 py-4 border-t border-hairline flex justify-end gap-2">
|
| 306 |
<AtButton variant="primary" @click="downloadExport">
|
| 307 |
<template #icon>
|
| 308 |
+
<Download class="w-3.5 h-3.5" :stroke-width="2" />
|
| 309 |
</template>
|
| 310 |
下载 auth.json
|
| 311 |
</AtButton>
|
|
|
|
| 319 |
<!-- Loading skeleton -->
|
| 320 |
<div v-else-if="loading" class="space-y-4">
|
| 321 |
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
| 322 |
+
<div v-for="i in 4" :key="i" class="glass-soft rounded-lg p-4 h-20 shimmer-bg"></div>
|
| 323 |
</div>
|
| 324 |
+
<div class="glass-soft rounded-lg h-64 shimmer-bg"></div>
|
| 325 |
</div>
|
| 326 |
</template>
|
| 327 |
|
| 328 |
<script setup>
|
| 329 |
+
import { computed, ref, watch } from 'vue'
|
| 330 |
import { api } from '../api.js'
|
| 331 |
import StatusBadge from './StatusBadge.vue'
|
| 332 |
import UsabilityCell from './UsabilityCell.vue'
|
| 333 |
import MasterHealthBanner from './MasterHealthBanner.vue'
|
| 334 |
import PoolHealthCard from './PoolHealthCard.vue'
|
| 335 |
import AtButton from './AtButton.vue'
|
| 336 |
+
import { ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, X } from 'lucide-vue-next'
|
| 337 |
import {
|
| 338 |
quotaRemainingPct as _qr,
|
| 339 |
quotaPctText,
|
|
|
|
| 350 |
runningTask: Object,
|
| 351 |
adminStatus: { type: Object, default: null },
|
| 352 |
masterHealth: { type: Object, default: null },
|
| 353 |
+
registerFailures: { type: Object, default: null },
|
| 354 |
+
registerFailuresLoading: { type: Boolean, default: false },
|
| 355 |
+
registerFailuresUnavailable: { type: String, default: '' },
|
| 356 |
})
|
| 357 |
const emit = defineEmits(['refresh', 'reload-master-health'])
|
| 358 |
|
|
|
|
| 364 |
const copied = ref(false)
|
| 365 |
const messageClass = ref('')
|
| 366 |
const masterHealthBusy = ref(false)
|
| 367 |
+
const accountPage = ref(1)
|
| 368 |
+
const ACCOUNT_PAGE_SIZE = 50
|
| 369 |
|
| 370 |
// 批量删除选中态:按邮箱(小写)保存,便于跨刷新复用
|
| 371 |
const selectedSet = ref(new Set())
|
| 372 |
const batchDeleting = ref(false)
|
| 373 |
const batchProgress = ref('')
|
| 374 |
+
const bulkToggling = ref(false)
|
| 375 |
+
|
| 376 |
+
const failuresItems = computed(() => props.registerFailures?.items || [])
|
| 377 |
+
const failuresCounts = computed(() => props.registerFailures?.counts || {})
|
| 378 |
+
const failuresLoading = computed(() => !!props.registerFailuresLoading)
|
| 379 |
+
const failuresUnavailable = computed(() => props.registerFailuresUnavailable || '')
|
| 380 |
+
|
| 381 |
+
const accounts = computed(() => props.status?.accounts || [])
|
| 382 |
+
const totalAccounts = computed(() => accounts.value.length)
|
| 383 |
+
const totalAccountPages = computed(() => Math.max(1, Math.ceil(totalAccounts.value / ACCOUNT_PAGE_SIZE)))
|
| 384 |
+
const accountPageStart = computed(() => (accountPage.value - 1) * ACCOUNT_PAGE_SIZE)
|
| 385 |
+
const accountPageEnd = computed(() => Math.min(accountPageStart.value + ACCOUNT_PAGE_SIZE, totalAccounts.value))
|
| 386 |
+
const visibleAccounts = computed(() => accounts.value.slice(accountPageStart.value, accountPageEnd.value))
|
| 387 |
+
const hiddenAccountCount = computed(() => Math.max(0, accounts.value.length - visibleAccounts.value.length))
|
| 388 |
+
|
| 389 |
+
function goAccountPage(page) {
|
| 390 |
+
const nextPage = Math.min(Math.max(1, page), totalAccountPages.value)
|
| 391 |
+
if (nextPage === accountPage.value) return
|
| 392 |
+
accountPage.value = nextPage
|
| 393 |
+
clearSelection()
|
| 394 |
+
}
|
| 395 |
|
| 396 |
+
watch(totalAccountPages, pages => {
|
| 397 |
+
if (accountPage.value > pages) {
|
| 398 |
+
accountPage.value = pages
|
| 399 |
+
clearSelection()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
}
|
| 401 |
+
})
|
| 402 |
+
|
| 403 |
+
function loadFailures() {
|
| 404 |
+
emit('refresh')
|
| 405 |
}
|
| 406 |
|
| 407 |
function fmtTs(ts) {
|
|
|
|
| 413 |
|
| 414 |
function failureCategoryClass(cat) {
|
| 415 |
const map = {
|
| 416 |
+
phone_blocked: 'bg-rose-50 text-rose-700 border-rose-200',
|
| 417 |
+
duplicate_exhausted: 'bg-orange-50 text-orange-700 border-orange-200',
|
| 418 |
+
register_failed: 'bg-amber-50 text-amber-700 border-amber-200',
|
| 419 |
+
oauth_failed: 'bg-sky-50 text-sky-700 border-sky-200',
|
| 420 |
+
kick_failed: 'bg-yellow-50 text-yellow-700 border-yellow-200',
|
| 421 |
+
exception: 'bg-red-50 text-red-700 border-red-200',
|
| 422 |
+
master_subscription_degraded: 'bg-orange-50 text-orange-700 border-orange-200',
|
| 423 |
}
|
| 424 |
+
return map[cat] || 'bg-ink-50 text-ink-600 border-hairline'
|
| 425 |
}
|
| 426 |
|
| 427 |
function fmtFailureExtra(f) {
|
|
|
|
| 433 |
return parts.join(' ') || '-'
|
| 434 |
}
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
const adminReady = computed(() => !!props.adminStatus?.configured)
|
| 437 |
+
const actionDisabled = computed(() => !!props.runningTask || !adminReady.value || bulkToggling.value)
|
| 438 |
|
| 439 |
const selectableEmails = computed(() =>
|
| 440 |
+
visibleAccounts.value.filter(a => !a.is_main_account).map(a => a.email)
|
| 441 |
)
|
| 442 |
const selectedEmails = computed(() =>
|
| 443 |
selectableEmails.value.filter(e => selectedSet.value.has(e.toLowerCase()))
|
| 444 |
)
|
| 445 |
+
const selectedDisableEmails = computed(() =>
|
| 446 |
+
visibleAccounts.value
|
| 447 |
+
.filter(a => !a.is_main_account && !a.disabled && selectedSet.value.has((a.email || '').toLowerCase()))
|
| 448 |
+
.map(a => a.email)
|
| 449 |
+
)
|
| 450 |
+
const selectedEnableEmails = computed(() =>
|
| 451 |
+
visibleAccounts.value
|
| 452 |
+
.filter(a => !a.is_main_account && a.disabled && selectedSet.value.has((a.email || '').toLowerCase()))
|
| 453 |
+
.map(a => a.email)
|
| 454 |
+
)
|
| 455 |
const allSelectableChecked = computed(() =>
|
| 456 |
selectableEmails.value.length > 0 && selectedEmails.value.length === selectableEmails.value.length
|
| 457 |
)
|
|
|
|
| 478 |
// Round 9 — 派生 minGraceUntil:从所有 GRACE 子号取最早到期者,banner/PoolHealthCard 共用
|
| 479 |
const minGraceUntil = computed(() => {
|
| 480 |
let min = null
|
| 481 |
+
for (const acc of accounts.value) {
|
| 482 |
if (acc.status === 'degraded_grace' && typeof acc.grace_until === 'number') {
|
| 483 |
if (min === null || acc.grace_until < min) min = acc.grace_until
|
| 484 |
}
|
|
|
|
| 489 |
const cards = computed(() => {
|
| 490 |
if (!props.status) return []
|
| 491 |
const s = props.status.summary || {}
|
| 492 |
+
const dg = s.degraded_grace ?? accounts.value.filter(a => a.status === 'degraded_grace').length
|
| 493 |
return [
|
| 494 |
+
{ label: 'Active', value: s.active || 0, color: 'text-emerald-700', dot: 'rgb(16, 185, 129)' },
|
| 495 |
+
{ label: 'Standby', value: s.standby || 0, color: 'text-amber-700', dot: 'rgb(217, 119, 6)' },
|
| 496 |
+
{ label: 'Grace', value: dg, color: 'text-orange-700', dot: 'rgb(234, 88, 12)' },
|
| 497 |
+
{ label: 'Personal', value: s.personal || 0, color: 'text-sky-700', dot: 'rgb(2, 132, 199)' },
|
| 498 |
+
{ label: 'Disabled', value: s.disabled || 0, color: 'text-stone-700', dot: 'rgb(120, 113, 108)' },
|
| 499 |
+
{ label: 'Total', value: s.total || 0, color: 'text-ink-950', dot: 'rgb(79, 70, 229)' },
|
| 500 |
]
|
| 501 |
})
|
| 502 |
|
|
|
|
| 565 |
|
| 566 |
function canLogin(acc) {
|
| 567 |
if (acc.is_main_account) return false
|
| 568 |
+
if (acc.disabled) return false
|
| 569 |
if (acc.status === 'active') return false
|
| 570 |
if (acc.status === 'personal' && acc.auth_file) return false
|
| 571 |
if (acc.status === 'degraded_grace') return false // grace 期内仍可用,无需补登录
|
|
|
|
| 575 |
// (主号有自己的"立即重测"按钮在 MasterHealthBanner;orphan/pending 无 token 探不动)
|
| 576 |
function canProbe(acc) {
|
| 577 |
if (acc.is_main_account) return false
|
| 578 |
+
if (acc.disabled) return false
|
| 579 |
if (!acc.auth_file) return false
|
| 580 |
if (acc.status === 'pending' || acc.status === 'orphan') return false
|
| 581 |
return true
|
|
|
|
| 644 |
}
|
| 645 |
}
|
| 646 |
|
| 647 |
+
async function toggleAccountDisabled(acc) {
|
| 648 |
+
if (actionDisabled.value) return
|
| 649 |
+
const disabling = !acc.disabled
|
| 650 |
+
const ok = window.confirm(
|
| 651 |
+
disabling
|
| 652 |
+
? `确认禁用账号 ${acc.email}?\n禁用后会保留本地记录,但自动巡检、轮转和 CPA 同步会跳过该账号。`
|
| 653 |
+
: `确认启用账号 ${acc.email}?\n启用后该账号会重新参与自动巡检、轮转和 CPA 同步。`
|
| 654 |
+
)
|
| 655 |
+
if (!ok) return
|
| 656 |
+
actionEmail.value = acc.email
|
| 657 |
+
actionType.value = disabling ? 'disable' : 'enable'
|
| 658 |
+
try {
|
| 659 |
+
const result = disabling
|
| 660 |
+
? await api.disableAccount(acc.email)
|
| 661 |
+
: await api.enableAccount(acc.email)
|
| 662 |
+
toast.success(disabling ? '账号已禁用' : '账号已启用', result.message || acc.email)
|
| 663 |
+
emit('refresh')
|
| 664 |
+
} catch (e) {
|
| 665 |
+
toast.error(disabling ? '禁用失败' : '启用失败', e.message)
|
| 666 |
+
} finally {
|
| 667 |
+
actionEmail.value = ''; actionType.value = ''
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
async function removeAccount(email) {
|
| 672 |
if (actionDisabled.value) return
|
| 673 |
// 二次确认已在 AtButton(confirm) 内做了第一次,这里再用 native confirm 提示破坏性操作细节
|
|
|
|
| 685 |
}
|
| 686 |
}
|
| 687 |
|
| 688 |
+
async function bulkDisableSelected() {
|
| 689 |
+
if (actionDisabled.value || bulkToggling.value) return
|
| 690 |
+
const emails = selectedDisableEmails.value
|
| 691 |
+
if (!emails.length) return
|
| 692 |
+
const preview = emails.slice(0, 8).join('\n')
|
| 693 |
+
const more = emails.length > 8 ? `\n…还有 ${emails.length - 8} 个` : ''
|
| 694 |
+
const ok = window.confirm(
|
| 695 |
+
`确认批量禁用以下 ${emails.length} 个账号?\n禁用后会保留本地记录,但自动巡检、轮转和 CPA 同步会跳过它们。\n\n${preview}${more}`
|
| 696 |
+
)
|
| 697 |
+
if (!ok) return
|
| 698 |
+
bulkToggling.value = true
|
| 699 |
+
try {
|
| 700 |
+
const result = await api.bulkDisableAccounts(emails)
|
| 701 |
+
toast.success('批量禁用完成', result.message || `已禁用 ${emails.length} 个账号`)
|
| 702 |
+
clearSelection()
|
| 703 |
+
emit('refresh')
|
| 704 |
+
} catch (e) {
|
| 705 |
+
toast.error('批量禁用失败', e.message)
|
| 706 |
+
} finally {
|
| 707 |
+
bulkToggling.value = false
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
async function bulkEnableSelected() {
|
| 712 |
+
if (actionDisabled.value || bulkToggling.value) return
|
| 713 |
+
const emails = selectedEnableEmails.value
|
| 714 |
+
if (!emails.length) return
|
| 715 |
+
const preview = emails.slice(0, 8).join('\n')
|
| 716 |
+
const more = emails.length > 8 ? `\n…还有 ${emails.length - 8} 个` : ''
|
| 717 |
+
const ok = window.confirm(
|
| 718 |
+
`确认批量启用以下 ${emails.length} 个账号?\n启用后它们会重新参与自动巡检、轮转和 CPA 同步。\n\n${preview}${more}`
|
| 719 |
+
)
|
| 720 |
+
if (!ok) return
|
| 721 |
+
bulkToggling.value = true
|
| 722 |
+
try {
|
| 723 |
+
const result = await api.bulkEnableAccounts(emails)
|
| 724 |
+
toast.success('批量启用完成', result.message || `已启用 ${emails.length} 个账号`)
|
| 725 |
+
clearSelection()
|
| 726 |
+
emit('refresh')
|
| 727 |
+
} catch (e) {
|
| 728 |
+
toast.error('批量启用失败', e.message)
|
| 729 |
+
} finally {
|
| 730 |
+
bulkToggling.value = false
|
| 731 |
+
}
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
async function batchDelete() {
|
| 735 |
if (actionDisabled.value || batchDeleting.value) return
|
| 736 |
const emails = selectedEmails.value
|
web/src/components/HealthDonut.vue
CHANGED
|
@@ -19,9 +19,9 @@
|
|
| 19 |
class="transition-all duration-500" />
|
| 20 |
</svg>
|
| 21 |
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
| 22 |
-
<div class="text-[10px] uppercase tracking-widest text-
|
| 23 |
-
<div class="text-2xl font-bold text-
|
| 24 |
-
<div v-if="centerHint" class="text-[10px] text-
|
| 25 |
</div>
|
| 26 |
</div>
|
| 27 |
</template>
|
|
|
|
| 19 |
class="transition-all duration-500" />
|
| 20 |
</svg>
|
| 21 |
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
| 22 |
+
<div class="text-[10px] uppercase tracking-widest text-ink-500 leading-none">{{ centerLabel }}</div>
|
| 23 |
+
<div class="text-2xl font-bold text-ink-950 tabular leading-none mt-1">{{ centerValue }}</div>
|
| 24 |
+
<div v-if="centerHint" class="text-[10px] text-ink-500 mt-0.5">{{ centerHint }}</div>
|
| 25 |
</div>
|
| 26 |
</div>
|
| 27 |
</template>
|
web/src/components/LogViewer.vue
CHANGED
|
@@ -1,45 +1,51 @@
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
<div class="flex items-center justify-between mb-6">
|
| 4 |
-
<h2 class="text-xl font-bold text-
|
| 5 |
<div class="flex items-center gap-3">
|
| 6 |
-
<
|
| 7 |
-
|
|
|
|
| 8 |
自动滚动
|
| 9 |
</label>
|
| 10 |
-
<
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
刷新
|
| 13 |
-
</
|
| 14 |
<button @click="clearLogs"
|
| 15 |
-
class="px-3 py-1.5 bg-
|
|
|
|
| 16 |
清空
|
| 17 |
</button>
|
| 18 |
</div>
|
| 19 |
</div>
|
| 20 |
|
| 21 |
<div ref="logContainer"
|
| 22 |
-
class="bg-
|
| 23 |
-
<div v-if="logs.length === 0" class="text-
|
| 24 |
<div v-for="(log, i) in logs" :key="i"
|
| 25 |
-
class="py-0.5 flex gap-3 hover:bg-
|
| 26 |
-
<span class="text-
|
| 27 |
<span class="shrink-0 w-16"
|
| 28 |
:class="{
|
| 29 |
-
'text-
|
| 30 |
-
'text-
|
| 31 |
-
'text-
|
| 32 |
-
'text-
|
| 33 |
}">{{ log.level }}</span>
|
| 34 |
-
<span class="text-
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
</div>
|
| 38 |
</template>
|
| 39 |
|
| 40 |
<script setup>
|
| 41 |
-
import { ref, onMounted, onUnmounted, nextTick
|
| 42 |
import { api } from '../api.js'
|
|
|
|
|
|
|
| 43 |
|
| 44 |
const logs = ref([])
|
| 45 |
const loading = ref(false)
|
|
@@ -47,6 +53,7 @@ const autoScroll = ref(true)
|
|
| 47 |
const logContainer = ref(null)
|
| 48 |
let pollTimer = null
|
| 49 |
let lastTime = 0
|
|
|
|
| 50 |
|
| 51 |
function formatTime(ts) {
|
| 52 |
const d = new Date(ts * 1000)
|
|
@@ -62,9 +69,9 @@ async function fetchLogs() {
|
|
| 62 |
logs.value = result.logs
|
| 63 |
} else {
|
| 64 |
logs.value.push(...result.logs)
|
| 65 |
-
// 保留最新
|
| 66 |
-
if (logs.value.length >
|
| 67 |
-
logs.value = logs.value.slice(-
|
| 68 |
}
|
| 69 |
}
|
| 70 |
lastTime = result.logs[result.logs.length - 1].time
|
|
@@ -90,10 +97,18 @@ function clearLogs() {
|
|
| 90 |
|
| 91 |
onMounted(() => {
|
| 92 |
fetchLogs()
|
| 93 |
-
pollTimer = setInterval(
|
|
|
|
|
|
|
|
|
|
| 94 |
})
|
| 95 |
|
| 96 |
onUnmounted(() => {
|
| 97 |
if (pollTimer) clearInterval(pollTimer)
|
|
|
|
| 98 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</script>
|
|
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
<div class="flex items-center justify-between mb-6">
|
| 4 |
+
<h2 class="text-xl font-bold text-ink-950">日志</h2>
|
| 5 |
<div class="flex items-center gap-3">
|
| 6 |
+
<span class="text-[11px] text-ink-500 hidden sm:inline">保留最新 1000 条,页面隐藏时暂停轮询</span>
|
| 7 |
+
<label class="flex items-center gap-2 text-sm text-ink-600">
|
| 8 |
+
<input type="checkbox" v-model="autoScroll" class="rounded border-hairline accent-indigo-600" />
|
| 9 |
自动滚动
|
| 10 |
</label>
|
| 11 |
+
<AtButton variant="secondary" size="sm" :loading="loading" @click="fetchLogs">
|
| 12 |
+
<template #icon>
|
| 13 |
+
<RefreshCw class="w-3.5 h-3.5" :stroke-width="2" />
|
| 14 |
+
</template>
|
| 15 |
刷新
|
| 16 |
+
</AtButton>
|
| 17 |
<button @click="clearLogs"
|
| 18 |
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-surface hover:bg-ink-100 text-sm rounded-lg border border-hairline transition text-ink-600 hover:text-ink-950 focus-ring">
|
| 19 |
+
<Trash2 class="w-3.5 h-3.5" :stroke-width="2" />
|
| 20 |
清空
|
| 21 |
</button>
|
| 22 |
</div>
|
| 23 |
</div>
|
| 24 |
|
| 25 |
<div ref="logContainer"
|
| 26 |
+
class="bg-surface border border-hairline rounded-lg p-3 md:p-4 font-mono text-xs leading-relaxed h-[calc(100vh-200px)] md:h-[600px] overflow-y-auto">
|
| 27 |
+
<div v-if="logs.length === 0" class="text-ink-400 text-center py-8">暂无日志</div>
|
| 28 |
<div v-for="(log, i) in logs" :key="i"
|
| 29 |
+
class="py-0.5 flex gap-3 hover:bg-ink-50">
|
| 30 |
+
<span class="text-ink-500 shrink-0">{{ formatTime(log.time) }}</span>
|
| 31 |
<span class="shrink-0 w-16"
|
| 32 |
:class="{
|
| 33 |
+
'text-rose-700': log.level === 'ERROR',
|
| 34 |
+
'text-amber-700': log.level === 'WARNING',
|
| 35 |
+
'text-sky-700': log.level === 'INFO',
|
| 36 |
+
'text-ink-500': log.level === 'DEBUG',
|
| 37 |
}">{{ log.level }}</span>
|
| 38 |
+
<span class="text-ink-700 break-all">{{ log.message }}</span>
|
| 39 |
</div>
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
</template>
|
| 43 |
|
| 44 |
<script setup>
|
| 45 |
+
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
| 46 |
import { api } from '../api.js'
|
| 47 |
+
import AtButton from './AtButton.vue'
|
| 48 |
+
import { RefreshCw, Trash2 } from 'lucide-vue-next'
|
| 49 |
|
| 50 |
const logs = ref([])
|
| 51 |
const loading = ref(false)
|
|
|
|
| 53 |
const logContainer = ref(null)
|
| 54 |
let pollTimer = null
|
| 55 |
let lastTime = 0
|
| 56 |
+
const LOG_RENDER_LIMIT = 1000
|
| 57 |
|
| 58 |
function formatTime(ts) {
|
| 59 |
const d = new Date(ts * 1000)
|
|
|
|
| 69 |
logs.value = result.logs
|
| 70 |
} else {
|
| 71 |
logs.value.push(...result.logs)
|
| 72 |
+
// 保留最新 N 条
|
| 73 |
+
if (logs.value.length > LOG_RENDER_LIMIT) {
|
| 74 |
+
logs.value = logs.value.slice(-LOG_RENDER_LIMIT)
|
| 75 |
}
|
| 76 |
}
|
| 77 |
lastTime = result.logs[result.logs.length - 1].time
|
|
|
|
| 97 |
|
| 98 |
onMounted(() => {
|
| 99 |
fetchLogs()
|
| 100 |
+
pollTimer = setInterval(() => {
|
| 101 |
+
if (!document.hidden) fetchLogs()
|
| 102 |
+
}, 3000)
|
| 103 |
+
document.addEventListener('visibilitychange', handleVisibility)
|
| 104 |
})
|
| 105 |
|
| 106 |
onUnmounted(() => {
|
| 107 |
if (pollTimer) clearInterval(pollTimer)
|
| 108 |
+
document.removeEventListener('visibilitychange', handleVisibility)
|
| 109 |
})
|
| 110 |
+
|
| 111 |
+
function handleVisibility() {
|
| 112 |
+
if (!document.hidden) fetchLogs()
|
| 113 |
+
}
|
| 114 |
</script>
|
web/src/components/MailProviderCard.vue
CHANGED
|
@@ -13,10 +13,10 @@
|
|
| 13 |
<template>
|
| 14 |
<div class="mail-provider-card">
|
| 15 |
<!-- 步骤 1:Provider 选择 -->
|
| 16 |
-
<div class="mb-3 p-3 bg-
|
| 17 |
<div class="flex items-center justify-between mb-2">
|
| 18 |
-
<span class="text-sm text-
|
| 19 |
-
<span class="text-xs" :class="state === 'PROVIDER' ? 'text-
|
| 20 |
{{ state === 'PROVIDER' ? '请选择' : '已选 ' + form.MAIL_PROVIDER }}
|
| 21 |
</span>
|
| 22 |
</div>
|
|
@@ -25,8 +25,8 @@
|
|
| 25 |
v-for="opt in providerOptions" :key="opt.value"
|
| 26 |
@click="selectProvider(opt.value)"
|
| 27 |
:class="form.MAIL_PROVIDER === opt.value
|
| 28 |
-
? 'bg-
|
| 29 |
-
: 'bg-
|
| 30 |
class="flex-1 px-3 py-2 border rounded text-sm transition">
|
| 31 |
<div class="font-medium">{{ opt.label }}</div>
|
| 32 |
<div class="text-xs opacity-75 mt-0.5">{{ opt.desc }}</div>
|
|
@@ -37,7 +37,7 @@
|
|
| 37 |
<!-- 步骤 2:服务器连接 -->
|
| 38 |
<div class="mb-3 p-3 rounded border" :class="cardClass('CONNECTION')">
|
| 39 |
<div class="flex items-center justify-between mb-2">
|
| 40 |
-
<span class="text-sm text-
|
| 41 |
<span class="text-xs" :class="connectionStatusClass">{{ connectionStatus }}</span>
|
| 42 |
</div>
|
| 43 |
<div class="space-y-2" :class="state === 'PROVIDER' ? 'opacity-40 pointer-events-none' : ''">
|
|
@@ -45,22 +45,22 @@
|
|
| 45 |
v-model="form[baseUrlKey]"
|
| 46 |
type="text"
|
| 47 |
:placeholder="form.MAIL_PROVIDER === 'maillab' ? 'https://your-maillab.example.com' : 'https://example.com/api'"
|
| 48 |
-
class="w-full px-2 py-1.5 bg-
|
| 49 |
<input
|
| 50 |
v-if="form.MAIL_PROVIDER === 'maillab'"
|
| 51 |
v-model="form.MAILLAB_USERNAME"
|
| 52 |
type="text"
|
| 53 |
placeholder="管理员邮箱 (MAILLAB_USERNAME)"
|
| 54 |
-
class="w-full px-2 py-1.5 bg-
|
| 55 |
<input
|
| 56 |
v-model="form[passwordKey]"
|
| 57 |
type="password"
|
| 58 |
:placeholder="passwordPlaceholder"
|
| 59 |
-
class="w-full px-2 py-1.5 bg-
|
| 60 |
<button
|
| 61 |
@click="testConnection"
|
| 62 |
:disabled="testing || !canTestConnection"
|
| 63 |
-
class="px-3 py-1.5 bg-
|
| 64 |
{{ testing ? '测试中...' : '测试连接' }}
|
| 65 |
</button>
|
| 66 |
</div>
|
|
@@ -69,14 +69,14 @@
|
|
| 69 |
<!-- 步骤 3:域名归属 -->
|
| 70 |
<div class="mb-3 p-3 rounded border" :class="cardClass('DOMAIN')">
|
| 71 |
<div class="flex items-center justify-between mb-2">
|
| 72 |
-
<span class="text-sm text-
|
| 73 |
<span class="text-xs" :class="domainStatusClass">{{ domainStatus }}</span>
|
| 74 |
</div>
|
| 75 |
<div class="space-y-2" :class="!canEnterDomain ? 'opacity-40 pointer-events-none' : ''">
|
| 76 |
<select
|
| 77 |
v-if="domainList && domainList.length"
|
| 78 |
v-model="selectedDomain"
|
| 79 |
-
class="w-full px-2 py-1.5 bg-
|
| 80 |
<option v-for="d in domainList" :key="d" :value="stripAt(d)">{{ d }}</option>
|
| 81 |
</select>
|
| 82 |
<input
|
|
@@ -84,11 +84,11 @@
|
|
| 84 |
v-model="selectedDomain"
|
| 85 |
type="text"
|
| 86 |
placeholder="example.com"
|
| 87 |
-
class="w-full px-2 py-1.5 bg-
|
| 88 |
<button
|
| 89 |
@click="verifyDomain"
|
| 90 |
:disabled="verifying || !selectedDomain"
|
| 91 |
-
class="px-3 py-1.5 bg-
|
| 92 |
{{ verifying ? '验证中...' : '验证归属' }}
|
| 93 |
</button>
|
| 94 |
</div>
|
|
@@ -108,7 +108,7 @@ const props = defineProps({
|
|
| 108 |
const emit = defineEmits(['update:modelValue', 'state-change', 'verified', 'error'])
|
| 109 |
|
| 110 |
// form 通过 v-model 双向绑定;直接 mutate 内部对象再 emit 完整对象保持 reactivity。
|
| 111 |
-
|
| 112 |
get: () => props.modelValue,
|
| 113 |
set: (v) => emit('update:modelValue', v),
|
| 114 |
})
|
|
@@ -154,8 +154,8 @@ const connectionStatus = computed(() => {
|
|
| 154 |
return detectedProvider.value ? `已通过 (${detectedProvider.value})` : '已通过'
|
| 155 |
})
|
| 156 |
const connectionStatusClass = computed(() => {
|
| 157 |
-
if (state.value === 'PROVIDER' || state.value === 'CONNECTION') return 'text-
|
| 158 |
-
return 'text-
|
| 159 |
})
|
| 160 |
|
| 161 |
const domainStatus = computed(() => {
|
|
@@ -164,7 +164,7 @@ const domainStatus = computed(() => {
|
|
| 164 |
if (state.value === 'DOMAIN') return '请验证域名归属'
|
| 165 |
return '已通过'
|
| 166 |
})
|
| 167 |
-
const domainStatusClass = computed(() => state.value === 'SAVE' ? 'text-
|
| 168 |
|
| 169 |
watch(state, (s) => emit('state-change', s))
|
| 170 |
|
|
@@ -172,9 +172,9 @@ function cardClass(targetState) {
|
|
| 172 |
const order = ['PROVIDER', 'CONNECTION', 'DOMAIN', 'SAVE']
|
| 173 |
const cur = order.indexOf(state.value)
|
| 174 |
const tgt = order.indexOf(targetState)
|
| 175 |
-
if (tgt > cur) return 'bg-
|
| 176 |
-
if (tgt < cur) return 'bg-
|
| 177 |
-
return 'bg-
|
| 178 |
}
|
| 179 |
|
| 180 |
function stripAt(d) {
|
|
|
|
| 13 |
<template>
|
| 14 |
<div class="mail-provider-card">
|
| 15 |
<!-- 步骤 1:Provider 选择 -->
|
| 16 |
+
<div class="mb-3 p-3 bg-ink-50 border border-hairline rounded">
|
| 17 |
<div class="flex items-center justify-between mb-2">
|
| 18 |
+
<span class="text-sm text-ink-950">{{ mode === 'setup' ? '1. 邮箱后端' : '1. 后端类型' }}</span>
|
| 19 |
+
<span class="text-xs" :class="state === 'PROVIDER' ? 'text-amber-700' : 'text-emerald-700'">
|
| 20 |
{{ state === 'PROVIDER' ? '请选择' : '已选 ' + form.MAIL_PROVIDER }}
|
| 21 |
</span>
|
| 22 |
</div>
|
|
|
|
| 25 |
v-for="opt in providerOptions" :key="opt.value"
|
| 26 |
@click="selectProvider(opt.value)"
|
| 27 |
:class="form.MAIL_PROVIDER === opt.value
|
| 28 |
+
? 'bg-indigo-600 border-indigo-500 text-on-accent'
|
| 29 |
+
: 'bg-surface border-hairline text-ink-700 hover:bg-ink-100'"
|
| 30 |
class="flex-1 px-3 py-2 border rounded text-sm transition">
|
| 31 |
<div class="font-medium">{{ opt.label }}</div>
|
| 32 |
<div class="text-xs opacity-75 mt-0.5">{{ opt.desc }}</div>
|
|
|
|
| 37 |
<!-- 步骤 2:服务器连接 -->
|
| 38 |
<div class="mb-3 p-3 rounded border" :class="cardClass('CONNECTION')">
|
| 39 |
<div class="flex items-center justify-between mb-2">
|
| 40 |
+
<span class="text-sm text-ink-950">2. 服务器连接</span>
|
| 41 |
<span class="text-xs" :class="connectionStatusClass">{{ connectionStatus }}</span>
|
| 42 |
</div>
|
| 43 |
<div class="space-y-2" :class="state === 'PROVIDER' ? 'opacity-40 pointer-events-none' : ''">
|
|
|
|
| 45 |
v-model="form[baseUrlKey]"
|
| 46 |
type="text"
|
| 47 |
:placeholder="form.MAIL_PROVIDER === 'maillab' ? 'https://your-maillab.example.com' : 'https://example.com/api'"
|
| 48 |
+
class="w-full px-2 py-1.5 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring" />
|
| 49 |
<input
|
| 50 |
v-if="form.MAIL_PROVIDER === 'maillab'"
|
| 51 |
v-model="form.MAILLAB_USERNAME"
|
| 52 |
type="text"
|
| 53 |
placeholder="管理员邮箱 (MAILLAB_USERNAME)"
|
| 54 |
+
class="w-full px-2 py-1.5 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring" />
|
| 55 |
<input
|
| 56 |
v-model="form[passwordKey]"
|
| 57 |
type="password"
|
| 58 |
:placeholder="passwordPlaceholder"
|
| 59 |
+
class="w-full px-2 py-1.5 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring" />
|
| 60 |
<button
|
| 61 |
@click="testConnection"
|
| 62 |
:disabled="testing || !canTestConnection"
|
| 63 |
+
class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-on-accent text-xs rounded focus-ring">
|
| 64 |
{{ testing ? '测试中...' : '测试连接' }}
|
| 65 |
</button>
|
| 66 |
</div>
|
|
|
|
| 69 |
<!-- 步骤 3:域名归属 -->
|
| 70 |
<div class="mb-3 p-3 rounded border" :class="cardClass('DOMAIN')">
|
| 71 |
<div class="flex items-center justify-between mb-2">
|
| 72 |
+
<span class="text-sm text-ink-950">3. 域名归属</span>
|
| 73 |
<span class="text-xs" :class="domainStatusClass">{{ domainStatus }}</span>
|
| 74 |
</div>
|
| 75 |
<div class="space-y-2" :class="!canEnterDomain ? 'opacity-40 pointer-events-none' : ''">
|
| 76 |
<select
|
| 77 |
v-if="domainList && domainList.length"
|
| 78 |
v-model="selectedDomain"
|
| 79 |
+
class="w-full px-2 py-1.5 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring">
|
| 80 |
<option v-for="d in domainList" :key="d" :value="stripAt(d)">{{ d }}</option>
|
| 81 |
</select>
|
| 82 |
<input
|
|
|
|
| 84 |
v-model="selectedDomain"
|
| 85 |
type="text"
|
| 86 |
placeholder="example.com"
|
| 87 |
+
class="w-full px-2 py-1.5 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring" />
|
| 88 |
<button
|
| 89 |
@click="verifyDomain"
|
| 90 |
:disabled="verifying || !selectedDomain"
|
| 91 |
+
class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-on-accent text-xs rounded focus-ring">
|
| 92 |
{{ verifying ? '验证中...' : '验证归属' }}
|
| 93 |
</button>
|
| 94 |
</div>
|
|
|
|
| 108 |
const emit = defineEmits(['update:modelValue', 'state-change', 'verified', 'error'])
|
| 109 |
|
| 110 |
// form 通过 v-model 双向绑定;直接 mutate 内部对象再 emit 完整对象保持 reactivity。
|
| 111 |
+
let form = computed({
|
| 112 |
get: () => props.modelValue,
|
| 113 |
set: (v) => emit('update:modelValue', v),
|
| 114 |
})
|
|
|
|
| 154 |
return detectedProvider.value ? `已通过 (${detectedProvider.value})` : '已通过'
|
| 155 |
})
|
| 156 |
const connectionStatusClass = computed(() => {
|
| 157 |
+
if (state.value === 'PROVIDER' || state.value === 'CONNECTION') return 'text-amber-700'
|
| 158 |
+
return 'text-emerald-700'
|
| 159 |
})
|
| 160 |
|
| 161 |
const domainStatus = computed(() => {
|
|
|
|
| 164 |
if (state.value === 'DOMAIN') return '请验证域名归属'
|
| 165 |
return '已通过'
|
| 166 |
})
|
| 167 |
+
const domainStatusClass = computed(() => state.value === 'SAVE' ? 'text-emerald-700' : 'text-amber-700')
|
| 168 |
|
| 169 |
watch(state, (s) => emit('state-change', s))
|
| 170 |
|
|
|
|
| 172 |
const order = ['PROVIDER', 'CONNECTION', 'DOMAIN', 'SAVE']
|
| 173 |
const cur = order.indexOf(state.value)
|
| 174 |
const tgt = order.indexOf(targetState)
|
| 175 |
+
if (tgt > cur) return 'bg-ink-50 border-hairline'
|
| 176 |
+
if (tgt < cur) return 'bg-emerald-50 border-emerald-200'
|
| 177 |
+
return 'bg-indigo-50 border-indigo-200'
|
| 178 |
}
|
| 179 |
|
| 180 |
function stripAt(d) {
|
web/src/components/MasterHealthBanner.vue
CHANGED
|
@@ -10,12 +10,10 @@
|
|
| 10 |
refresh: 立即重测按钮触发
|
| 11 |
-->
|
| 12 |
<template>
|
| 13 |
-
<div v-if="visible" class="relative overflow-hidden rounded-
|
| 14 |
-
:class="[tone.border,
|
| 15 |
-
<!-- 背景装饰 -->
|
| 16 |
-
<div class="absolute inset-0 pointer-events-none opacity-60" :style="meshStyle"></div>
|
| 17 |
<!-- 角标 -->
|
| 18 |
-
<div class="absolute top-0 right-0 px-3 py-1 text-[10px] font-mono uppercase tracking-widest rounded-bl-
|
| 19 |
:class="[tone.tagBg, tone.tagText]">
|
| 20 |
{{ tone.tag }}
|
| 21 |
</div>
|
|
@@ -23,22 +21,12 @@
|
|
| 23 |
<div class="relative px-5 py-4">
|
| 24 |
<div class="flex items-start gap-4">
|
| 25 |
<!-- 图标 -->
|
| 26 |
-
<div class="relative shrink-0 w-11 h-11 rounded-
|
| 27 |
:class="tone.iconWrap">
|
| 28 |
-
<
|
| 29 |
-
|
| 30 |
-
</
|
| 31 |
-
<
|
| 32 |
-
<circle cx="12" cy="12" r="9"/>
|
| 33 |
-
<path d="M12 7v6M12 16h.01" stroke-linecap="round"/>
|
| 34 |
-
</svg>
|
| 35 |
-
<svg v-else-if="severity === 'info'" viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2">
|
| 36 |
-
<circle cx="12" cy="12" r="9"/>
|
| 37 |
-
<path d="M11 11h1v5h1M12 7.5h.01" stroke-linecap="round"/>
|
| 38 |
-
</svg>
|
| 39 |
-
<svg v-else viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2">
|
| 40 |
-
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
|
| 41 |
-
</svg>
|
| 42 |
|
| 43 |
<span v-if="severity === 'critical'" class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-rose-400">
|
| 44 |
<span class="absolute inset-0 rounded-full bg-rose-400 animate-ping opacity-75"></span>
|
|
@@ -49,7 +37,7 @@
|
|
| 49 |
<div class="flex-1 min-w-0">
|
| 50 |
<div class="flex items-center gap-3 flex-wrap">
|
| 51 |
<h3 class="text-base font-bold" :class="tone.title">{{ title }}</h3>
|
| 52 |
-
<span v-if="graceCountdown" class="font-mono text-xs px-2 py-0.5 rounded-md bg-
|
| 53 |
:class="graceCountdownColor">
|
| 54 |
grace · {{ graceCountdown }}
|
| 55 |
</span>
|
|
@@ -83,6 +71,7 @@
|
|
| 83 |
<script setup>
|
| 84 |
import { computed } from 'vue'
|
| 85 |
import { formatGraceRemain, graceUrgencyClass, masterHealthSeverity } from '../composables/useStatus.js'
|
|
|
|
| 86 |
|
| 87 |
const props = defineProps({
|
| 88 |
masterHealth: { type: Object, default: null },
|
|
@@ -186,54 +175,39 @@ const tone = computed(() => {
|
|
| 186 |
if (severity.value === 'warning') {
|
| 187 |
return {
|
| 188 |
tag: 'GRACE',
|
| 189 |
-
tagBg: 'bg-amber-
|
| 190 |
-
tagText: 'text-amber-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
| 196 |
}
|
| 197 |
}
|
| 198 |
if (severity.value === 'info') {
|
| 199 |
return {
|
| 200 |
tag: 'STALE',
|
| 201 |
-
tagBg: 'bg-slate-
|
| 202 |
-
tagText: 'text-slate-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
}
|
| 209 |
}
|
| 210 |
return {
|
| 211 |
tag: 'ALERT',
|
| 212 |
-
tagBg: 'bg-rose-
|
| 213 |
-
tagText: 'text-rose-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
| 219 |
}
|
| 220 |
})
|
| 221 |
-
|
| 222 |
-
const containerStyle = computed(() => {
|
| 223 |
-
const bg = {
|
| 224 |
-
warning: 'linear-gradient(135deg, rgba(120, 53, 15, 0.45) 0%, rgba(154, 52, 18, 0.35) 60%, rgba(159, 18, 57, 0.30) 100%)',
|
| 225 |
-
critical: 'linear-gradient(135deg, rgba(127, 29, 29, 0.55) 0%, rgba(159, 18, 57, 0.42) 100%)',
|
| 226 |
-
info: 'linear-gradient(135deg, rgba(30, 41, 59, 0.55) 0%, rgba(15, 23, 42, 0.45) 100%)',
|
| 227 |
-
}
|
| 228 |
-
return { background: bg[severity.value] || bg.critical }
|
| 229 |
-
})
|
| 230 |
-
|
| 231 |
-
const meshStyle = computed(() => {
|
| 232 |
-
const m = {
|
| 233 |
-
warning: 'radial-gradient(ellipse 60% 60% at 90% 10%, rgba(251, 146, 60, 0.18), transparent 60%), radial-gradient(ellipse 60% 80% at 10% 100%, rgba(251, 113, 133, 0.12), transparent 60%)',
|
| 234 |
-
critical: 'radial-gradient(ellipse 70% 70% at 95% 0%, rgba(251, 113, 133, 0.20), transparent 60%), radial-gradient(ellipse 50% 70% at 0% 100%, rgba(220, 38, 38, 0.18), transparent 60%)',
|
| 235 |
-
info: 'radial-gradient(ellipse 60% 60% at 100% 0%, rgba(148, 163, 184, 0.12), transparent 60%)',
|
| 236 |
-
}
|
| 237 |
-
return { background: m[severity.value] || m.critical }
|
| 238 |
-
})
|
| 239 |
</script>
|
|
|
|
| 10 |
refresh: 立即重测按钮触发
|
| 11 |
-->
|
| 12 |
<template>
|
| 13 |
+
<div v-if="visible" class="relative overflow-hidden rounded-lg border lift-hover shadow-card"
|
| 14 |
+
:class="[tone.border, tone.bg]" role="alert">
|
|
|
|
|
|
|
| 15 |
<!-- 角标 -->
|
| 16 |
+
<div class="absolute top-0 right-0 px-3 py-1 text-[10px] font-mono uppercase tracking-widest rounded-bl-lg"
|
| 17 |
:class="[tone.tagBg, tone.tagText]">
|
| 18 |
{{ tone.tag }}
|
| 19 |
</div>
|
|
|
|
| 21 |
<div class="relative px-5 py-4">
|
| 22 |
<div class="flex items-start gap-4">
|
| 23 |
<!-- 图标 -->
|
| 24 |
+
<div class="relative shrink-0 w-11 h-11 rounded-lg flex items-center justify-center"
|
| 25 |
:class="tone.iconWrap">
|
| 26 |
+
<AlertTriangle v-if="severity === 'critical'" class="w-5 h-5" :stroke-width="2" />
|
| 27 |
+
<CircleAlert v-else-if="severity === 'warning'" class="w-5 h-5" :stroke-width="2" />
|
| 28 |
+
<Info v-else-if="severity === 'info'" class="w-5 h-5" :stroke-width="2" />
|
| 29 |
+
<Check v-else class="w-5 h-5" :stroke-width="2" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
<span v-if="severity === 'critical'" class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-rose-400">
|
| 32 |
<span class="absolute inset-0 rounded-full bg-rose-400 animate-ping opacity-75"></span>
|
|
|
|
| 37 |
<div class="flex-1 min-w-0">
|
| 38 |
<div class="flex items-center gap-3 flex-wrap">
|
| 39 |
<h3 class="text-base font-bold" :class="tone.title">{{ title }}</h3>
|
| 40 |
+
<span v-if="graceCountdown" class="font-mono text-xs px-2 py-0.5 rounded-md bg-surface border border-hairline"
|
| 41 |
:class="graceCountdownColor">
|
| 42 |
grace · {{ graceCountdown }}
|
| 43 |
</span>
|
|
|
|
| 71 |
<script setup>
|
| 72 |
import { computed } from 'vue'
|
| 73 |
import { formatGraceRemain, graceUrgencyClass, masterHealthSeverity } from '../composables/useStatus.js'
|
| 74 |
+
import { AlertTriangle, Check, CircleAlert, Info } from 'lucide-vue-next'
|
| 75 |
|
| 76 |
const props = defineProps({
|
| 77 |
masterHealth: { type: Object, default: null },
|
|
|
|
| 175 |
if (severity.value === 'warning') {
|
| 176 |
return {
|
| 177 |
tag: 'GRACE',
|
| 178 |
+
tagBg: 'bg-amber-100',
|
| 179 |
+
tagText: 'text-amber-800',
|
| 180 |
+
bg: 'bg-amber-50',
|
| 181 |
+
iconWrap: 'bg-amber-100 text-amber-700 border border-amber-200',
|
| 182 |
+
title: 'text-amber-950',
|
| 183 |
+
body: 'text-amber-800',
|
| 184 |
+
border: 'border-amber-200',
|
| 185 |
+
btn: 'bg-surface hover:bg-amber-100 text-amber-800 border-amber-200',
|
| 186 |
}
|
| 187 |
}
|
| 188 |
if (severity.value === 'info') {
|
| 189 |
return {
|
| 190 |
tag: 'STALE',
|
| 191 |
+
tagBg: 'bg-slate-100',
|
| 192 |
+
tagText: 'text-slate-700',
|
| 193 |
+
bg: 'bg-slate-50',
|
| 194 |
+
iconWrap: 'bg-slate-100 text-slate-700 border border-slate-200',
|
| 195 |
+
title: 'text-slate-950',
|
| 196 |
+
body: 'text-slate-700',
|
| 197 |
+
border: 'border-slate-200',
|
| 198 |
+
btn: 'bg-surface hover:bg-slate-100 text-slate-700 border-slate-200',
|
| 199 |
}
|
| 200 |
}
|
| 201 |
return {
|
| 202 |
tag: 'ALERT',
|
| 203 |
+
tagBg: 'bg-rose-100',
|
| 204 |
+
tagText: 'text-rose-800',
|
| 205 |
+
bg: 'bg-rose-50',
|
| 206 |
+
iconWrap: 'bg-rose-100 text-rose-700 border border-rose-200',
|
| 207 |
+
title: 'text-rose-950',
|
| 208 |
+
body: 'text-rose-800',
|
| 209 |
+
border: 'border-rose-200',
|
| 210 |
+
btn: 'bg-surface hover:bg-rose-100 text-rose-800 border-rose-200',
|
| 211 |
}
|
| 212 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
</script>
|
web/src/components/OAuthPage.vue
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="mt-6 space-y-6">
|
| 3 |
-
<div class="
|
| 4 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 5 |
<div>
|
| 6 |
-
<h2 class="text-lg font-semibold text-
|
| 7 |
-
<p class="text-sm text-
|
| 8 |
参考 CLIProxyAPI 的手动 OAuth 思路:系统先生成认证链接,你在浏览器中手动完成登录,最后把回调 URL 粘贴回来完成认证。
|
| 9 |
</p>
|
| 10 |
</div>
|
| 11 |
<span
|
| 12 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 13 |
:class="manualAccountBusy
|
| 14 |
-
? 'bg-
|
| 15 |
-
: 'bg-
|
| 16 |
>
|
| 17 |
{{ manualAccountBusy ? '进行中' : '空闲' }}
|
| 18 |
</span>
|
|
@@ -24,14 +24,14 @@
|
|
| 24 |
|
| 25 |
<div
|
| 26 |
v-if="manualAccountStatus?.status === 'completed' && manualAccountStatus?.account"
|
| 27 |
-
class="mb-4 px-4 py-3 rounded-lg text-sm border bg-
|
| 28 |
>
|
| 29 |
{{ manualAccountStatus.message || `已添加账号 ${manualAccountStatus.account.email}` }}
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<div
|
| 33 |
v-else-if="manualAccountStatus?.status === 'error' && manualAccountStatus?.error"
|
| 34 |
-
class="mb-4 px-4 py-3 rounded-lg text-sm border bg-
|
| 35 |
>
|
| 36 |
{{ manualAccountStatus.error }}
|
| 37 |
</div>
|
|
@@ -40,22 +40,22 @@
|
|
| 40 |
<button
|
| 41 |
@click="startManualAccount"
|
| 42 |
:disabled="manualSubmitting"
|
| 43 |
-
class="px-4 py-2 bg-
|
| 44 |
>
|
| 45 |
{{ manualSubmitting ? '生成中...' : '生成 OAuth 链接' }}
|
| 46 |
</button>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
<div v-else class="space-y-4">
|
| 50 |
-
<div class="text-sm text-
|
| 51 |
已生成 OAuth 链接。若当前机器可访问 <span class="font-mono">localhost:1455</span>,系统会自动接收回调;否则请手动粘贴最终回调 URL。
|
| 52 |
</div>
|
| 53 |
|
| 54 |
<div
|
| 55 |
class="px-4 py-3 rounded-lg text-sm border"
|
| 56 |
:class="manualAccountStatus?.auto_callback_available
|
| 57 |
-
? 'bg-blue-
|
| 58 |
-
: 'bg-amber-
|
| 59 |
>
|
| 60 |
{{
|
| 61 |
manualAccountStatus?.auto_callback_available
|
|
@@ -65,8 +65,8 @@
|
|
| 65 |
</div>
|
| 66 |
|
| 67 |
<div class="space-y-2">
|
| 68 |
-
<div class="text-xs text-
|
| 69 |
-
<div class="p-3 bg-
|
| 70 |
{{ manualAccountStatus?.auth_url }}
|
| 71 |
</div>
|
| 72 |
</div>
|
|
@@ -76,7 +76,7 @@
|
|
| 76 |
:href="manualAccountStatus?.auth_url"
|
| 77 |
target="_blank"
|
| 78 |
rel="noopener noreferrer"
|
| 79 |
-
class="px-4 py-2 bg-
|
| 80 |
>
|
| 81 |
打开 OAuth 链接
|
| 82 |
</a>
|
|
@@ -84,7 +84,7 @@
|
|
| 84 |
|
| 85 |
<div
|
| 86 |
v-if="manualAccountStatus?.callback_received"
|
| 87 |
-
class="text-xs text-emerald-
|
| 88 |
>
|
| 89 |
已收到{{ manualAccountStatus?.callback_source === 'auto' ? '自动' : '手动' }}回调,刷新轮询中…
|
| 90 |
</div>
|
|
@@ -95,18 +95,18 @@
|
|
| 95 |
type="text"
|
| 96 |
placeholder="粘贴回调 URL,例如 http://localhost:1455/auth/callback?code=...&state=..."
|
| 97 |
:disabled="manualSubmitting"
|
| 98 |
-
class="w-full px-3 py-2 bg-
|
| 99 |
/>
|
| 100 |
<button
|
| 101 |
@click="submitManualCallback"
|
| 102 |
:disabled="manualSubmitting || !manualCallbackUrl"
|
| 103 |
-
class="px-4 py-2 bg-
|
| 104 |
>
|
| 105 |
{{ manualSubmitting ? '提交中...' : '提交回调 URL' }}
|
| 106 |
</button>
|
| 107 |
</div>
|
| 108 |
|
| 109 |
-
<div v-if="manualSubmitting && manualSubmittingHint" class="text-xs text-emerald-
|
| 110 |
{{ manualSubmittingHint }}
|
| 111 |
</div>
|
| 112 |
|
|
@@ -114,7 +114,7 @@
|
|
| 114 |
<button
|
| 115 |
@click="cancelManualAccount"
|
| 116 |
:disabled="manualSubmitting"
|
| 117 |
-
class="px-4 py-2 bg-
|
| 118 |
>
|
| 119 |
取消 OAuth 登录
|
| 120 |
</button>
|
|
@@ -159,8 +159,8 @@ watch(
|
|
| 159 |
function setMessage(text, type = 'success') {
|
| 160 |
message.value = text
|
| 161 |
messageClass.value = type === 'success'
|
| 162 |
-
? 'bg-
|
| 163 |
-
: 'bg-
|
| 164 |
window.clearTimeout(setMessage._timer)
|
| 165 |
setMessage._timer = window.setTimeout(() => {
|
| 166 |
message.value = ''
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="mt-6 space-y-6">
|
| 3 |
+
<div class="glass rounded-lg p-4">
|
| 4 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 5 |
<div>
|
| 6 |
+
<h2 class="text-lg font-semibold text-ink-950">OAuth 登录</h2>
|
| 7 |
+
<p class="text-sm text-ink-500 mt-1">
|
| 8 |
参考 CLIProxyAPI 的手动 OAuth 思路:系统先生成认证链接,你在浏览器中手动完成登录,最后把回调 URL 粘贴回来完成认证。
|
| 9 |
</p>
|
| 10 |
</div>
|
| 11 |
<span
|
| 12 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 13 |
:class="manualAccountBusy
|
| 14 |
+
? 'bg-amber-50 text-amber-800 border-amber-200'
|
| 15 |
+
: 'bg-ink-50 text-ink-600 border-hairline'"
|
| 16 |
>
|
| 17 |
{{ manualAccountBusy ? '进行中' : '空闲' }}
|
| 18 |
</span>
|
|
|
|
| 24 |
|
| 25 |
<div
|
| 26 |
v-if="manualAccountStatus?.status === 'completed' && manualAccountStatus?.account"
|
| 27 |
+
class="mb-4 px-4 py-3 rounded-lg text-sm border bg-emerald-50 text-emerald-700 border-emerald-200"
|
| 28 |
>
|
| 29 |
{{ manualAccountStatus.message || `已添加账号 ${manualAccountStatus.account.email}` }}
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<div
|
| 33 |
v-else-if="manualAccountStatus?.status === 'error' && manualAccountStatus?.error"
|
| 34 |
+
class="mb-4 px-4 py-3 rounded-lg text-sm border bg-rose-50 text-rose-700 border-rose-200"
|
| 35 |
>
|
| 36 |
{{ manualAccountStatus.error }}
|
| 37 |
</div>
|
|
|
|
| 40 |
<button
|
| 41 |
@click="startManualAccount"
|
| 42 |
:disabled="manualSubmitting"
|
| 43 |
+
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 focus-ring"
|
| 44 |
>
|
| 45 |
{{ manualSubmitting ? '生成中...' : '生成 OAuth 链接' }}
|
| 46 |
</button>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
<div v-else class="space-y-4">
|
| 50 |
+
<div class="text-sm text-ink-700">
|
| 51 |
已生成 OAuth 链接。若当前机器可访问 <span class="font-mono">localhost:1455</span>,系统会自动接收回调;否则请手动粘贴最终回调 URL。
|
| 52 |
</div>
|
| 53 |
|
| 54 |
<div
|
| 55 |
class="px-4 py-3 rounded-lg text-sm border"
|
| 56 |
:class="manualAccountStatus?.auto_callback_available
|
| 57 |
+
? 'bg-blue-50 text-blue-700 border-blue-200'
|
| 58 |
+
: 'bg-amber-50 text-amber-800 border-amber-200'"
|
| 59 |
>
|
| 60 |
{{
|
| 61 |
manualAccountStatus?.auto_callback_available
|
|
|
|
| 65 |
</div>
|
| 66 |
|
| 67 |
<div class="space-y-2">
|
| 68 |
+
<div class="text-xs text-ink-500">OAuth 链接</div>
|
| 69 |
+
<div class="p-3 bg-ink-50 border border-hairline rounded-lg text-xs font-mono break-all text-ink-700">
|
| 70 |
{{ manualAccountStatus?.auth_url }}
|
| 71 |
</div>
|
| 72 |
</div>
|
|
|
|
| 76 |
:href="manualAccountStatus?.auth_url"
|
| 77 |
target="_blank"
|
| 78 |
rel="noopener noreferrer"
|
| 79 |
+
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition focus-ring"
|
| 80 |
>
|
| 81 |
打开 OAuth 链接
|
| 82 |
</a>
|
|
|
|
| 84 |
|
| 85 |
<div
|
| 86 |
v-if="manualAccountStatus?.callback_received"
|
| 87 |
+
class="text-xs text-emerald-700"
|
| 88 |
>
|
| 89 |
已收到{{ manualAccountStatus?.callback_source === 'auto' ? '自动' : '手动' }}回调,刷新轮询中…
|
| 90 |
</div>
|
|
|
|
| 95 |
type="text"
|
| 96 |
placeholder="粘贴回调 URL,例如 http://localhost:1455/auth/callback?code=...&state=..."
|
| 97 |
:disabled="manualSubmitting"
|
| 98 |
+
class="w-full px-3 py-2 bg-surface border border-hairline rounded-lg text-sm text-ink-950 focus-ring"
|
| 99 |
/>
|
| 100 |
<button
|
| 101 |
@click="submitManualCallback"
|
| 102 |
:disabled="manualSubmitting || !manualCallbackUrl"
|
| 103 |
+
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 focus-ring"
|
| 104 |
>
|
| 105 |
{{ manualSubmitting ? '提交中...' : '提交回调 URL' }}
|
| 106 |
</button>
|
| 107 |
</div>
|
| 108 |
|
| 109 |
+
<div v-if="manualSubmitting && manualSubmittingHint" class="text-xs text-emerald-700">
|
| 110 |
{{ manualSubmittingHint }}
|
| 111 |
</div>
|
| 112 |
|
|
|
|
| 114 |
<button
|
| 115 |
@click="cancelManualAccount"
|
| 116 |
:disabled="manualSubmitting"
|
| 117 |
+
class="px-4 py-2 bg-surface hover:bg-ink-100 text-sm text-ink-700 rounded-lg border border-hairline transition disabled:opacity-50 focus-ring"
|
| 118 |
>
|
| 119 |
取消 OAuth 登录
|
| 120 |
</button>
|
|
|
|
| 159 |
function setMessage(text, type = 'success') {
|
| 160 |
message.value = text
|
| 161 |
messageClass.value = type === 'success'
|
| 162 |
+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 163 |
+
: 'bg-rose-50 text-rose-700 border-rose-200'
|
| 164 |
window.clearTimeout(setMessage._timer)
|
| 165 |
setMessage._timer = window.setTimeout(() => {
|
| 166 |
message.value = ''
|
web/src/components/PoolHealthCard.vue
CHANGED
|
@@ -5,11 +5,7 @@
|
|
| 5 |
- master_health inline 状态
|
| 6 |
-->
|
| 7 |
<template>
|
| 8 |
-
<div class="glass rounded-
|
| 9 |
-
<!-- 背景装饰 -->
|
| 10 |
-
<div class="absolute -top-16 -right-16 w-64 h-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
| 11 |
-
:style="{ background: 'radial-gradient(circle, rgba(99, 102, 241, 0.30), transparent 60%)' }"></div>
|
| 12 |
-
|
| 13 |
<div class="relative flex flex-col lg:flex-row items-start lg:items-center gap-6">
|
| 14 |
<!-- 左:donut + 中心总数 -->
|
| 15 |
<div class="shrink-0">
|
|
@@ -25,7 +21,7 @@
|
|
| 25 |
<!-- 中:四档数字 -->
|
| 26 |
<div class="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-3 w-full">
|
| 27 |
<div v-for="card in cards" :key="card.key"
|
| 28 |
-
class="rounded-
|
| 29 |
:class="card.borderClass">
|
| 30 |
<div class="flex items-center gap-1.5 text-[10px] uppercase tracking-widest font-semibold"
|
| 31 |
:class="card.labelClass">
|
|
@@ -43,13 +39,11 @@
|
|
| 43 |
|
| 44 |
<!-- 右:master health inline -->
|
| 45 |
<div class="shrink-0 lg:max-w-xs w-full lg:w-auto">
|
| 46 |
-
<div class="rounded-
|
| 47 |
:class="masterTone.border" :style="{ background: masterTone.bg }">
|
| 48 |
<div class="shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
|
| 49 |
:class="masterTone.iconWrap">
|
| 50 |
-
<
|
| 51 |
-
<path :fill="masterTone.iconFill" d="M3 2.5h10v3a4.99 4.99 0 0 1-5 5 4.99 4.99 0 0 1-5-5v-3zM5 12.5h6v1.5H5z"/>
|
| 52 |
-
</svg>
|
| 53 |
</div>
|
| 54 |
<div class="min-w-0 flex-1">
|
| 55 |
<div class="text-[10px] uppercase tracking-widest font-semibold opacity-70" :class="masterTone.text">
|
|
@@ -72,6 +66,7 @@
|
|
| 72 |
import { computed } from 'vue'
|
| 73 |
import HealthDonut from './HealthDonut.vue'
|
| 74 |
import { computeUsability, formatGraceRemain } from '../composables/useStatus.js'
|
|
|
|
| 75 |
|
| 76 |
const props = defineProps({
|
| 77 |
accounts: { type: Array, default: () => [] },
|
|
@@ -98,29 +93,29 @@ const cards = computed(() => [
|
|
| 98 |
key: 'usable', label: '可用', value: counts.value.usable,
|
| 99 |
color: 'rgba(52, 211, 153, 1)',
|
| 100 |
borderClass: 'border-emerald-500/20',
|
| 101 |
-
labelClass: 'text-emerald-
|
| 102 |
-
valueClass: 'text-emerald-
|
| 103 |
},
|
| 104 |
{
|
| 105 |
key: 'grace', label: 'Grace', value: counts.value.grace,
|
| 106 |
color: 'rgba(251, 146, 60, 1)',
|
| 107 |
borderClass: 'border-orange-500/30',
|
| 108 |
-
labelClass: 'text-orange-
|
| 109 |
-
valueClass: 'text-orange-
|
| 110 |
},
|
| 111 |
{
|
| 112 |
key: 'standby', label: '待机', value: counts.value.standby,
|
| 113 |
color: 'rgba(251, 191, 36, 1)',
|
| 114 |
borderClass: 'border-amber-500/20',
|
| 115 |
-
labelClass: 'text-amber-
|
| 116 |
-
valueClass: 'text-amber-
|
| 117 |
},
|
| 118 |
{
|
| 119 |
key: 'unusable', label: '不可用', value: counts.value.unusable,
|
| 120 |
color: 'rgba(244, 63, 94, 1)',
|
| 121 |
borderClass: 'border-rose-500/20',
|
| 122 |
-
labelClass: 'text-rose-
|
| 123 |
-
valueClass: 'text-rose-
|
| 124 |
},
|
| 125 |
])
|
| 126 |
|
|
@@ -147,46 +142,41 @@ const masterTone = computed(() => {
|
|
| 147 |
// Round 11:subscription_grace = healthy=True 但 grace 期内,渲染橙色提示 (而非 healthy 绿色)
|
| 148 |
if (r === 'subscription_grace') {
|
| 149 |
return {
|
| 150 |
-
bg: '
|
| 151 |
-
border: 'border-orange-
|
| 152 |
-
iconWrap: 'bg-orange-
|
| 153 |
-
|
| 154 |
-
text: 'text-orange-200',
|
| 155 |
}
|
| 156 |
}
|
| 157 |
if (!m || m.healthy === true || r === 'active') {
|
| 158 |
return {
|
| 159 |
-
bg: '
|
| 160 |
-
border: 'border-emerald-
|
| 161 |
-
iconWrap: 'bg-emerald-
|
| 162 |
-
|
| 163 |
-
text: 'text-emerald-200',
|
| 164 |
}
|
| 165 |
}
|
| 166 |
if (r === 'subscription_cancelled') {
|
| 167 |
return {
|
| 168 |
-
bg: '
|
| 169 |
-
border: 'border-orange-
|
| 170 |
-
iconWrap: 'bg-orange-
|
| 171 |
-
|
| 172 |
-
text: 'text-orange-200',
|
| 173 |
}
|
| 174 |
}
|
| 175 |
if (r === 'network_error') {
|
| 176 |
return {
|
| 177 |
-
bg: '
|
| 178 |
-
border: 'border-slate-
|
| 179 |
-
iconWrap: 'bg-slate-
|
| 180 |
-
|
| 181 |
-
text: 'text-slate-200',
|
| 182 |
}
|
| 183 |
}
|
| 184 |
return {
|
| 185 |
-
bg: '
|
| 186 |
-
border: 'border-rose-
|
| 187 |
-
iconWrap: 'bg-rose-
|
| 188 |
-
|
| 189 |
-
text: 'text-rose-200',
|
| 190 |
}
|
| 191 |
})
|
| 192 |
|
|
|
|
| 5 |
- master_health inline 状态
|
| 6 |
-->
|
| 7 |
<template>
|
| 8 |
+
<div class="glass rounded-lg p-5 lg:p-6 lift-hover relative overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
<div class="relative flex flex-col lg:flex-row items-start lg:items-center gap-6">
|
| 10 |
<!-- 左:donut + 中心总数 -->
|
| 11 |
<div class="shrink-0">
|
|
|
|
| 21 |
<!-- 中:四档数字 -->
|
| 22 |
<div class="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-3 w-full">
|
| 23 |
<div v-for="card in cards" :key="card.key"
|
| 24 |
+
class="rounded-lg p-3 border bg-surface hover:bg-ink-50 transition-all"
|
| 25 |
:class="card.borderClass">
|
| 26 |
<div class="flex items-center gap-1.5 text-[10px] uppercase tracking-widest font-semibold"
|
| 27 |
:class="card.labelClass">
|
|
|
|
| 39 |
|
| 40 |
<!-- 右:master health inline -->
|
| 41 |
<div class="shrink-0 lg:max-w-xs w-full lg:w-auto">
|
| 42 |
+
<div class="rounded-lg border p-3 flex items-start gap-3"
|
| 43 |
:class="masterTone.border" :style="{ background: masterTone.bg }">
|
| 44 |
<div class="shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
|
| 45 |
:class="masterTone.iconWrap">
|
| 46 |
+
<ShieldCheck class="w-4 h-4" :stroke-width="2" />
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
<div class="min-w-0 flex-1">
|
| 49 |
<div class="text-[10px] uppercase tracking-widest font-semibold opacity-70" :class="masterTone.text">
|
|
|
|
| 66 |
import { computed } from 'vue'
|
| 67 |
import HealthDonut from './HealthDonut.vue'
|
| 68 |
import { computeUsability, formatGraceRemain } from '../composables/useStatus.js'
|
| 69 |
+
import { ShieldCheck } from 'lucide-vue-next'
|
| 70 |
|
| 71 |
const props = defineProps({
|
| 72 |
accounts: { type: Array, default: () => [] },
|
|
|
|
| 93 |
key: 'usable', label: '可用', value: counts.value.usable,
|
| 94 |
color: 'rgba(52, 211, 153, 1)',
|
| 95 |
borderClass: 'border-emerald-500/20',
|
| 96 |
+
labelClass: 'text-emerald-700',
|
| 97 |
+
valueClass: 'text-emerald-700',
|
| 98 |
},
|
| 99 |
{
|
| 100 |
key: 'grace', label: 'Grace', value: counts.value.grace,
|
| 101 |
color: 'rgba(251, 146, 60, 1)',
|
| 102 |
borderClass: 'border-orange-500/30',
|
| 103 |
+
labelClass: 'text-orange-700',
|
| 104 |
+
valueClass: 'text-orange-700',
|
| 105 |
},
|
| 106 |
{
|
| 107 |
key: 'standby', label: '待机', value: counts.value.standby,
|
| 108 |
color: 'rgba(251, 191, 36, 1)',
|
| 109 |
borderClass: 'border-amber-500/20',
|
| 110 |
+
labelClass: 'text-amber-700',
|
| 111 |
+
valueClass: 'text-amber-700',
|
| 112 |
},
|
| 113 |
{
|
| 114 |
key: 'unusable', label: '不可用', value: counts.value.unusable,
|
| 115 |
color: 'rgba(244, 63, 94, 1)',
|
| 116 |
borderClass: 'border-rose-500/20',
|
| 117 |
+
labelClass: 'text-rose-700',
|
| 118 |
+
valueClass: 'text-rose-700',
|
| 119 |
},
|
| 120 |
])
|
| 121 |
|
|
|
|
| 142 |
// Round 11:subscription_grace = healthy=True 但 grace 期内,渲染橙色提示 (而非 healthy 绿色)
|
| 143 |
if (r === 'subscription_grace') {
|
| 144 |
return {
|
| 145 |
+
bg: '#fff7ed',
|
| 146 |
+
border: 'border-orange-200',
|
| 147 |
+
iconWrap: 'bg-orange-100 text-orange-700',
|
| 148 |
+
text: 'text-orange-800',
|
|
|
|
| 149 |
}
|
| 150 |
}
|
| 151 |
if (!m || m.healthy === true || r === 'active') {
|
| 152 |
return {
|
| 153 |
+
bg: '#ecfdf5',
|
| 154 |
+
border: 'border-emerald-200',
|
| 155 |
+
iconWrap: 'bg-emerald-100 text-emerald-700',
|
| 156 |
+
text: 'text-emerald-800',
|
|
|
|
| 157 |
}
|
| 158 |
}
|
| 159 |
if (r === 'subscription_cancelled') {
|
| 160 |
return {
|
| 161 |
+
bg: '#fff7ed',
|
| 162 |
+
border: 'border-orange-200',
|
| 163 |
+
iconWrap: 'bg-orange-100 text-orange-700',
|
| 164 |
+
text: 'text-orange-800',
|
|
|
|
| 165 |
}
|
| 166 |
}
|
| 167 |
if (r === 'network_error') {
|
| 168 |
return {
|
| 169 |
+
bg: '#f8fafc',
|
| 170 |
+
border: 'border-slate-200',
|
| 171 |
+
iconWrap: 'bg-slate-100 text-slate-700',
|
| 172 |
+
text: 'text-slate-700',
|
|
|
|
| 173 |
}
|
| 174 |
}
|
| 175 |
return {
|
| 176 |
+
bg: '#fff1f2',
|
| 177 |
+
border: 'border-rose-200',
|
| 178 |
+
iconWrap: 'bg-rose-100 text-rose-700',
|
| 179 |
+
text: 'text-rose-800',
|
|
|
|
| 180 |
}
|
| 181 |
})
|
| 182 |
|
web/src/components/Settings.vue
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="space-y-5">
|
| 3 |
<div>
|
| 4 |
-
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-
|
| 5 |
<h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">设置</h2>
|
| 6 |
-
<p class="text-sm text-
|
| 7 |
</div>
|
| 8 |
|
| 9 |
<!-- F3 — 共享父级 master-health banner -->
|
|
@@ -13,20 +13,20 @@
|
|
| 13 |
:loading="masterHealthLoading"
|
| 14 |
@refresh="onReloadMasterHealth" />
|
| 15 |
|
| 16 |
-
<div class="glass rounded-
|
| 17 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 18 |
<div>
|
| 19 |
<h2 class="text-lg font-semibold text-ink-950">管理员登录</h2>
|
| 20 |
-
<p class="text-sm text-
|
| 21 |
首次启动先在这里完成主号登录,系统会统一写入单个 state.json 文件,保存邮箱、session、workspace ID、workspace 名称;如果你走了密码登录,也会保留密码供主号 Codex 复用。
|
| 22 |
</p>
|
| 23 |
</div>
|
| 24 |
<span
|
| 25 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 26 |
:class="adminConfigured
|
| 27 |
-
? 'bg-
|
| 28 |
: adminBusy
|
| 29 |
-
? 'bg-
|
| 30 |
: 'bg-surface-hover text-ink-500 border-hairline'"
|
| 31 |
>
|
| 32 |
{{ adminConfigured ? '已配置' : adminBusy ? '登录中' : '未配置' }}
|
|
@@ -52,10 +52,10 @@
|
|
| 52 |
</div>
|
| 53 |
<div class="px-3 py-3 bg-surface-hover border border-hairline rounded-lg md:col-span-2">
|
| 54 |
<div class="text-gray-500 mb-1">Session Token</div>
|
| 55 |
-
<div v-if="props.adminStatus?.session_present" class="text-
|
| 56 |
<div v-else class="space-y-2">
|
| 57 |
<div class="text-amber-400 text-xs">未配置(Team 管理功能需要 session token)</div>
|
| 58 |
-
<div class="text-
|
| 59 |
<div>获取方式:</div>
|
| 60 |
<ol class="list-decimal list-inside space-y-1">
|
| 61 |
<li>
|
|
@@ -118,12 +118,12 @@
|
|
| 118 |
</button>
|
| 119 |
</div>
|
| 120 |
|
| 121 |
-
<div class="border border-hairline rounded-
|
| 122 |
<div class="text-sm font-medium text-ink-950">或手动导入 session_token</div>
|
| 123 |
-
<p class="text-xs text-
|
| 124 |
适合你已经在浏览器里拿到 <span class="font-mono">__Secure-next-auth.session-token</span> 的场景。系统会校验 token,并自动识别 workspace ID / 名称。
|
| 125 |
</p>
|
| 126 |
-
<div class="text-
|
| 127 |
<div>获取方式:</div>
|
| 128 |
<ol class="list-decimal list-inside space-y-1">
|
| 129 |
<li>
|
|
@@ -189,7 +189,7 @@
|
|
| 189 |
</div>
|
| 190 |
|
| 191 |
<div v-if="adminBusy" class="space-y-4">
|
| 192 |
-
<div class="text-sm text-
|
| 193 |
当前邮箱: <span class="font-mono">{{ loginEmail || props.adminStatus?.email || '-' }}</span>
|
| 194 |
</div>
|
| 195 |
|
|
@@ -224,14 +224,14 @@
|
|
| 224 |
<button
|
| 225 |
@click="submitCode"
|
| 226 |
:disabled="submitting || !code"
|
| 227 |
-
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 disabled:bg-
|
| 228 |
>
|
| 229 |
{{ submitting ? '提交中...' : '提交验证码' }}
|
| 230 |
</button>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
<div v-else-if="props.adminStatus?.login_step === 'workspace_required'" class="space-y-3">
|
| 234 |
-
<div class="text-sm text-
|
| 235 |
请选择要进入的组织 / workspace
|
| 236 |
</div>
|
| 237 |
<select
|
|
@@ -251,13 +251,13 @@
|
|
| 251 |
<button
|
| 252 |
@click="submitWorkspace"
|
| 253 |
:disabled="submitting || !workspaceOptionId"
|
| 254 |
-
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 disabled:bg-
|
| 255 |
>
|
| 256 |
{{ submitting ? '提交中...' : '确认组织选择' }}
|
| 257 |
</button>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
-
<div v-if="submitting && adminSubmittingHint" class="text-xs text-
|
| 261 |
{{ adminSubmittingHint }}
|
| 262 |
</div>
|
| 263 |
|
|
@@ -273,7 +273,7 @@
|
|
| 273 |
</div>
|
| 274 |
|
| 275 |
<div v-if="codexBusy" class="mt-4 space-y-4 border-t border-hairline pt-4">
|
| 276 |
-
<div class="text-sm text-
|
| 277 |
主号 Codex 登录继续中
|
| 278 |
</div>
|
| 279 |
|
|
@@ -314,7 +314,7 @@
|
|
| 314 |
</button>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
-
<div v-if="syncingMain && codexSubmittingHint" class="text-xs text-cyan-
|
| 318 |
{{ codexSubmittingHint }}
|
| 319 |
</div>
|
| 320 |
|
|
@@ -330,15 +330,15 @@
|
|
| 330 |
</div>
|
| 331 |
</div>
|
| 332 |
|
| 333 |
-
<div class="glass rounded-
|
| 334 |
<div class="flex items-center justify-between mb-4">
|
| 335 |
<h2 class="text-lg font-semibold text-ink-950">巡检设置</h2>
|
| 336 |
-
<span v-if="saved" class="text-xs text-
|
| 337 |
</div>
|
| 338 |
|
| 339 |
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
| 340 |
<div>
|
| 341 |
-
<label class="block text-sm text-
|
| 342 |
<div class="flex items-center gap-2">
|
| 343 |
<input v-model.number="form.interval" type="number" min="1"
|
| 344 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
@@ -346,7 +346,7 @@
|
|
| 346 |
</div>
|
| 347 |
</div>
|
| 348 |
<div>
|
| 349 |
-
<label class="block text-sm text-
|
| 350 |
<div class="flex items-center gap-2">
|
| 351 |
<input v-model.number="form.threshold" type="number" min="1" max="100"
|
| 352 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
@@ -354,7 +354,7 @@
|
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
<div>
|
| 357 |
-
<label class="block text-sm text-
|
| 358 |
<div class="flex items-center gap-2">
|
| 359 |
<input v-model.number="form.min_low" type="number" min="1"
|
| 360 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
@@ -375,18 +375,18 @@
|
|
| 375 |
</div>
|
| 376 |
|
| 377 |
<!-- SPEC-1 §FR-008 / AC-017 — 邮箱后端切换 (Settings 复用 SetupPage 4 步状态机) -->
|
| 378 |
-
<div class="glass rounded-
|
| 379 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 380 |
<div>
|
| 381 |
<h2 class="text-lg font-semibold text-ink-950">邮箱后端</h2>
|
| 382 |
-
<p class="text-sm text-
|
| 383 |
切换或重新配置临时邮箱服务(cf_temp_email / maillab)。SetupPage 完成后所有字段已落盘,这里仅在需要更换服务时使用。
|
| 384 |
</p>
|
| 385 |
</div>
|
| 386 |
<span
|
| 387 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 388 |
:class="mailFormDirty
|
| 389 |
-
? 'bg-
|
| 390 |
: 'bg-surface-hover text-ink-500 border-hairline'"
|
| 391 |
>
|
| 392 |
{{ mailFormDirty ? '有改动' : '已配置' }}
|
|
@@ -423,10 +423,10 @@
|
|
| 423 |
</div>
|
| 424 |
|
| 425 |
<!-- SPEC-2 §席位策略 + 探测节流 -->
|
| 426 |
-
<div class="glass rounded-
|
| 427 |
<div class="mb-4">
|
| 428 |
<h2 class="text-lg font-semibold text-ink-950">账号生命周期</h2>
|
| 429 |
-
<p class="text-sm text-
|
| 430 |
控制邀请席位偏好(完整 ChatGPT 席位 vs 仅 Codex 席位),以及 sync 巡检识别"被踢"的探测节流。
|
| 431 |
</p>
|
| 432 |
</div>
|
|
@@ -438,7 +438,7 @@
|
|
| 438 |
<div class="mb-4 p-3 bg-surface-hover/60 border border-hairline rounded">
|
| 439 |
<div class="flex items-center justify-between mb-2">
|
| 440 |
<span class="text-sm text-ink-950">邀请席位偏好</span>
|
| 441 |
-
<span class="text-xs text-
|
| 442 |
</div>
|
| 443 |
<div class="flex gap-2">
|
| 444 |
<button
|
|
@@ -462,7 +462,7 @@
|
|
| 462 |
<div class="text-sm text-ink-950 mb-2">sync 探测节流</div>
|
| 463 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 464 |
<div>
|
| 465 |
-
<div class="text-xs text-
|
| 466 |
<input
|
| 467 |
v-model.number="syncProbeConcurrency"
|
| 468 |
type="number"
|
|
@@ -470,7 +470,7 @@
|
|
| 470 |
class="w-full px-2 py-1.5 bg-surface-hover border border-hairline rounded text-sm text-ink-950" />
|
| 471 |
</div>
|
| 472 |
<div>
|
| 473 |
-
<div class="text-xs text-
|
| 474 |
<input
|
| 475 |
v-model.number="syncProbeCooldown"
|
| 476 |
type="number"
|
|
@@ -593,8 +593,8 @@ const lifecycleMessageClass = ref('')
|
|
| 593 |
function setLifecycleMessage(text, type = 'success') {
|
| 594 |
lifecycleMessage.value = text
|
| 595 |
lifecycleMessageClass.value = type === 'success'
|
| 596 |
-
? 'bg-
|
| 597 |
-
: 'bg-
|
| 598 |
window.clearTimeout(setLifecycleMessage._t)
|
| 599 |
setLifecycleMessage._t = window.setTimeout(() => { lifecycleMessage.value = '' }, 8000)
|
| 600 |
}
|
|
@@ -638,30 +638,24 @@ onMounted(async () => {
|
|
| 638 |
threshold: cfg.threshold,
|
| 639 |
min_low: cfg.min_low,
|
| 640 |
}
|
| 641 |
-
} catch
|
| 642 |
-
console.error('加载巡检配置失败:', e)
|
| 643 |
-
}
|
| 644 |
try {
|
| 645 |
const seat = await api.getPreferredSeatType()
|
| 646 |
preferredSeatType.value = seat?.value || 'default'
|
| 647 |
-
} catch
|
| 648 |
-
console.error('加载邀请席位偏好失败:', e)
|
| 649 |
-
}
|
| 650 |
try {
|
| 651 |
const sp = await api.getSyncProbe()
|
| 652 |
syncProbeConcurrency.value = sp?.concurrency ?? 5
|
| 653 |
syncProbeCooldown.value = sp?.cooldown_minutes ?? 30
|
| 654 |
-
} catch
|
| 655 |
-
console.error('加载 sync 探测节流失败:', e)
|
| 656 |
-
}
|
| 657 |
// F3 — 母号订阅健康度由 App 级共享 props.masterHealth,不再本组件自管
|
| 658 |
})
|
| 659 |
|
| 660 |
function setMessage(text, type = 'success') {
|
| 661 |
message.value = text
|
| 662 |
messageClass.value = type === 'success'
|
| 663 |
-
? 'bg-
|
| 664 |
-
: 'bg-
|
| 665 |
window.clearTimeout(setMessage._timer)
|
| 666 |
setMessage._timer = window.setTimeout(() => {
|
| 667 |
message.value = ''
|
|
@@ -882,9 +876,9 @@ const mailFormDirty = computed(() => {
|
|
| 882 |
|
| 883 |
function setMailMessage(text, type = 'success') {
|
| 884 |
mailMessage.value = text
|
| 885 |
-
if (type === 'success') mailMessageClass.value = 'bg-
|
| 886 |
-
else if (type === 'warning') mailMessageClass.value = 'bg-
|
| 887 |
-
else mailMessageClass.value = 'bg-
|
| 888 |
window.clearTimeout(setMailMessage._timer)
|
| 889 |
setMailMessage._timer = window.setTimeout(() => { mailMessage.value = '' }, 10000)
|
| 890 |
}
|
|
@@ -895,13 +889,13 @@ function onMailStateChange(s) {
|
|
| 895 |
|
| 896 |
function onMailVerified(payload) {
|
| 897 |
if (payload?.leakedProbe) {
|
| 898 |
-
setMailMessage(`
|
| 899 |
}
|
| 900 |
}
|
| 901 |
|
| 902 |
function onMailError(resp) {
|
| 903 |
if (resp?.soft && resp.warnings) {
|
| 904 |
-
setMailMessage('
|
| 905 |
return
|
| 906 |
}
|
| 907 |
setMailMessage(`${resp.error_code || 'ERROR'}: ${resp.message || '未知错误'}` +
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="space-y-5">
|
| 3 |
<div>
|
| 4 |
+
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-700 mb-1">Configuration</div>
|
| 5 |
<h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">设置</h2>
|
| 6 |
+
<p class="text-sm text-ink-500 mt-1">管理员登录、巡检策略、邮箱后端、生命周期参数,皆可在此调整。</p>
|
| 7 |
</div>
|
| 8 |
|
| 9 |
<!-- F3 — 共享父级 master-health banner -->
|
|
|
|
| 13 |
:loading="masterHealthLoading"
|
| 14 |
@refresh="onReloadMasterHealth" />
|
| 15 |
|
| 16 |
+
<div class="glass rounded-lg p-5">
|
| 17 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 18 |
<div>
|
| 19 |
<h2 class="text-lg font-semibold text-ink-950">管理员登录</h2>
|
| 20 |
+
<p class="text-sm text-ink-500 mt-1">
|
| 21 |
首次启动先在这里完成主号登录,系统会统一写入单个 state.json 文件,保存邮箱、session、workspace ID、workspace 名称;如果你走了密码登录,也会保留密码供主号 Codex 复用。
|
| 22 |
</p>
|
| 23 |
</div>
|
| 24 |
<span
|
| 25 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 26 |
:class="adminConfigured
|
| 27 |
+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 28 |
: adminBusy
|
| 29 |
+
? 'bg-amber-50 text-amber-700 border-amber-200'
|
| 30 |
: 'bg-surface-hover text-ink-500 border-hairline'"
|
| 31 |
>
|
| 32 |
{{ adminConfigured ? '已配置' : adminBusy ? '登录中' : '未配置' }}
|
|
|
|
| 52 |
</div>
|
| 53 |
<div class="px-3 py-3 bg-surface-hover border border-hairline rounded-lg md:col-span-2">
|
| 54 |
<div class="text-gray-500 mb-1">Session Token</div>
|
| 55 |
+
<div v-if="props.adminStatus?.session_present" class="text-emerald-700 text-xs">已配置</div>
|
| 56 |
<div v-else class="space-y-2">
|
| 57 |
<div class="text-amber-400 text-xs">未配置(Team 管理功能需要 session token)</div>
|
| 58 |
+
<div class="text-ink-500 text-xs space-y-2">
|
| 59 |
<div>获取方式:</div>
|
| 60 |
<ol class="list-decimal list-inside space-y-1">
|
| 61 |
<li>
|
|
|
|
| 118 |
</button>
|
| 119 |
</div>
|
| 120 |
|
| 121 |
+
<div class="border border-hairline rounded-lg p-4 bg-surface-hover">
|
| 122 |
<div class="text-sm font-medium text-ink-950">或手动导入 session_token</div>
|
| 123 |
+
<p class="text-xs text-ink-500 mt-1 mb-3">
|
| 124 |
适合你已经在浏览器里拿到 <span class="font-mono">__Secure-next-auth.session-token</span> 的场景。系统会校验 token,并自动识别 workspace ID / 名称。
|
| 125 |
</p>
|
| 126 |
+
<div class="text-ink-500 text-xs space-y-2 mb-3">
|
| 127 |
<div>获取方式:</div>
|
| 128 |
<ol class="list-decimal list-inside space-y-1">
|
| 129 |
<li>
|
|
|
|
| 189 |
</div>
|
| 190 |
|
| 191 |
<div v-if="adminBusy" class="space-y-4">
|
| 192 |
+
<div class="text-sm text-ink-700">
|
| 193 |
当前邮箱: <span class="font-mono">{{ loginEmail || props.adminStatus?.email || '-' }}</span>
|
| 194 |
</div>
|
| 195 |
|
|
|
|
| 224 |
<button
|
| 225 |
@click="submitCode"
|
| 226 |
:disabled="submitting || !code"
|
| 227 |
+
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 disabled:bg-ink-100 disabled:hover:bg-ink-100"
|
| 228 |
>
|
| 229 |
{{ submitting ? '提交中...' : '提交验证码' }}
|
| 230 |
</button>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
<div v-else-if="props.adminStatus?.login_step === 'workspace_required'" class="space-y-3">
|
| 234 |
+
<div class="text-sm text-ink-700">
|
| 235 |
请选择要进入的组织 / workspace
|
| 236 |
</div>
|
| 237 |
<select
|
|
|
|
| 251 |
<button
|
| 252 |
@click="submitWorkspace"
|
| 253 |
:disabled="submitting || !workspaceOptionId"
|
| 254 |
+
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-on-accent text-sm rounded-lg transition disabled:opacity-50 disabled:bg-ink-100 disabled:hover:bg-ink-100"
|
| 255 |
>
|
| 256 |
{{ submitting ? '提交中...' : '确认组织选择' }}
|
| 257 |
</button>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
+
<div v-if="submitting && adminSubmittingHint" class="text-xs text-sky-700">
|
| 261 |
{{ adminSubmittingHint }}
|
| 262 |
</div>
|
| 263 |
|
|
|
|
| 273 |
</div>
|
| 274 |
|
| 275 |
<div v-if="codexBusy" class="mt-4 space-y-4 border-t border-hairline pt-4">
|
| 276 |
+
<div class="text-sm text-ink-700">
|
| 277 |
主号 Codex 登录继续中
|
| 278 |
</div>
|
| 279 |
|
|
|
|
| 314 |
</button>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
+
<div v-if="syncingMain && codexSubmittingHint" class="text-xs text-cyan-700">
|
| 318 |
{{ codexSubmittingHint }}
|
| 319 |
</div>
|
| 320 |
|
|
|
|
| 330 |
</div>
|
| 331 |
</div>
|
| 332 |
|
| 333 |
+
<div class="glass rounded-lg p-5">
|
| 334 |
<div class="flex items-center justify-between mb-4">
|
| 335 |
<h2 class="text-lg font-semibold text-ink-950">巡检设置</h2>
|
| 336 |
+
<span v-if="saved" class="text-xs text-emerald-700 transition">已保存</span>
|
| 337 |
</div>
|
| 338 |
|
| 339 |
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
| 340 |
<div>
|
| 341 |
+
<label class="block text-sm text-ink-500 mb-1">巡检间隔</label>
|
| 342 |
<div class="flex items-center gap-2">
|
| 343 |
<input v-model.number="form.interval" type="number" min="1"
|
| 344 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
|
|
| 346 |
</div>
|
| 347 |
</div>
|
| 348 |
<div>
|
| 349 |
+
<label class="block text-sm text-ink-500 mb-1">额度阈值</label>
|
| 350 |
<div class="flex items-center gap-2">
|
| 351 |
<input v-model.number="form.threshold" type="number" min="1" max="100"
|
| 352 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
|
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
<div>
|
| 357 |
+
<label class="block text-sm text-ink-500 mb-1">触发账号数</label>
|
| 358 |
<div class="flex items-center gap-2">
|
| 359 |
<input v-model.number="form.min_low" type="number" min="1"
|
| 360 |
class="w-full px-3 py-2 bg-surface-hover border border-hairline rounded-lg text-sm text-ink-950 focus:outline-none focus:border-indigo-500" />
|
|
|
|
| 375 |
</div>
|
| 376 |
|
| 377 |
<!-- SPEC-1 §FR-008 / AC-017 — 邮箱后端切换 (Settings 复用 SetupPage 4 步状态机) -->
|
| 378 |
+
<div class="glass rounded-lg p-5">
|
| 379 |
<div class="flex items-center justify-between gap-4 mb-4">
|
| 380 |
<div>
|
| 381 |
<h2 class="text-lg font-semibold text-ink-950">邮箱后端</h2>
|
| 382 |
+
<p class="text-sm text-ink-500 mt-1">
|
| 383 |
切换或重新配置临时邮箱服务(cf_temp_email / maillab)。SetupPage 完成后所有字段已落盘,这里仅在需要更换服务时使用。
|
| 384 |
</p>
|
| 385 |
</div>
|
| 386 |
<span
|
| 387 |
class="min-w-[72px] px-3 py-1.5 rounded-full text-xs text-center whitespace-nowrap border"
|
| 388 |
:class="mailFormDirty
|
| 389 |
+
? 'bg-amber-50 text-amber-700 border-amber-200'
|
| 390 |
: 'bg-surface-hover text-ink-500 border-hairline'"
|
| 391 |
>
|
| 392 |
{{ mailFormDirty ? '有改动' : '已配置' }}
|
|
|
|
| 423 |
</div>
|
| 424 |
|
| 425 |
<!-- SPEC-2 §席位策略 + 探测节流 -->
|
| 426 |
+
<div class="glass rounded-lg p-5">
|
| 427 |
<div class="mb-4">
|
| 428 |
<h2 class="text-lg font-semibold text-ink-950">账号生命周期</h2>
|
| 429 |
+
<p class="text-sm text-ink-500 mt-1">
|
| 430 |
控制邀请席位偏好(完整 ChatGPT 席位 vs 仅 Codex 席位),以及 sync 巡检识别"被踢"的探测节流。
|
| 431 |
</p>
|
| 432 |
</div>
|
|
|
|
| 438 |
<div class="mb-4 p-3 bg-surface-hover/60 border border-hairline rounded">
|
| 439 |
<div class="flex items-center justify-between mb-2">
|
| 440 |
<span class="text-sm text-ink-950">邀请席位偏好</span>
|
| 441 |
+
<span class="text-xs text-ink-500">{{ preferredSeatType === 'codex' ? '锁定 Codex 席位' : '优先 ChatGPT 完整席位' }}</span>
|
| 442 |
</div>
|
| 443 |
<div class="flex gap-2">
|
| 444 |
<button
|
|
|
|
| 462 |
<div class="text-sm text-ink-950 mb-2">sync 探测节流</div>
|
| 463 |
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 464 |
<div>
|
| 465 |
+
<div class="text-xs text-ink-500 mb-1">并发上限 (1-16)</div>
|
| 466 |
<input
|
| 467 |
v-model.number="syncProbeConcurrency"
|
| 468 |
type="number"
|
|
|
|
| 470 |
class="w-full px-2 py-1.5 bg-surface-hover border border-hairline rounded text-sm text-ink-950" />
|
| 471 |
</div>
|
| 472 |
<div>
|
| 473 |
+
<div class="text-xs text-ink-500 mb-1">同号去重冷却 (分钟,1-1440)</div>
|
| 474 |
<input
|
| 475 |
v-model.number="syncProbeCooldown"
|
| 476 |
type="number"
|
|
|
|
| 593 |
function setLifecycleMessage(text, type = 'success') {
|
| 594 |
lifecycleMessage.value = text
|
| 595 |
lifecycleMessageClass.value = type === 'success'
|
| 596 |
+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 597 |
+
: 'bg-rose-50 text-rose-700 border-rose-200'
|
| 598 |
window.clearTimeout(setLifecycleMessage._t)
|
| 599 |
setLifecycleMessage._t = window.setTimeout(() => { lifecycleMessage.value = '' }, 8000)
|
| 600 |
}
|
|
|
|
| 638 |
threshold: cfg.threshold,
|
| 639 |
min_low: cfg.min_low,
|
| 640 |
}
|
| 641 |
+
} catch {}
|
|
|
|
|
|
|
| 642 |
try {
|
| 643 |
const seat = await api.getPreferredSeatType()
|
| 644 |
preferredSeatType.value = seat?.value || 'default'
|
| 645 |
+
} catch {}
|
|
|
|
|
|
|
| 646 |
try {
|
| 647 |
const sp = await api.getSyncProbe()
|
| 648 |
syncProbeConcurrency.value = sp?.concurrency ?? 5
|
| 649 |
syncProbeCooldown.value = sp?.cooldown_minutes ?? 30
|
| 650 |
+
} catch {}
|
|
|
|
|
|
|
| 651 |
// F3 — 母号订阅健康度由 App 级共享 props.masterHealth,不再本组件自管
|
| 652 |
})
|
| 653 |
|
| 654 |
function setMessage(text, type = 'success') {
|
| 655 |
message.value = text
|
| 656 |
messageClass.value = type === 'success'
|
| 657 |
+
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 658 |
+
: 'bg-rose-50 text-rose-700 border-rose-200'
|
| 659 |
window.clearTimeout(setMessage._timer)
|
| 660 |
setMessage._timer = window.setTimeout(() => {
|
| 661 |
message.value = ''
|
|
|
|
| 876 |
|
| 877 |
function setMailMessage(text, type = 'success') {
|
| 878 |
mailMessage.value = text
|
| 879 |
+
if (type === 'success') mailMessageClass.value = 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 880 |
+
else if (type === 'warning') mailMessageClass.value = 'bg-amber-50 text-amber-700 border-amber-200'
|
| 881 |
+
else mailMessageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 882 |
window.clearTimeout(setMailMessage._timer)
|
| 883 |
setMailMessage._timer = window.setTimeout(() => { mailMessage.value = '' }, 10000)
|
| 884 |
}
|
|
|
|
| 889 |
|
| 890 |
function onMailVerified(payload) {
|
| 891 |
if (payload?.leakedProbe) {
|
| 892 |
+
setMailMessage(`提醒: 探测邮箱回收失败,请到后台手动删除: ${payload.leakedProbe.email}`, 'warning')
|
| 893 |
}
|
| 894 |
}
|
| 895 |
|
| 896 |
function onMailError(resp) {
|
| 897 |
if (resp?.soft && resp.warnings) {
|
| 898 |
+
setMailMessage('提醒: ' + resp.warnings.join('; '), 'warning')
|
| 899 |
return
|
| 900 |
}
|
| 901 |
setMailMessage(`${resp.error_code || 'ERROR'}: ${resp.message || '未知错误'}` +
|
web/src/components/SetupPage.vue
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="min-h-screen flex items-center justify-center p-4">
|
| 3 |
-
<div class="
|
| 4 |
-
<h1 class="text-xl font-bold text-
|
| 5 |
-
<p class="text-sm text-
|
| 6 |
|
| 7 |
<div v-if="message" class="mb-4 px-4 py-3 rounded-lg text-sm border" :class="messageClass">
|
| 8 |
{{ message }}
|
|
@@ -10,27 +10,28 @@
|
|
| 10 |
|
| 11 |
<!-- 卡片 1+2+3:Mail Provider 选择 / 连接 / 域名(Round 7 P2.2 抽 MailProviderCard) -->
|
| 12 |
<MailProviderCard
|
| 13 |
-
|
| 14 |
mode="setup"
|
|
|
|
| 15 |
@state-change="onMailStateChange"
|
| 16 |
@verified="onMailVerified"
|
| 17 |
@error="onMailError" />
|
| 18 |
|
| 19 |
<!-- 卡片 4:其他配置 -->
|
| 20 |
<div class="mb-4 p-4 rounded-lg border" :class="cardClass('SAVE')">
|
| 21 |
-
<h2 class="text-sm font-semibold text-
|
| 22 |
<div class="space-y-3" :class="state !== 'SAVE' ? 'opacity-40 pointer-events-none' : ''">
|
| 23 |
<div v-for="field in otherFields" :key="field.key">
|
| 24 |
-
<label class="block text-xs text-
|
| 25 |
{{ field.prompt }}
|
| 26 |
<span v-if="!field.optional" class="text-red-400">*</span>
|
| 27 |
-
<span v-if="field.key === 'API_KEY'" class="text-
|
| 28 |
</label>
|
| 29 |
<input
|
| 30 |
v-model="form[field.key]"
|
| 31 |
:type="field.key.includes('PASSWORD') || field.key.includes('KEY') ? 'password' : 'text'"
|
| 32 |
:placeholder="field.default || ''"
|
| 33 |
-
class="w-full px-3 py-2 bg-
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
</div>
|
|
@@ -38,7 +39,7 @@
|
|
| 38 |
<button
|
| 39 |
@click="save"
|
| 40 |
:disabled="saving || state !== 'SAVE'"
|
| 41 |
-
class="w-full px-4 py-2.5 bg-
|
| 42 |
{{ saving ? '验证并保存中...' : '保存配置' }}
|
| 43 |
</button>
|
| 44 |
</div>
|
|
@@ -85,31 +86,35 @@ function cardClass(targetState) {
|
|
| 85 |
const order = ['PROVIDER', 'CONNECTION', 'DOMAIN', 'SAVE']
|
| 86 |
const cur = order.indexOf(state.value)
|
| 87 |
const tgt = order.indexOf(targetState)
|
| 88 |
-
if (tgt > cur) return 'bg-
|
| 89 |
-
if (tgt < cur) return 'bg-
|
| 90 |
-
return 'bg-
|
| 91 |
}
|
| 92 |
|
| 93 |
function onMailStateChange(s) {
|
| 94 |
state.value = s
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
function onMailVerified(payload) {
|
| 98 |
if (payload?.leakedProbe) {
|
| 99 |
-
message.value = `
|
| 100 |
-
messageClass.value = 'bg-
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
| 104 |
function onMailError(resp) {
|
| 105 |
if (resp?.soft && resp.warnings) {
|
| 106 |
-
message.value = '
|
| 107 |
-
messageClass.value = 'bg-
|
| 108 |
return
|
| 109 |
}
|
| 110 |
message.value = `${resp.error_code || 'ERROR'}: ${resp.message || '未知错误'}` +
|
| 111 |
(resp.hint ? ` — ${resp.hint}` : '')
|
| 112 |
-
messageClass.value = 'bg-
|
| 113 |
}
|
| 114 |
|
| 115 |
async function save() {
|
|
@@ -119,11 +124,11 @@ async function save() {
|
|
| 119 |
const result = await api.saveSetup({ ...form })
|
| 120 |
if (result.api_key) setApiKey(result.api_key)
|
| 121 |
message.value = result.message || '保存成功'
|
| 122 |
-
messageClass.value = 'bg-
|
| 123 |
setTimeout(() => emit('configured'), 1000)
|
| 124 |
} catch (e) {
|
| 125 |
message.value = e.message
|
| 126 |
-
messageClass.value = 'bg-
|
| 127 |
} finally {
|
| 128 |
saving.value = false
|
| 129 |
}
|
|
@@ -141,7 +146,7 @@ onMounted(async () => {
|
|
| 141 |
if (result.provider) form.MAIL_PROVIDER = result.provider
|
| 142 |
} catch (e) {
|
| 143 |
message.value = '获取配置状态失败: ' + e.message
|
| 144 |
-
messageClass.value = 'bg-
|
| 145 |
}
|
| 146 |
})
|
| 147 |
</script>
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="min-h-screen flex items-center justify-center p-4">
|
| 3 |
+
<div class="glass rounded-lg p-6 w-full max-w-2xl">
|
| 4 |
+
<h1 class="text-xl font-bold text-ink-950 text-center mb-2">AutoTeam 初始配置</h1>
|
| 5 |
+
<p class="text-sm text-ink-500 text-center mb-6">首次使用请按步骤填写以下配置</p>
|
| 6 |
|
| 7 |
<div v-if="message" class="mb-4 px-4 py-3 rounded-lg text-sm border" :class="messageClass">
|
| 8 |
{{ message }}
|
|
|
|
| 10 |
|
| 11 |
<!-- 卡片 1+2+3:Mail Provider 选择 / 连接 / 域名(Round 7 P2.2 抽 MailProviderCard) -->
|
| 12 |
<MailProviderCard
|
| 13 |
+
:model-value="form"
|
| 14 |
mode="setup"
|
| 15 |
+
@update:model-value="updateMailForm"
|
| 16 |
@state-change="onMailStateChange"
|
| 17 |
@verified="onMailVerified"
|
| 18 |
@error="onMailError" />
|
| 19 |
|
| 20 |
<!-- 卡片 4:其他配置 -->
|
| 21 |
<div class="mb-4 p-4 rounded-lg border" :class="cardClass('SAVE')">
|
| 22 |
+
<h2 class="text-sm font-semibold text-ink-950 mb-3">4. 其他配置</h2>
|
| 23 |
<div class="space-y-3" :class="state !== 'SAVE' ? 'opacity-40 pointer-events-none' : ''">
|
| 24 |
<div v-for="field in otherFields" :key="field.key">
|
| 25 |
+
<label class="block text-xs text-ink-600 mb-1">
|
| 26 |
{{ field.prompt }}
|
| 27 |
<span v-if="!field.optional" class="text-red-400">*</span>
|
| 28 |
+
<span v-if="field.key === 'API_KEY'" class="text-ink-500 text-[10px] ml-1">(留空自动生成)</span>
|
| 29 |
</label>
|
| 30 |
<input
|
| 31 |
v-model="form[field.key]"
|
| 32 |
:type="field.key.includes('PASSWORD') || field.key.includes('KEY') ? 'password' : 'text'"
|
| 33 |
:placeholder="field.default || ''"
|
| 34 |
+
class="w-full px-3 py-2 bg-surface border border-hairline rounded text-sm text-ink-950 focus-ring" />
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
</div>
|
|
|
|
| 39 |
<button
|
| 40 |
@click="save"
|
| 41 |
:disabled="saving || state !== 'SAVE'"
|
| 42 |
+
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-on-accent text-sm font-medium rounded-lg transition focus-ring">
|
| 43 |
{{ saving ? '验证并保存中...' : '保存配置' }}
|
| 44 |
</button>
|
| 45 |
</div>
|
|
|
|
| 86 |
const order = ['PROVIDER', 'CONNECTION', 'DOMAIN', 'SAVE']
|
| 87 |
const cur = order.indexOf(state.value)
|
| 88 |
const tgt = order.indexOf(targetState)
|
| 89 |
+
if (tgt > cur) return 'bg-ink-50 border-hairline'
|
| 90 |
+
if (tgt < cur) return 'bg-emerald-50 border-emerald-200'
|
| 91 |
+
return 'bg-indigo-50 border-indigo-200'
|
| 92 |
}
|
| 93 |
|
| 94 |
function onMailStateChange(s) {
|
| 95 |
state.value = s
|
| 96 |
}
|
| 97 |
|
| 98 |
+
function updateMailForm(value) {
|
| 99 |
+
Object.assign(form, value || {})
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
function onMailVerified(payload) {
|
| 103 |
if (payload?.leakedProbe) {
|
| 104 |
+
message.value = `提醒: 探测邮箱回收失败,请到后台手动删除: ${payload.leakedProbe.email}`
|
| 105 |
+
messageClass.value = 'bg-amber-50 text-amber-800 border-amber-200'
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
| 109 |
function onMailError(resp) {
|
| 110 |
if (resp?.soft && resp.warnings) {
|
| 111 |
+
message.value = '提醒: ' + resp.warnings.join('; ')
|
| 112 |
+
messageClass.value = 'bg-amber-50 text-amber-800 border-amber-200'
|
| 113 |
return
|
| 114 |
}
|
| 115 |
message.value = `${resp.error_code || 'ERROR'}: ${resp.message || '未知错误'}` +
|
| 116 |
(resp.hint ? ` — ${resp.hint}` : '')
|
| 117 |
+
messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 118 |
}
|
| 119 |
|
| 120 |
async function save() {
|
|
|
|
| 124 |
const result = await api.saveSetup({ ...form })
|
| 125 |
if (result.api_key) setApiKey(result.api_key)
|
| 126 |
message.value = result.message || '保存成功'
|
| 127 |
+
messageClass.value = 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 128 |
setTimeout(() => emit('configured'), 1000)
|
| 129 |
} catch (e) {
|
| 130 |
message.value = e.message
|
| 131 |
+
messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 132 |
} finally {
|
| 133 |
saving.value = false
|
| 134 |
}
|
|
|
|
| 146 |
if (result.provider) form.MAIL_PROVIDER = result.provider
|
| 147 |
} catch (e) {
|
| 148 |
message.value = '获取配置状态失败: ' + e.message
|
| 149 |
+
messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 150 |
}
|
| 151 |
})
|
| 152 |
</script>
|
web/src/components/Sidebar.vue
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
|
| 8 |
<div class="mb-8 px-2">
|
| 9 |
<div class="flex items-center gap-2 mb-1">
|
| 10 |
-
<span class="inline-block w-2 h-2 rounded-sm bg-
|
| 11 |
<h1 class="text-lg font-extrabold text-ink-950 tracking-tight">AutoTeam</h1>
|
| 12 |
</div>
|
| 13 |
<p class="text-[10px] uppercase tracking-[0.2em] text-ink-400 ml-4">Operations Console</p>
|
|
@@ -16,13 +16,13 @@
|
|
| 16 |
<div class="space-y-0.5 flex-1">
|
| 17 |
<button v-for="item in items" :key="item.key"
|
| 18 |
@click="$emit('navigate', item.key)"
|
| 19 |
-
class="w-full text-left px-3 py-2 rounded-
|
| 20 |
relative group focus-ring"
|
| 21 |
:class="active === item.key
|
| 22 |
? 'bg-indigo-50 text-indigo-700'
|
| 23 |
: 'text-ink-600 hover:bg-ink-100 hover:text-ink-950'">
|
| 24 |
<span v-if="active === item.key" class="absolute left-0 top-2 bottom-2 w-0.5 rounded-r-full
|
| 25 |
-
bg-
|
| 26 |
<component :is="item.icon" class="w-4 h-4 shrink-0" :stroke-width="2" />
|
| 27 |
<span class="font-medium">{{ item.label }}</span>
|
| 28 |
</button>
|
|
@@ -30,13 +30,13 @@
|
|
| 30 |
|
| 31 |
<div class="space-y-0.5 pt-4 border-t border-hairline">
|
| 32 |
<button @click="$emit('refresh')" :disabled="loading"
|
| 33 |
-
class="w-full text-left px-3 py-2 rounded-
|
| 34 |
text-ink-600 hover:bg-ink-100 hover:text-ink-950 disabled:opacity-50 focus-ring">
|
| 35 |
<RefreshCw class="w-4 h-4 shrink-0" :class="loading ? 'animate-spin' : ''" :stroke-width="2" />
|
| 36 |
<span class="font-medium">{{ loading ? '刷新中…' : '刷新数据' }}</span>
|
| 37 |
</button>
|
| 38 |
<button v-if="authRequired" @click="$emit('logout')"
|
| 39 |
-
class="w-full text-left px-3 py-2 rounded-
|
| 40 |
text-ink-600 hover:bg-rose-50 hover:text-rose-700 focus-ring">
|
| 41 |
<LogOut class="w-4 h-4 shrink-0" :stroke-width="2" />
|
| 42 |
<span class="font-medium">登出</span>
|
|
@@ -52,7 +52,7 @@
|
|
| 52 |
class="flex-1 flex flex-col items-center py-2 text-[10px] transition relative"
|
| 53 |
:class="active === item.key ? 'text-indigo-700' : 'text-ink-500 hover:text-ink-700'">
|
| 54 |
<span v-if="active === item.key"
|
| 55 |
-
class="absolute top-0 left-1/4 right-1/4 h-0.5 rounded-b-full bg-
|
| 56 |
<component :is="item.icon" class="w-5 h-5" :stroke-width="2" />
|
| 57 |
<span class="mt-0.5 font-medium">{{ item.mobileLabel || item.label }}</span>
|
| 58 |
</button>
|
|
|
|
| 7 |
|
| 8 |
<div class="mb-8 px-2">
|
| 9 |
<div class="flex items-center gap-2 mb-1">
|
| 10 |
+
<span class="inline-block w-2 h-2 rounded-sm bg-indigo-600"></span>
|
| 11 |
<h1 class="text-lg font-extrabold text-ink-950 tracking-tight">AutoTeam</h1>
|
| 12 |
</div>
|
| 13 |
<p class="text-[10px] uppercase tracking-[0.2em] text-ink-400 ml-4">Operations Console</p>
|
|
|
|
| 16 |
<div class="space-y-0.5 flex-1">
|
| 17 |
<button v-for="item in items" :key="item.key"
|
| 18 |
@click="$emit('navigate', item.key)"
|
| 19 |
+
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-all flex items-center gap-2.5
|
| 20 |
relative group focus-ring"
|
| 21 |
:class="active === item.key
|
| 22 |
? 'bg-indigo-50 text-indigo-700'
|
| 23 |
: 'text-ink-600 hover:bg-ink-100 hover:text-ink-950'">
|
| 24 |
<span v-if="active === item.key" class="absolute left-0 top-2 bottom-2 w-0.5 rounded-r-full
|
| 25 |
+
bg-indigo-600"></span>
|
| 26 |
<component :is="item.icon" class="w-4 h-4 shrink-0" :stroke-width="2" />
|
| 27 |
<span class="font-medium">{{ item.label }}</span>
|
| 28 |
</button>
|
|
|
|
| 30 |
|
| 31 |
<div class="space-y-0.5 pt-4 border-t border-hairline">
|
| 32 |
<button @click="$emit('refresh')" :disabled="loading"
|
| 33 |
+
class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center gap-2.5
|
| 34 |
text-ink-600 hover:bg-ink-100 hover:text-ink-950 disabled:opacity-50 focus-ring">
|
| 35 |
<RefreshCw class="w-4 h-4 shrink-0" :class="loading ? 'animate-spin' : ''" :stroke-width="2" />
|
| 36 |
<span class="font-medium">{{ loading ? '刷新中…' : '刷新数据' }}</span>
|
| 37 |
</button>
|
| 38 |
<button v-if="authRequired" @click="$emit('logout')"
|
| 39 |
+
class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center gap-2.5
|
| 40 |
text-ink-600 hover:bg-rose-50 hover:text-rose-700 focus-ring">
|
| 41 |
<LogOut class="w-4 h-4 shrink-0" :stroke-width="2" />
|
| 42 |
<span class="font-medium">登出</span>
|
|
|
|
| 52 |
class="flex-1 flex flex-col items-center py-2 text-[10px] transition relative"
|
| 53 |
:class="active === item.key ? 'text-indigo-700' : 'text-ink-500 hover:text-ink-700'">
|
| 54 |
<span v-if="active === item.key"
|
| 55 |
+
class="absolute top-0 left-1/4 right-1/4 h-0.5 rounded-b-full bg-indigo-600"></span>
|
| 56 |
<component :is="item.icon" class="w-5 h-5" :stroke-width="2" />
|
| 57 |
<span class="mt-0.5 font-medium">{{ item.mobileLabel || item.label }}</span>
|
| 58 |
</button>
|
web/src/components/StatusBadge.vue
CHANGED
|
@@ -51,7 +51,7 @@ const label = computed(() => statusLabel(props.status))
|
|
| 51 |
const isGrace = computed(() => props.status === 'degraded_grace')
|
| 52 |
const remainText = computed(() => (isGrace.value ? formatGraceRemain(props.graceUntil) : ''))
|
| 53 |
const graceDate = computed(() => formatGraceDate(props.graceUntil))
|
| 54 |
-
const urgencyClass = computed(() => `${graceUrgencyClass(props.graceUntil)} bg-
|
| 55 |
const isGraceUrgent = computed(() => {
|
| 56 |
if (!isGrace.value) return false
|
| 57 |
const ms = graceRemainMs(props.graceUntil)
|
|
@@ -62,7 +62,8 @@ const isGraceUrgent = computed(() => {
|
|
| 62 |
const bgStyle = computed(() => {
|
| 63 |
const map = {
|
| 64 |
active: 'linear-gradient(135deg, rgba(52, 211, 153, 0.20) 0%, rgba(20, 184, 166, 0.12) 100%)',
|
| 65 |
-
personal: 'linear-gradient(135deg, rgba(
|
|
|
|
| 66 |
standby: 'linear-gradient(135deg, rgba(251, 191, 36, 0.18) 0%, rgba(245, 158, 11, 0.10) 100%)',
|
| 67 |
degraded_grace: 'linear-gradient(135deg, rgba(251, 191, 36, 0.25) 0%, rgba(249, 115, 22, 0.22) 50%, rgba(244, 63, 94, 0.20) 100%)',
|
| 68 |
auth_invalid: 'linear-gradient(135deg, rgba(244, 63, 94, 0.18) 0%, rgba(239, 68, 68, 0.12) 100%)',
|
|
|
|
| 51 |
const isGrace = computed(() => props.status === 'degraded_grace')
|
| 52 |
const remainText = computed(() => (isGrace.value ? formatGraceRemain(props.graceUntil) : ''))
|
| 53 |
const graceDate = computed(() => formatGraceDate(props.graceUntil))
|
| 54 |
+
const urgencyClass = computed(() => `${graceUrgencyClass(props.graceUntil)} bg-surface border border-hairline`)
|
| 55 |
const isGraceUrgent = computed(() => {
|
| 56 |
if (!isGrace.value) return false
|
| 57 |
const ms = graceRemainMs(props.graceUntil)
|
|
|
|
| 62 |
const bgStyle = computed(() => {
|
| 63 |
const map = {
|
| 64 |
active: 'linear-gradient(135deg, rgba(52, 211, 153, 0.20) 0%, rgba(20, 184, 166, 0.12) 100%)',
|
| 65 |
+
personal: 'linear-gradient(135deg, rgba(56, 189, 248, 0.20) 0%, rgba(59, 130, 246, 0.12) 100%)',
|
| 66 |
+
disabled: 'linear-gradient(135deg, rgba(214, 211, 209, 0.70) 0%, rgba(241, 245, 249, 0.60) 100%)',
|
| 67 |
standby: 'linear-gradient(135deg, rgba(251, 191, 36, 0.18) 0%, rgba(245, 158, 11, 0.10) 100%)',
|
| 68 |
degraded_grace: 'linear-gradient(135deg, rgba(251, 191, 36, 0.25) 0%, rgba(249, 115, 22, 0.22) 50%, rgba(244, 63, 94, 0.20) 100%)',
|
| 69 |
auth_invalid: 'linear-gradient(135deg, rgba(244, 63, 94, 0.18) 0%, rgba(239, 68, 68, 0.12) 100%)',
|
web/src/components/SyncPage.vue
CHANGED
|
@@ -1,14 +1,15 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="space-y-5">
|
| 3 |
<div>
|
| 4 |
-
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-
|
| 5 |
-
<h2 class="text-2xl font-extrabold text-
|
| 6 |
-
<p class="text-sm text-
|
| 7 |
</div>
|
| 8 |
<TaskPanel
|
| 9 |
mode="sync"
|
| 10 |
:running-task="runningTask"
|
| 11 |
:admin-status="adminStatus"
|
|
|
|
| 12 |
@task-started="$emit('task-started')"
|
| 13 |
@refresh="$emit('refresh')"
|
| 14 |
/>
|
|
@@ -21,6 +22,7 @@ import TaskPanel from './TaskPanel.vue'
|
|
| 21 |
defineProps({
|
| 22 |
runningTask: Object,
|
| 23 |
adminStatus: Object,
|
|
|
|
| 24 |
})
|
| 25 |
|
| 26 |
defineEmits(['task-started', 'refresh'])
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="space-y-5">
|
| 3 |
<div>
|
| 4 |
+
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-700 mb-1">Sync Center</div>
|
| 5 |
+
<h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">同步中心</h2>
|
| 6 |
+
<p class="text-sm text-ink-500 mt-1">本地账号池对账、同步到 CPA、从 CPA 反向拉取认证文件。</p>
|
| 7 |
</div>
|
| 8 |
<TaskPanel
|
| 9 |
mode="sync"
|
| 10 |
:running-task="runningTask"
|
| 11 |
:admin-status="adminStatus"
|
| 12 |
+
:rotate-stream="rotateStream"
|
| 13 |
@task-started="$emit('task-started')"
|
| 14 |
@refresh="$emit('refresh')"
|
| 15 |
/>
|
|
|
|
| 22 |
defineProps({
|
| 23 |
runningTask: Object,
|
| 24 |
adminStatus: Object,
|
| 25 |
+
rotateStream: { type: Object, default: null },
|
| 26 |
})
|
| 27 |
|
| 28 |
defineEmits(['task-started', 'refresh'])
|
web/src/components/TaskHistory.vue
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
<template>
|
| 2 |
-
<div class="mt-6
|
| 3 |
-
<div class="px-4 py-3 border-b border-
|
| 4 |
-
<h2 class="text-lg font-semibold text-
|
|
|
|
|
|
|
|
|
|
| 5 |
</div>
|
| 6 |
|
| 7 |
<div v-if="tasks.length === 0" class="px-4 py-8 text-center text-gray-500 text-sm">
|
|
@@ -11,7 +14,7 @@
|
|
| 11 |
<div v-else class="overflow-x-auto">
|
| 12 |
<table class="w-full text-sm">
|
| 13 |
<thead>
|
| 14 |
-
<tr class="text-
|
| 15 |
<th class="px-4 py-3 font-medium">任务 ID</th>
|
| 16 |
<th class="px-4 py-3 font-medium">命令</th>
|
| 17 |
<th class="px-4 py-3 font-medium">参数</th>
|
|
@@ -22,15 +25,15 @@
|
|
| 22 |
</tr>
|
| 23 |
</thead>
|
| 24 |
<tbody>
|
| 25 |
-
<tr v-for="task in
|
| 26 |
-
class="border-b border-
|
| 27 |
-
<td class="px-4 py-3 font-mono text-xs text-
|
| 28 |
<td class="px-4 py-3">
|
| 29 |
-
<span class="px-2 py-0.5 bg-
|
| 30 |
{{ task.command }}
|
| 31 |
</span>
|
| 32 |
</td>
|
| 33 |
-
<td class="px-4 py-3 text-xs text-
|
| 34 |
<td class="px-4 py-3">
|
| 35 |
<span class="inline-flex items-center gap-1.5 text-xs font-medium" :class="taskStatusClass(task.status)">
|
| 36 |
<span v-if="task.status === 'running'" class="animate-spin inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full"></span>
|
|
@@ -38,9 +41,9 @@
|
|
| 38 |
{{ taskStatusLabel(task.status) }}
|
| 39 |
</span>
|
| 40 |
</td>
|
| 41 |
-
<td class="px-4 py-3 text-xs text-
|
| 42 |
-
<td class="px-4 py-3 text-xs text-
|
| 43 |
-
<td class="px-4 py-3 text-xs max-w-xs truncate" :class="task.error ? 'text-
|
| 44 |
{{ task.error || formatResult(task.result) }}
|
| 45 |
</td>
|
| 46 |
</tr>
|
|
@@ -51,25 +54,30 @@
|
|
| 51 |
</template>
|
| 52 |
|
| 53 |
<script setup>
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
tasks: { type: Array, default: () => [] },
|
| 56 |
})
|
| 57 |
|
|
|
|
|
|
|
|
|
|
| 58 |
function taskStatusClass(s) {
|
| 59 |
return {
|
| 60 |
-
pending: 'text-
|
| 61 |
-
running: 'text-
|
| 62 |
-
completed: 'text-
|
| 63 |
-
failed: 'text-
|
| 64 |
-
}[s] || 'text-
|
| 65 |
}
|
| 66 |
|
| 67 |
function taskDotClass(s) {
|
| 68 |
return {
|
| 69 |
-
pending: 'bg-
|
| 70 |
-
completed: 'bg-
|
| 71 |
-
failed: 'bg-
|
| 72 |
-
}[s] || 'bg-
|
| 73 |
}
|
| 74 |
|
| 75 |
function taskStatusLabel(s) {
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<div class="mt-6 glass rounded-lg overflow-hidden">
|
| 3 |
+
<div class="px-4 py-3 border-b border-hairline">
|
| 4 |
+
<h2 class="text-lg font-semibold text-ink-950">任务历史</h2>
|
| 5 |
+
<p v-if="hiddenTaskCount" class="text-xs text-ink-500 mt-1">
|
| 6 |
+
仅显示最新 {{ visibleTasks.length }} 条,已隐藏 {{ hiddenTaskCount }} 条较早记录。
|
| 7 |
+
</p>
|
| 8 |
</div>
|
| 9 |
|
| 10 |
<div v-if="tasks.length === 0" class="px-4 py-8 text-center text-gray-500 text-sm">
|
|
|
|
| 14 |
<div v-else class="overflow-x-auto">
|
| 15 |
<table class="w-full text-sm">
|
| 16 |
<thead>
|
| 17 |
+
<tr class="text-ink-500 text-left border-b border-hairline">
|
| 18 |
<th class="px-4 py-3 font-medium">任务 ID</th>
|
| 19 |
<th class="px-4 py-3 font-medium">命令</th>
|
| 20 |
<th class="px-4 py-3 font-medium">参数</th>
|
|
|
|
| 25 |
</tr>
|
| 26 |
</thead>
|
| 27 |
<tbody>
|
| 28 |
+
<tr v-for="task in visibleTasks" :key="task.task_id"
|
| 29 |
+
class="border-b border-hairline hover:bg-ink-50 transition">
|
| 30 |
+
<td class="px-4 py-3 font-mono text-xs text-ink-500">{{ task.task_id }}</td>
|
| 31 |
<td class="px-4 py-3">
|
| 32 |
+
<span class="px-2 py-0.5 bg-ink-50 rounded text-xs font-medium text-ink-700 border border-hairline">
|
| 33 |
{{ task.command }}
|
| 34 |
</span>
|
| 35 |
</td>
|
| 36 |
+
<td class="px-4 py-3 text-xs text-ink-500">{{ formatParams(task.params) }}</td>
|
| 37 |
<td class="px-4 py-3">
|
| 38 |
<span class="inline-flex items-center gap-1.5 text-xs font-medium" :class="taskStatusClass(task.status)">
|
| 39 |
<span v-if="task.status === 'running'" class="animate-spin inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full"></span>
|
|
|
|
| 41 |
{{ taskStatusLabel(task.status) }}
|
| 42 |
</span>
|
| 43 |
</td>
|
| 44 |
+
<td class="px-4 py-3 text-xs text-ink-500">{{ formatTime(task.created_at) }}</td>
|
| 45 |
+
<td class="px-4 py-3 text-xs text-ink-500">{{ duration(task) }}</td>
|
| 46 |
+
<td class="px-4 py-3 text-xs max-w-xs truncate" :class="task.error ? 'text-rose-700' : 'text-ink-500'">
|
| 47 |
{{ task.error || formatResult(task.result) }}
|
| 48 |
</td>
|
| 49 |
</tr>
|
|
|
|
| 54 |
</template>
|
| 55 |
|
| 56 |
<script setup>
|
| 57 |
+
import { computed } from 'vue'
|
| 58 |
+
|
| 59 |
+
const props = defineProps({
|
| 60 |
tasks: { type: Array, default: () => [] },
|
| 61 |
})
|
| 62 |
|
| 63 |
+
const visibleTasks = computed(() => props.tasks.slice(0, 200))
|
| 64 |
+
const hiddenTaskCount = computed(() => Math.max(0, props.tasks.length - visibleTasks.value.length))
|
| 65 |
+
|
| 66 |
function taskStatusClass(s) {
|
| 67 |
return {
|
| 68 |
+
pending: 'text-ink-500',
|
| 69 |
+
running: 'text-amber-700',
|
| 70 |
+
completed: 'text-emerald-700',
|
| 71 |
+
failed: 'text-rose-700',
|
| 72 |
+
}[s] || 'text-ink-500'
|
| 73 |
}
|
| 74 |
|
| 75 |
function taskDotClass(s) {
|
| 76 |
return {
|
| 77 |
+
pending: 'bg-ink-400',
|
| 78 |
+
completed: 'bg-emerald-500',
|
| 79 |
+
failed: 'bg-rose-500',
|
| 80 |
+
}[s] || 'bg-ink-400'
|
| 81 |
}
|
| 82 |
|
| 83 |
function taskStatusLabel(s) {
|
web/src/components/TaskHistoryPage.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
-
<h2 class="text-xl font-bold text-
|
| 4 |
-
<p class="text-sm text-
|
| 5 |
查看后台任务的执行状态、耗时、参数和结果,便于排查失败原因。
|
| 6 |
</p>
|
| 7 |
<TaskHistory :tasks="tasks" />
|
|
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
+
<h2 class="text-xl font-bold text-ink-950 mb-2">任务历史</h2>
|
| 4 |
+
<p class="text-sm text-ink-500 mb-6">
|
| 5 |
查看后台任务的执行状态、耗时、参数和结果,便于排查失败原因。
|
| 6 |
</p>
|
| 7 |
<TaskHistory :tasks="tasks" />
|
web/src/components/TaskPanel.vue
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
<template>
|
| 2 |
-
<div class="glass rounded-
|
| 3 |
<div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
| 4 |
<div>
|
| 5 |
-
<h2 class="text-base font-bold text-
|
| 6 |
-
<p class="text-[11px] text-
|
| 7 |
</div>
|
| 8 |
<div v-if="runningTask" class="flex items-center gap-2 text-xs">
|
| 9 |
-
<span class="text-
|
| 10 |
-
<span class="font-mono text-amber-
|
| 11 |
-
<span class="font-mono text-
|
| 12 |
<AtButton variant="danger" size="sm" :loading="cancelling" :disabled="cancelRequested" @click="cancelTask">
|
| 13 |
{{ cancelRequested ? '停止中…' : '停止任务' }}
|
| 14 |
</AtButton>
|
|
@@ -16,7 +16,7 @@
|
|
| 16 |
</div>
|
| 17 |
|
| 18 |
<div v-if="showAdminHint"
|
| 19 |
-
class="mb-4 px-4 py-2.5 rounded-
|
| 20 |
{{ adminHint }}
|
| 21 |
</div>
|
| 22 |
|
|
@@ -25,21 +25,22 @@
|
|
| 25 |
<button v-for="action in visibleActions" :key="action.key"
|
| 26 |
@click="execute(action)"
|
| 27 |
:disabled="isDisabled(action)"
|
| 28 |
-
class="relative h-10 px-4 rounded-
|
| 29 |
lift-hover focus-ring select-none whitespace-nowrap
|
| 30 |
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-ink-100 disabled:text-ink-400 disabled:border-hairline"
|
| 31 |
:class="actionColorClass(action)">
|
| 32 |
<span class="inline-flex items-center gap-2">
|
| 33 |
-
<
|
| 34 |
-
|
|
|
|
| 35 |
</span>
|
| 36 |
</button>
|
| 37 |
</div>
|
| 38 |
|
| 39 |
<!-- round-12 F2 — rotate 实时进度面板 (SSE) -->
|
| 40 |
-
<div class="mt-4 rounded-
|
| 41 |
<button type="button"
|
| 42 |
-
class="w-full flex items-center justify-between gap-3 px-4 py-2.5 text-sm focus-ring rounded-
|
| 43 |
@click="showProgress = !showProgress">
|
| 44 |
<span class="inline-flex items-center gap-2 font-semibold text-ink-700">
|
| 45 |
<Activity class="w-4 h-4" :class="rotateStream.isConnected.value ? 'text-emerald-600' : 'text-ink-400'" :stroke-width="2" />
|
|
@@ -78,30 +79,30 @@
|
|
| 78 |
|
| 79 |
<!-- 注册域名切换(仅 pool 模式可见) -->
|
| 80 |
<div v-if="mode === 'pool'"
|
| 81 |
-
class="mt-5 p-3 rounded-
|
| 82 |
-
<span class="text-[10px] uppercase tracking-widest text-
|
| 83 |
-
<span class="text-
|
| 84 |
<input v-model="domainInput" type="text" placeholder="your-domain.com"
|
| 85 |
-
class="flex-1 min-w-[180px] px-3 py-1.5 bg-
|
| 86 |
<AtButton variant="primary" size="sm" :loading="domainBusy" :disabled="!domainInput" @click="saveDomain">
|
| 87 |
保存并验证
|
| 88 |
</AtButton>
|
| 89 |
-
<span v-if="currentDomain" class="text-[11px] text-
|
| 90 |
-
<span v-if="domainMsg" class="ml-1 text-[11px] font-medium" :class="domainMsgOk ? 'text-emerald-
|
| 91 |
</div>
|
| 92 |
|
| 93 |
<!-- 参数输入 -->
|
| 94 |
<div v-if="showParams"
|
| 95 |
-
class="mt-4 p-3 rounded-
|
| 96 |
-
<label class="text-[11px] uppercase tracking-widest text-indigo-
|
| 97 |
<input v-model.number="paramValue" type="number" min="1" :max="paramMax"
|
| 98 |
-
class="w-24 px-3 py-1.5 bg-
|
| 99 |
-
<AtButton variant="primary" size="sm" @click="confirmAction">确认执行</AtButton>
|
| 100 |
<AtButton variant="ghost" size="sm" @click="showParams = false">取消</AtButton>
|
| 101 |
</div>
|
| 102 |
|
| 103 |
<!-- 结果提示 -->
|
| 104 |
-
<div v-if="message" class="mt-4 px-4 py-2.5 rounded-
|
| 105 |
{{ message }}
|
| 106 |
</div>
|
| 107 |
</div>
|
|
@@ -127,7 +128,7 @@ import {
|
|
| 127 |
} from 'lucide-vue-next'
|
| 128 |
import { api } from '../api.js'
|
| 129 |
import AtButton from './AtButton.vue'
|
| 130 |
-
import {
|
| 131 |
import { statusLabel } from '../composables/useStatus.js'
|
| 132 |
|
| 133 |
const props = defineProps({
|
|
@@ -136,13 +137,13 @@ const props = defineProps({
|
|
| 136 |
mode: { type: String, default: 'all' },
|
| 137 |
// Round 8 — 母号订阅健康度,degraded 时禁用 fill-personal
|
| 138 |
masterHealth: { type: Object, default: null },
|
|
|
|
| 139 |
})
|
| 140 |
const emit = defineEmits(['task-started', 'refresh'])
|
| 141 |
|
| 142 |
-
// round-12 F2/F3 — SSE 实时进度
|
| 143 |
-
//
|
| 144 |
-
|
| 145 |
-
const rotateStream = useStatusInvalidator()
|
| 146 |
const showProgress = ref(false)
|
| 147 |
|
| 148 |
function transitionIcon(ev) {
|
|
@@ -179,8 +180,8 @@ function formatTransitionTime(ts) {
|
|
| 179 |
const actions = [
|
| 180 |
{ key: 'rotate', group: 'pool', label: '智能轮转', icon: RotateCw, method: 'startRotate', needParam: true, paramName: 'target', tone: 'primary' },
|
| 181 |
{ key: 'check', group: 'pool', label: '检查额度', icon: ChartPie, method: 'startCheck', needParam: false, tone: 'emerald' },
|
| 182 |
-
{ key: 'fill', group: 'pool', label: '补满成员', icon: Plus, method: 'startFill', needParam: true, paramName: 'target', tone: '
|
| 183 |
-
{ key: 'fill-personal', group: 'pool', label: '生成免费号', icon: Coins, method: 'startFillPersonal', needParam: true, paramName: 'count', tone: '
|
| 184 |
{ key: 'add', group: 'pool', label: '添加账号', icon: UserPlus, method: 'startAdd', needParam: false, tone: 'amber' },
|
| 185 |
{ key: 'cleanup', group: 'pool', label: '清理成员', icon: Brush, method: 'startCleanup', needParam: false, tone: 'rose' },
|
| 186 |
{ key: 'sync', group: 'sync', label: '同步 CPA', icon: RefreshCw, method: 'postSync', needParam: false, sync: true, allowWithoutAdmin: true, tone: 'cyan' },
|
|
@@ -193,6 +194,7 @@ const paramLabel = ref('')
|
|
| 193 |
const paramValue = ref(5)
|
| 194 |
const paramMax = ref(20)
|
| 195 |
const pendingAction = ref(null)
|
|
|
|
| 196 |
|
| 197 |
const cancelling = ref(false)
|
| 198 |
const cancelRequested = ref(false)
|
|
@@ -218,10 +220,11 @@ async function cancelTask() {
|
|
| 218 |
const r = await api.cancelTask()
|
| 219 |
cancelRequested.value = true
|
| 220 |
message.value = r.message || '已请求停止'
|
| 221 |
-
messageClass.value = 'bg-amber-
|
|
|
|
| 222 |
} catch (e) {
|
| 223 |
message.value = `停���失败: ${e.message}`
|
| 224 |
-
messageClass.value = 'bg-rose-
|
| 225 |
} finally {
|
| 226 |
cancelling.value = false
|
| 227 |
setTimeout(() => { if (messageClass.value.includes('amber')) message.value = '' }, 10000)
|
|
@@ -291,19 +294,24 @@ const masterDegraded = computed(() => !!(
|
|
| 291 |
))
|
| 292 |
|
| 293 |
function isDisabled(action) {
|
|
|
|
| 294 |
if (props.runningTask) return true
|
| 295 |
if (!adminReady.value && !action.allowWithoutAdmin) return true
|
| 296 |
if (action.key === 'fill-personal' && masterDegraded.value) return true
|
| 297 |
return false
|
| 298 |
}
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
// 按 tone 给按钮配色 — round-12 F1 Bright v1
|
| 301 |
function actionColorClass(action) {
|
| 302 |
const map = {
|
| 303 |
primary: 'text-indigo-700 bg-indigo-50 border-indigo-200 hover:bg-indigo-100 hover:border-indigo-300',
|
| 304 |
emerald: 'text-emerald-700 bg-emerald-50 border-emerald-200 hover:bg-emerald-100',
|
| 305 |
-
|
| 306 |
-
|
| 307 |
amber: 'text-amber-800 bg-amber-50 border-amber-200 hover:bg-amber-100',
|
| 308 |
rose: 'text-rose-700 bg-rose-50 border-rose-200 hover:bg-rose-100',
|
| 309 |
cyan: 'text-cyan-700 bg-cyan-50 border-cyan-200 hover:bg-cyan-100',
|
|
@@ -339,21 +347,24 @@ async function confirmAction() {
|
|
| 339 |
}
|
| 340 |
|
| 341 |
async function doExecute(action, param) {
|
|
|
|
| 342 |
try {
|
| 343 |
if (action.sync) {
|
| 344 |
const result = await api[action.method]()
|
| 345 |
message.value = result.message || '操作完成'
|
| 346 |
-
messageClass.value = 'bg-emerald-
|
| 347 |
emit('refresh')
|
| 348 |
} else {
|
| 349 |
const result = await api[action.method](param)
|
| 350 |
message.value = `任务已提交: ${result.task_id}`
|
| 351 |
-
messageClass.value = 'bg-
|
| 352 |
emit('task-started')
|
| 353 |
}
|
| 354 |
} catch (e) {
|
| 355 |
message.value = e.message
|
| 356 |
-
messageClass.value = 'bg-rose-
|
|
|
|
|
|
|
| 357 |
}
|
| 358 |
setTimeout(() => { message.value = '' }, 8000)
|
| 359 |
}
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<div class="glass rounded-lg p-5">
|
| 3 |
<div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
| 4 |
<div>
|
| 5 |
+
<h2 class="text-base font-bold text-ink-950 tracking-tight">{{ panelTitle }}</h2>
|
| 6 |
+
<p class="text-[11px] text-ink-500 mt-0.5">提交后立即进入后台任务观察窗口,完成后自动同步状态。</p>
|
| 7 |
</div>
|
| 8 |
<div v-if="runningTask" class="flex items-center gap-2 text-xs">
|
| 9 |
+
<span class="text-ink-500 uppercase tracking-widest text-[10px]">Running</span>
|
| 10 |
+
<span class="font-mono text-amber-800 px-2 py-0.5 rounded bg-amber-50 border border-amber-200">{{ runningTask.command }}</span>
|
| 11 |
+
<span class="font-mono text-ink-600">{{ runningTask.task_id ? runningTask.task_id.slice(0, 8) : '' }}</span>
|
| 12 |
<AtButton variant="danger" size="sm" :loading="cancelling" :disabled="cancelRequested" @click="cancelTask">
|
| 13 |
{{ cancelRequested ? '停止中…' : '停止任务' }}
|
| 14 |
</AtButton>
|
|
|
|
| 16 |
</div>
|
| 17 |
|
| 18 |
<div v-if="showAdminHint"
|
| 19 |
+
class="mb-4 px-4 py-2.5 rounded-lg text-sm border bg-amber-50 text-amber-800 border-amber-200">
|
| 20 |
{{ adminHint }}
|
| 21 |
</div>
|
| 22 |
|
|
|
|
| 25 |
<button v-for="action in visibleActions" :key="action.key"
|
| 26 |
@click="execute(action)"
|
| 27 |
:disabled="isDisabled(action)"
|
| 28 |
+
class="relative h-10 px-4 rounded-lg text-sm font-semibold border transition-all
|
| 29 |
lift-hover focus-ring select-none whitespace-nowrap
|
| 30 |
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-ink-100 disabled:text-ink-400 disabled:border-hairline"
|
| 31 |
:class="actionColorClass(action)">
|
| 32 |
<span class="inline-flex items-center gap-2">
|
| 33 |
+
<span v-if="isSubmitting(action)" class="inline-block w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></span>
|
| 34 |
+
<component v-else :is="action.icon" class="w-4 h-4 shrink-0" :stroke-width="2" />
|
| 35 |
+
{{ isSubmitting(action) ? '提交中' : action.label }}
|
| 36 |
</span>
|
| 37 |
</button>
|
| 38 |
</div>
|
| 39 |
|
| 40 |
<!-- round-12 F2 — rotate 实时进度面板 (SSE) -->
|
| 41 |
+
<div class="mt-4 rounded-lg border border-hairline bg-surface">
|
| 42 |
<button type="button"
|
| 43 |
+
class="w-full flex items-center justify-between gap-3 px-4 py-2.5 text-sm focus-ring rounded-lg"
|
| 44 |
@click="showProgress = !showProgress">
|
| 45 |
<span class="inline-flex items-center gap-2 font-semibold text-ink-700">
|
| 46 |
<Activity class="w-4 h-4" :class="rotateStream.isConnected.value ? 'text-emerald-600' : 'text-ink-400'" :stroke-width="2" />
|
|
|
|
| 79 |
|
| 80 |
<!-- 注册域名切换(仅 pool 模式可见) -->
|
| 81 |
<div v-if="mode === 'pool'"
|
| 82 |
+
class="mt-5 p-3 rounded-lg border border-hairline bg-ink-50 flex flex-wrap items-center gap-2 text-sm">
|
| 83 |
+
<span class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold mr-1">注册域名</span>
|
| 84 |
+
<span class="text-ink-400">@</span>
|
| 85 |
<input v-model="domainInput" type="text" placeholder="your-domain.com"
|
| 86 |
+
class="flex-1 min-w-[180px] px-3 py-1.5 bg-surface border border-hairline rounded-lg text-ink-950 text-sm font-mono focus-ring transition" />
|
| 87 |
<AtButton variant="primary" size="sm" :loading="domainBusy" :disabled="!domainInput" @click="saveDomain">
|
| 88 |
保存并验证
|
| 89 |
</AtButton>
|
| 90 |
+
<span v-if="currentDomain" class="text-[11px] text-ink-500 font-mono">当前: @{{ currentDomain }}</span>
|
| 91 |
+
<span v-if="domainMsg" class="ml-1 text-[11px] font-medium" :class="domainMsgOk ? 'text-emerald-700' : 'text-rose-700'">{{ domainMsg }}</span>
|
| 92 |
</div>
|
| 93 |
|
| 94 |
<!-- 参数输入 -->
|
| 95 |
<div v-if="showParams"
|
| 96 |
+
class="mt-4 p-3 rounded-lg border border-indigo-200 bg-indigo-50 flex items-center gap-3 animate-rise">
|
| 97 |
+
<label class="text-[11px] uppercase tracking-widest text-indigo-700 font-semibold">{{ paramLabel }}</label>
|
| 98 |
<input v-model.number="paramValue" type="number" min="1" :max="paramMax"
|
| 99 |
+
class="w-24 px-3 py-1.5 bg-surface border border-hairline rounded-lg text-ink-950 text-sm font-mono focus-ring tabular" />
|
| 100 |
+
<AtButton variant="primary" size="sm" :loading="!!executingActionKey" @click="confirmAction">确认执行</AtButton>
|
| 101 |
<AtButton variant="ghost" size="sm" @click="showParams = false">取消</AtButton>
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<!-- 结果提示 -->
|
| 105 |
+
<div v-if="message" class="mt-4 px-4 py-2.5 rounded-lg text-sm border animate-rise" :class="messageClass">
|
| 106 |
{{ message }}
|
| 107 |
</div>
|
| 108 |
</div>
|
|
|
|
| 128 |
} from 'lucide-vue-next'
|
| 129 |
import { api } from '../api.js'
|
| 130 |
import AtButton from './AtButton.vue'
|
| 131 |
+
import { useRotateStream } from '../composables/useRotateStream.js'
|
| 132 |
import { statusLabel } from '../composables/useStatus.js'
|
| 133 |
|
| 134 |
const props = defineProps({
|
|
|
|
| 137 |
mode: { type: String, default: 'all' },
|
| 138 |
// Round 8 — 母号订阅健康度,degraded 时禁用 fill-personal
|
| 139 |
masterHealth: { type: Object, default: null },
|
| 140 |
+
rotateStream: { type: Object, default: null },
|
| 141 |
})
|
| 142 |
const emit = defineEmits(['task-started', 'refresh'])
|
| 143 |
|
| 144 |
+
// round-12 F2/F3 — 共享 SSE 流直接用于实时进度展示;
|
| 145 |
+
// App 级状态层负责统一 invalidation,这里不再额外挂一层桥接。
|
| 146 |
+
const rotateStream = props.rotateStream || useRotateStream()
|
|
|
|
| 147 |
const showProgress = ref(false)
|
| 148 |
|
| 149 |
function transitionIcon(ev) {
|
|
|
|
| 180 |
const actions = [
|
| 181 |
{ key: 'rotate', group: 'pool', label: '智能轮转', icon: RotateCw, method: 'startRotate', needParam: true, paramName: 'target', tone: 'primary' },
|
| 182 |
{ key: 'check', group: 'pool', label: '检查额度', icon: ChartPie, method: 'startCheck', needParam: false, tone: 'emerald' },
|
| 183 |
+
{ key: 'fill', group: 'pool', label: '补满成员', icon: Plus, method: 'startFill', needParam: true, paramName: 'target', tone: 'teal' },
|
| 184 |
+
{ key: 'fill-personal', group: 'pool', label: '生成免费号', icon: Coins, method: 'startFillPersonal', needParam: true, paramName: 'count', tone: 'lime' },
|
| 185 |
{ key: 'add', group: 'pool', label: '添加账号', icon: UserPlus, method: 'startAdd', needParam: false, tone: 'amber' },
|
| 186 |
{ key: 'cleanup', group: 'pool', label: '清理成员', icon: Brush, method: 'startCleanup', needParam: false, tone: 'rose' },
|
| 187 |
{ key: 'sync', group: 'sync', label: '同步 CPA', icon: RefreshCw, method: 'postSync', needParam: false, sync: true, allowWithoutAdmin: true, tone: 'cyan' },
|
|
|
|
| 194 |
const paramValue = ref(5)
|
| 195 |
const paramMax = ref(20)
|
| 196 |
const pendingAction = ref(null)
|
| 197 |
+
const executingActionKey = ref('')
|
| 198 |
|
| 199 |
const cancelling = ref(false)
|
| 200 |
const cancelRequested = ref(false)
|
|
|
|
| 220 |
const r = await api.cancelTask()
|
| 221 |
cancelRequested.value = true
|
| 222 |
message.value = r.message || '已请求停止'
|
| 223 |
+
messageClass.value = 'bg-amber-50 text-amber-800 border-amber-200'
|
| 224 |
+
emit('refresh')
|
| 225 |
} catch (e) {
|
| 226 |
message.value = `停���失败: ${e.message}`
|
| 227 |
+
messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 228 |
} finally {
|
| 229 |
cancelling.value = false
|
| 230 |
setTimeout(() => { if (messageClass.value.includes('amber')) message.value = '' }, 10000)
|
|
|
|
| 294 |
))
|
| 295 |
|
| 296 |
function isDisabled(action) {
|
| 297 |
+
if (executingActionKey.value) return true
|
| 298 |
if (props.runningTask) return true
|
| 299 |
if (!adminReady.value && !action.allowWithoutAdmin) return true
|
| 300 |
if (action.key === 'fill-personal' && masterDegraded.value) return true
|
| 301 |
return false
|
| 302 |
}
|
| 303 |
|
| 304 |
+
function isSubmitting(action) {
|
| 305 |
+
return executingActionKey.value === action.key
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
// 按 tone 给按钮配色 — round-12 F1 Bright v1
|
| 309 |
function actionColorClass(action) {
|
| 310 |
const map = {
|
| 311 |
primary: 'text-indigo-700 bg-indigo-50 border-indigo-200 hover:bg-indigo-100 hover:border-indigo-300',
|
| 312 |
emerald: 'text-emerald-700 bg-emerald-50 border-emerald-200 hover:bg-emerald-100',
|
| 313 |
+
teal: 'text-teal-700 bg-teal-50 border-teal-200 hover:bg-teal-100',
|
| 314 |
+
lime: 'text-lime-700 bg-lime-50 border-lime-200 hover:bg-lime-100',
|
| 315 |
amber: 'text-amber-800 bg-amber-50 border-amber-200 hover:bg-amber-100',
|
| 316 |
rose: 'text-rose-700 bg-rose-50 border-rose-200 hover:bg-rose-100',
|
| 317 |
cyan: 'text-cyan-700 bg-cyan-50 border-cyan-200 hover:bg-cyan-100',
|
|
|
|
| 347 |
}
|
| 348 |
|
| 349 |
async function doExecute(action, param) {
|
| 350 |
+
executingActionKey.value = action.key
|
| 351 |
try {
|
| 352 |
if (action.sync) {
|
| 353 |
const result = await api[action.method]()
|
| 354 |
message.value = result.message || '操作完成'
|
| 355 |
+
messageClass.value = 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
| 356 |
emit('refresh')
|
| 357 |
} else {
|
| 358 |
const result = await api[action.method](param)
|
| 359 |
message.value = `任务已提交: ${result.task_id}`
|
| 360 |
+
messageClass.value = 'bg-sky-50 text-sky-700 border-sky-200'
|
| 361 |
emit('task-started')
|
| 362 |
}
|
| 363 |
} catch (e) {
|
| 364 |
message.value = e.message
|
| 365 |
+
messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
|
| 366 |
+
} finally {
|
| 367 |
+
executingActionKey.value = ''
|
| 368 |
}
|
| 369 |
setTimeout(() => { message.value = '' }, 8000)
|
| 370 |
}
|
web/src/components/TasksPage.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
-
<h2 class="text-xl font-bold text-
|
| 4 |
<TaskPanel :running-task="runningTask" :admin-status="adminStatus" @task-started="$emit('task-started')" @refresh="$emit('refresh')" />
|
| 5 |
<TaskHistory :tasks="tasks" />
|
| 6 |
</div>
|
|
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
+
<h2 class="text-xl font-bold text-ink-950 mb-6">操作 & 任务</h2>
|
| 4 |
<TaskPanel :running-task="runningTask" :admin-status="adminStatus" @task-started="$emit('task-started')" @refresh="$emit('refresh')" />
|
| 5 |
<TaskHistory :tasks="tasks" />
|
| 6 |
</div>
|
web/src/components/TeamMembers.vue
CHANGED
|
@@ -1,30 +1,30 @@
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
<div class="flex items-center justify-between mb-6">
|
| 4 |
-
<h2 class="text-xl font-bold text-
|
| 5 |
<button @click="fetchMembers" :disabled="loading"
|
| 6 |
-
class="px-3 py-1.5 bg-
|
| 7 |
{{ loading ? '加载中...' : '刷新' }}
|
| 8 |
</button>
|
| 9 |
</div>
|
| 10 |
|
| 11 |
-
<div v-if="error" class="mb-4 px-4 py-3 rounded-lg text-sm bg-
|
| 12 |
{{ error }}
|
| 13 |
</div>
|
| 14 |
|
| 15 |
<div v-if="data" class="space-y-4">
|
| 16 |
<!-- 统计 -->
|
| 17 |
<div class="flex gap-4 text-sm">
|
| 18 |
-
<span class="px-3 py-1.5 bg-
|
| 19 |
-
<span v-if="data.invites > 0" class="px-3 py-1.5 bg-
|
| 20 |
</div>
|
| 21 |
|
| 22 |
<!-- 成员表格 -->
|
| 23 |
-
<div class="
|
| 24 |
<div class="overflow-x-auto">
|
| 25 |
<table class="w-full text-sm">
|
| 26 |
<thead>
|
| 27 |
-
<tr class="text-
|
| 28 |
<th class="px-4 py-3 font-medium">#</th>
|
| 29 |
<th class="px-4 py-3 font-medium">邮箱</th>
|
| 30 |
<th class="px-4 py-3 font-medium">角色</th>
|
|
@@ -35,27 +35,27 @@
|
|
| 35 |
</thead>
|
| 36 |
<tbody>
|
| 37 |
<tr v-for="(m, i) in data.members" :key="m.email + m.type"
|
| 38 |
-
class="border-b border-
|
| 39 |
-
<td class="px-4 py-3 text-
|
| 40 |
<td class="px-4 py-3 font-mono text-xs">{{ m.email }}</td>
|
| 41 |
<td class="px-4 py-3">
|
| 42 |
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
| 43 |
:class="{
|
| 44 |
-
'bg-
|
| 45 |
-
'bg-
|
| 46 |
-
'bg-
|
| 47 |
}">
|
| 48 |
{{ m.role || 'member' }}
|
| 49 |
</span>
|
| 50 |
</td>
|
| 51 |
<td class="px-4 py-3">
|
| 52 |
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
| 53 |
-
:class="m.type === 'invite' ? 'bg-
|
| 54 |
{{ m.type === 'invite' ? '待接受' : '已加入' }}
|
| 55 |
</span>
|
| 56 |
</td>
|
| 57 |
<td class="px-4 py-3">
|
| 58 |
-
<span class="text-xs" :class="m.is_local ? 'text-
|
| 59 |
{{ m.is_local ? '本地管理' : '外部' }}
|
| 60 |
</span>
|
| 61 |
</td>
|
|
@@ -66,8 +66,8 @@
|
|
| 66 |
:disabled="removingId === memberKey(m)"
|
| 67 |
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
| 68 |
:class="removingId === memberKey(m)
|
| 69 |
-
? 'bg-
|
| 70 |
-
: 'bg-rose-
|
| 71 |
>
|
| 72 |
{{ removingId === memberKey(m) ? '处理中...' : '移出' }}
|
| 73 |
</button>
|
|
@@ -80,10 +80,10 @@
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
<!-- Loading -->
|
| 83 |
-
<div v-else-if="loading" class="
|
| 84 |
|
| 85 |
<!-- Empty -->
|
| 86 |
-
<div v-else class="text-center text-
|
| 87 |
点击「刷新」加载 Team 成员列表
|
| 88 |
</div>
|
| 89 |
</div>
|
|
|
|
| 1 |
<template>
|
| 2 |
<div>
|
| 3 |
<div class="flex items-center justify-between mb-6">
|
| 4 |
+
<h2 class="text-xl font-bold text-ink-950">Team 成员</h2>
|
| 5 |
<button @click="fetchMembers" :disabled="loading"
|
| 6 |
+
class="px-3 py-1.5 bg-surface hover:bg-ink-100 text-sm rounded-lg border border-hairline transition disabled:opacity-50 focus-ring text-ink-700">
|
| 7 |
{{ loading ? '加载中...' : '刷新' }}
|
| 8 |
</button>
|
| 9 |
</div>
|
| 10 |
|
| 11 |
+
<div v-if="error" class="mb-4 px-4 py-3 rounded-lg text-sm bg-rose-50 text-rose-700 border border-rose-200">
|
| 12 |
{{ error }}
|
| 13 |
</div>
|
| 14 |
|
| 15 |
<div v-if="data" class="space-y-4">
|
| 16 |
<!-- 统计 -->
|
| 17 |
<div class="flex gap-4 text-sm">
|
| 18 |
+
<span class="px-3 py-1.5 bg-ink-50 rounded-lg text-ink-600 border border-hairline">成员: <span class="text-ink-950 font-medium">{{ data.total }}</span></span>
|
| 19 |
+
<span v-if="data.invites > 0" class="px-3 py-1.5 bg-amber-50 rounded-lg text-amber-800 border border-amber-200">待接受邀请: <span class="font-medium">{{ data.invites }}</span></span>
|
| 20 |
</div>
|
| 21 |
|
| 22 |
<!-- 成员表格 -->
|
| 23 |
+
<div class="glass rounded-lg overflow-hidden">
|
| 24 |
<div class="overflow-x-auto">
|
| 25 |
<table class="w-full text-sm">
|
| 26 |
<thead>
|
| 27 |
+
<tr class="text-ink-500 text-left border-b border-hairline">
|
| 28 |
<th class="px-4 py-3 font-medium">#</th>
|
| 29 |
<th class="px-4 py-3 font-medium">邮箱</th>
|
| 30 |
<th class="px-4 py-3 font-medium">角色</th>
|
|
|
|
| 35 |
</thead>
|
| 36 |
<tbody>
|
| 37 |
<tr v-for="(m, i) in data.members" :key="m.email + m.type"
|
| 38 |
+
class="border-b border-hairline hover:bg-ink-50 transition">
|
| 39 |
+
<td class="px-4 py-3 text-ink-500">{{ i + 1 }}</td>
|
| 40 |
<td class="px-4 py-3 font-mono text-xs">{{ m.email }}</td>
|
| 41 |
<td class="px-4 py-3">
|
| 42 |
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
| 43 |
:class="{
|
| 44 |
+
'bg-indigo-50 text-indigo-700': m.role === 'account-owner',
|
| 45 |
+
'bg-sky-50 text-sky-700': m.role === 'account-admin',
|
| 46 |
+
'bg-ink-50 text-ink-600': m.role !== 'account-owner' && m.role !== 'account-admin',
|
| 47 |
}">
|
| 48 |
{{ m.role || 'member' }}
|
| 49 |
</span>
|
| 50 |
</td>
|
| 51 |
<td class="px-4 py-3">
|
| 52 |
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
| 53 |
+
:class="m.type === 'invite' ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'">
|
| 54 |
{{ m.type === 'invite' ? '待接受' : '已加入' }}
|
| 55 |
</span>
|
| 56 |
</td>
|
| 57 |
<td class="px-4 py-3">
|
| 58 |
+
<span class="text-xs" :class="m.is_local ? 'text-sky-700' : 'text-ink-500'">
|
| 59 |
{{ m.is_local ? '本地管理' : '外部' }}
|
| 60 |
</span>
|
| 61 |
</td>
|
|
|
|
| 66 |
:disabled="removingId === memberKey(m)"
|
| 67 |
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
| 68 |
:class="removingId === memberKey(m)
|
| 69 |
+
? 'bg-ink-100 text-ink-400 border-hairline cursor-not-allowed'
|
| 70 |
+
: 'bg-rose-50 text-rose-700 border-rose-200 hover:bg-rose-100'"
|
| 71 |
>
|
| 72 |
{{ removingId === memberKey(m) ? '处理中...' : '移出' }}
|
| 73 |
</button>
|
|
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
<!-- Loading -->
|
| 83 |
+
<div v-else-if="loading" class="glass-soft rounded-lg h-64 shimmer-bg"></div>
|
| 84 |
|
| 85 |
<!-- Empty -->
|
| 86 |
+
<div v-else class="text-center text-ink-500 py-12">
|
| 87 |
点击「刷新」加载 Team 成员列表
|
| 88 |
</div>
|
| 89 |
</div>
|
web/src/components/ToastHost.vue
CHANGED
|
@@ -5,14 +5,14 @@
|
|
| 5 |
<div class="fixed top-3 right-3 z-[100] space-y-2 max-w-sm pointer-events-none">
|
| 6 |
<transition-group tag="div" class="space-y-2">
|
| 7 |
<div v-for="t in state.items" :key="t.id"
|
| 8 |
-
class="pointer-events-auto rounded-
|
| 9 |
:class="[toneClass(t.tone), t.leaving ? 'animate-toast-out' : 'animate-toast-in']"
|
| 10 |
@click="dismiss(t.id)">
|
| 11 |
<span class="shrink-0 mt-0.5">
|
| 12 |
-
<
|
| 13 |
-
<
|
| 14 |
-
<
|
| 15 |
-
<
|
| 16 |
</span>
|
| 17 |
<div class="flex-1 min-w-0">
|
| 18 |
<div class="text-sm font-semibold leading-tight" :class="titleColor(t.tone)">{{ t.text }}</div>
|
|
@@ -25,23 +25,24 @@
|
|
| 25 |
|
| 26 |
<script setup>
|
| 27 |
import { useToast } from '../composables/useToast.js'
|
|
|
|
| 28 |
|
| 29 |
const { state, dismiss } = useToast()
|
| 30 |
|
| 31 |
function toneClass(tone) {
|
| 32 |
return {
|
| 33 |
-
success: 'bg-emerald-
|
| 34 |
-
error: 'bg-rose-
|
| 35 |
-
warning: 'bg-amber-
|
| 36 |
-
info: 'bg-
|
| 37 |
-
}[tone] || 'bg-
|
| 38 |
}
|
| 39 |
function titleColor(tone) {
|
| 40 |
return {
|
| 41 |
-
success: 'text-emerald-
|
| 42 |
-
error: 'text-rose-
|
| 43 |
-
warning: 'text-amber-
|
| 44 |
-
info: 'text-
|
| 45 |
-
}[tone] || 'text-
|
| 46 |
}
|
| 47 |
</script>
|
|
|
|
| 5 |
<div class="fixed top-3 right-3 z-[100] space-y-2 max-w-sm pointer-events-none">
|
| 6 |
<transition-group tag="div" class="space-y-2">
|
| 7 |
<div v-for="t in state.items" :key="t.id"
|
| 8 |
+
class="pointer-events-auto rounded-lg border bg-surface shadow-card px-4 py-3 flex items-start gap-3"
|
| 9 |
:class="[toneClass(t.tone), t.leaving ? 'animate-toast-out' : 'animate-toast-in']"
|
| 10 |
@click="dismiss(t.id)">
|
| 11 |
<span class="shrink-0 mt-0.5">
|
| 12 |
+
<Check v-if="t.tone === 'success'" class="w-4 h-4 text-emerald-700" :stroke-width="2" />
|
| 13 |
+
<X v-else-if="t.tone === 'error'" class="w-4 h-4 text-rose-700" :stroke-width="2" />
|
| 14 |
+
<TriangleAlert v-else-if="t.tone === 'warning'" class="w-4 h-4 text-amber-700" :stroke-width="2" />
|
| 15 |
+
<Info v-else class="w-4 h-4 text-sky-700" :stroke-width="2" />
|
| 16 |
</span>
|
| 17 |
<div class="flex-1 min-w-0">
|
| 18 |
<div class="text-sm font-semibold leading-tight" :class="titleColor(t.tone)">{{ t.text }}</div>
|
|
|
|
| 25 |
|
| 26 |
<script setup>
|
| 27 |
import { useToast } from '../composables/useToast.js'
|
| 28 |
+
import { Check, Info, TriangleAlert, X } from 'lucide-vue-next'
|
| 29 |
|
| 30 |
const { state, dismiss } = useToast()
|
| 31 |
|
| 32 |
function toneClass(tone) {
|
| 33 |
return {
|
| 34 |
+
success: 'bg-emerald-50 border-emerald-200',
|
| 35 |
+
error: 'bg-rose-50 border-rose-200',
|
| 36 |
+
warning: 'bg-amber-50 border-amber-200',
|
| 37 |
+
info: 'bg-sky-50 border-sky-200',
|
| 38 |
+
}[tone] || 'bg-sky-50 border-sky-200'
|
| 39 |
}
|
| 40 |
function titleColor(tone) {
|
| 41 |
return {
|
| 42 |
+
success: 'text-emerald-900',
|
| 43 |
+
error: 'text-rose-900',
|
| 44 |
+
warning: 'text-amber-900',
|
| 45 |
+
info: 'text-sky-900',
|
| 46 |
+
}[tone] || 'text-sky-900'
|
| 47 |
}
|
| 48 |
</script>
|
web/src/composables/useAppState.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
| 2 |
+
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
| 3 |
+
import { api } from '../api.js'
|
| 4 |
+
import { useRotateStream } from './useRotateStream.js'
|
| 5 |
+
|
| 6 |
+
export const APP_QUERY_KEYS = {
|
| 7 |
+
status: ['status'],
|
| 8 |
+
tasks: ['tasks'],
|
| 9 |
+
adminStatus: ['admin-status'],
|
| 10 |
+
codexStatus: ['main-codex-status'],
|
| 11 |
+
manualAccountStatus: ['manual-account-status'],
|
| 12 |
+
registerFailures: ['register-failures'],
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const REFRESH_POLICY = {
|
| 16 |
+
activeMs: 3000,
|
| 17 |
+
idleMs: 120000,
|
| 18 |
+
burstMs: 90000,
|
| 19 |
+
staleMs: 5000,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function dataRef(query, fallback = null) {
|
| 23 |
+
return computed(() => query.data.value ?? fallback)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function activeTask(tasks) {
|
| 27 |
+
return (tasks || []).find(task => task.status === 'running' || task.status === 'pending') || null
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function useAppState(authenticated) {
|
| 31 |
+
const queryClient = useQueryClient()
|
| 32 |
+
const enabled = computed(() => !!authenticated.value)
|
| 33 |
+
const burstUntil = ref(0)
|
| 34 |
+
let activeTimer = null
|
| 35 |
+
let burstTimer = null
|
| 36 |
+
|
| 37 |
+
const statusQuery = useQuery({
|
| 38 |
+
queryKey: APP_QUERY_KEYS.status,
|
| 39 |
+
queryFn: () => api.getStatus(),
|
| 40 |
+
enabled,
|
| 41 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 42 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 43 |
+
refetchOnWindowFocus: true,
|
| 44 |
+
keepPreviousData: true,
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
const tasksQuery = useQuery({
|
| 48 |
+
queryKey: APP_QUERY_KEYS.tasks,
|
| 49 |
+
queryFn: () => api.getTasks(),
|
| 50 |
+
enabled,
|
| 51 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 52 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 53 |
+
refetchOnWindowFocus: true,
|
| 54 |
+
keepPreviousData: true,
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
const adminStatusQuery = useQuery({
|
| 58 |
+
queryKey: APP_QUERY_KEYS.adminStatus,
|
| 59 |
+
queryFn: () => api.getAdminStatus(),
|
| 60 |
+
enabled,
|
| 61 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 62 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 63 |
+
refetchOnWindowFocus: true,
|
| 64 |
+
keepPreviousData: true,
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
const codexStatusQuery = useQuery({
|
| 68 |
+
queryKey: APP_QUERY_KEYS.codexStatus,
|
| 69 |
+
queryFn: () => api.getMainCodexStatus(),
|
| 70 |
+
enabled,
|
| 71 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 72 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 73 |
+
refetchOnWindowFocus: true,
|
| 74 |
+
keepPreviousData: true,
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
const manualAccountStatusQuery = useQuery({
|
| 78 |
+
queryKey: APP_QUERY_KEYS.manualAccountStatus,
|
| 79 |
+
queryFn: () => api.getManualAccountStatus(),
|
| 80 |
+
enabled,
|
| 81 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 82 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 83 |
+
refetchOnWindowFocus: true,
|
| 84 |
+
keepPreviousData: true,
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
const registerFailuresQuery = useQuery({
|
| 88 |
+
queryKey: APP_QUERY_KEYS.registerFailures,
|
| 89 |
+
queryFn: () => api.getRegisterFailures(50),
|
| 90 |
+
enabled,
|
| 91 |
+
staleTime: REFRESH_POLICY.staleMs,
|
| 92 |
+
refetchInterval: computed(() => enabled.value ? REFRESH_POLICY.idleMs : false),
|
| 93 |
+
refetchOnWindowFocus: true,
|
| 94 |
+
keepPreviousData: true,
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
const status = dataRef(statusQuery)
|
| 98 |
+
const tasks = dataRef(tasksQuery, [])
|
| 99 |
+
const adminStatus = dataRef(adminStatusQuery)
|
| 100 |
+
const codexStatus = dataRef(codexStatusQuery)
|
| 101 |
+
const manualAccountStatus = dataRef(manualAccountStatusQuery)
|
| 102 |
+
const registerFailures = dataRef(registerFailuresQuery)
|
| 103 |
+
const runningTask = computed(() => activeTask(tasks.value))
|
| 104 |
+
const busyTask = computed(() => {
|
| 105 |
+
if (adminStatus.value?.login_in_progress) return { command: 'admin-login' }
|
| 106 |
+
if (codexStatus.value?.in_progress) return { command: 'main-codex-sync' }
|
| 107 |
+
return runningTask.value
|
| 108 |
+
})
|
| 109 |
+
const loading = computed(() => (
|
| 110 |
+
statusQuery.isFetching.value
|
| 111 |
+
|| tasksQuery.isFetching.value
|
| 112 |
+
|| adminStatusQuery.isFetching.value
|
| 113 |
+
|| codexStatusQuery.isFetching.value
|
| 114 |
+
|| manualAccountStatusQuery.isFetching.value
|
| 115 |
+
|| registerFailuresQuery.isFetching.value
|
| 116 |
+
))
|
| 117 |
+
const unauthorized = computed(() => [
|
| 118 |
+
statusQuery.error.value,
|
| 119 |
+
tasksQuery.error.value,
|
| 120 |
+
adminStatusQuery.error.value,
|
| 121 |
+
codexStatusQuery.error.value,
|
| 122 |
+
manualAccountStatusQuery.error.value,
|
| 123 |
+
registerFailuresQuery.error.value,
|
| 124 |
+
].some(error => error?.status === 401))
|
| 125 |
+
const registerFailuresUnavailable = computed(() => {
|
| 126 |
+
const error = registerFailuresQuery.error.value
|
| 127 |
+
if (!error) return ''
|
| 128 |
+
if (error.status === 404 || error.status === 405) {
|
| 129 |
+
return '当前后端未提供注册失败明细 JSON 接口,前端已降级为空列表。'
|
| 130 |
+
}
|
| 131 |
+
return `获取注册失败明细失败: ${error.message || '未知错误'}`
|
| 132 |
+
})
|
| 133 |
+
const registerFailuresLoading = computed(() => registerFailuresQuery.isFetching.value)
|
| 134 |
+
|
| 135 |
+
const rotateStream = useRotateStream({ immediate: false })
|
| 136 |
+
const offTransition = rotateStream.onTransition(() => {
|
| 137 |
+
refetchCore()
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
function invalidateCore() {
|
| 141 |
+
for (const key of [
|
| 142 |
+
APP_QUERY_KEYS.status,
|
| 143 |
+
APP_QUERY_KEYS.tasks,
|
| 144 |
+
APP_QUERY_KEYS.adminStatus,
|
| 145 |
+
APP_QUERY_KEYS.codexStatus,
|
| 146 |
+
APP_QUERY_KEYS.manualAccountStatus,
|
| 147 |
+
APP_QUERY_KEYS.registerFailures,
|
| 148 |
+
]) {
|
| 149 |
+
queryClient.invalidateQueries({ queryKey: key })
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function clearCoreCache() {
|
| 154 |
+
for (const key of [
|
| 155 |
+
APP_QUERY_KEYS.status,
|
| 156 |
+
APP_QUERY_KEYS.tasks,
|
| 157 |
+
APP_QUERY_KEYS.adminStatus,
|
| 158 |
+
APP_QUERY_KEYS.codexStatus,
|
| 159 |
+
APP_QUERY_KEYS.manualAccountStatus,
|
| 160 |
+
APP_QUERY_KEYS.registerFailures,
|
| 161 |
+
]) {
|
| 162 |
+
queryClient.removeQueries({ queryKey: key })
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
async function refetchCore() {
|
| 167 |
+
if (!enabled.value) return
|
| 168 |
+
invalidateCore()
|
| 169 |
+
await Promise.allSettled([
|
| 170 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.status, type: 'active' }),
|
| 171 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.tasks, type: 'active' }),
|
| 172 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.adminStatus, type: 'active' }),
|
| 173 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.codexStatus, type: 'active' }),
|
| 174 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.manualAccountStatus, type: 'active' }),
|
| 175 |
+
queryClient.refetchQueries({ queryKey: APP_QUERY_KEYS.registerFailures, type: 'active' }),
|
| 176 |
+
])
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function stopActiveRefresh() {
|
| 180 |
+
if (activeTimer) {
|
| 181 |
+
clearInterval(activeTimer)
|
| 182 |
+
activeTimer = null
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function startActiveRefresh(durationMs = 0) {
|
| 187 |
+
if (!enabled.value) return
|
| 188 |
+
if (!activeTimer) {
|
| 189 |
+
activeTimer = setInterval(refetchCore, REFRESH_POLICY.activeMs)
|
| 190 |
+
}
|
| 191 |
+
if (durationMs > 0) {
|
| 192 |
+
burstUntil.value = Date.now() + durationMs
|
| 193 |
+
clearTimeout(burstTimer)
|
| 194 |
+
burstTimer = setTimeout(() => {
|
| 195 |
+
burstUntil.value = 0
|
| 196 |
+
if (!busyTask.value) stopActiveRefresh()
|
| 197 |
+
}, durationMs)
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function notifyActionStarted() {
|
| 202 |
+
startActiveRefresh(REFRESH_POLICY.burstMs)
|
| 203 |
+
refetchCore()
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async function refresh() {
|
| 207 |
+
await refetchCore()
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
watch(enabled, (ok) => {
|
| 211 |
+
if (ok) {
|
| 212 |
+
rotateStream.connect()
|
| 213 |
+
refetchCore()
|
| 214 |
+
return
|
| 215 |
+
}
|
| 216 |
+
stopActiveRefresh()
|
| 217 |
+
rotateStream.disconnect()
|
| 218 |
+
clearCoreCache()
|
| 219 |
+
}, { immediate: true })
|
| 220 |
+
|
| 221 |
+
watch(unauthorized, (hit) => {
|
| 222 |
+
if (hit) {
|
| 223 |
+
authenticated.value = false
|
| 224 |
+
}
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
watch(busyTask, (task) => {
|
| 228 |
+
if (task) {
|
| 229 |
+
startActiveRefresh()
|
| 230 |
+
return
|
| 231 |
+
}
|
| 232 |
+
refetchCore()
|
| 233 |
+
if (!burstUntil.value || Date.now() >= burstUntil.value) stopActiveRefresh()
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
onUnmounted(() => {
|
| 237 |
+
stopActiveRefresh()
|
| 238 |
+
clearTimeout(burstTimer)
|
| 239 |
+
offTransition && offTransition()
|
| 240 |
+
rotateStream.disconnect()
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
status,
|
| 245 |
+
tasks,
|
| 246 |
+
adminStatus,
|
| 247 |
+
codexStatus,
|
| 248 |
+
manualAccountStatus,
|
| 249 |
+
registerFailures,
|
| 250 |
+
registerFailuresLoading,
|
| 251 |
+
registerFailuresUnavailable,
|
| 252 |
+
runningTask,
|
| 253 |
+
busyTask,
|
| 254 |
+
loading,
|
| 255 |
+
rotateStream,
|
| 256 |
+
refresh,
|
| 257 |
+
notifyActionStarted,
|
| 258 |
+
}
|
| 259 |
+
}
|
web/src/composables/useRotateStream.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
// { email, from, to, reason, ts, extra }
|
| 15 |
|
| 16 |
import { onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
| 17 |
|
| 18 |
const DEFAULT_URL = '/api/rotate/stream'
|
| 19 |
const MAX_EVENTS = 50
|
|
@@ -33,6 +34,14 @@ export function useRotateStream(opts = {}) {
|
|
| 33 |
let stoppedByQuota = false
|
| 34 |
const transitionCallbacks = new Set()
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
function _push(payload) {
|
| 37 |
// 首条 = 最新;超过 max 截尾
|
| 38 |
events.value = [payload, ...events.value].slice(0, max)
|
|
@@ -49,7 +58,7 @@ export function useRotateStream(opts = {}) {
|
|
| 49 |
function connect() {
|
| 50 |
if (source || stoppedByQuota) return
|
| 51 |
try {
|
| 52 |
-
source = new EventSource(
|
| 53 |
} catch (e) {
|
| 54 |
console.warn('[useRotateStream] EventSource ctor failed', e)
|
| 55 |
lastError.value = e
|
|
|
|
| 14 |
// { email, from, to, reason, ts, extra }
|
| 15 |
|
| 16 |
import { onMounted, onUnmounted, ref } from 'vue'
|
| 17 |
+
import { getApiKey } from '../api.js'
|
| 18 |
|
| 19 |
const DEFAULT_URL = '/api/rotate/stream'
|
| 20 |
const MAX_EVENTS = 50
|
|
|
|
| 34 |
let stoppedByQuota = false
|
| 35 |
const transitionCallbacks = new Set()
|
| 36 |
|
| 37 |
+
function urlWithAuthKey() {
|
| 38 |
+
const key = getApiKey()
|
| 39 |
+
if (!key) return url
|
| 40 |
+
const target = new URL(url, window.location.origin)
|
| 41 |
+
target.searchParams.set('key', key)
|
| 42 |
+
return `${target.pathname}${target.search}`
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
function _push(payload) {
|
| 46 |
// 首条 = 最新;超过 max 截尾
|
| 47 |
events.value = [payload, ...events.value].slice(0, max)
|
|
|
|
| 58 |
function connect() {
|
| 59 |
if (source || stoppedByQuota) return
|
| 60 |
try {
|
| 61 |
+
source = new EventSource(urlWithAuthKey())
|
| 62 |
} catch (e) {
|
| 63 |
console.warn('[useRotateStream] EventSource ctor failed', e)
|
| 64 |
lastError.value = e
|
web/src/composables/useStatus.js
CHANGED
|
@@ -47,9 +47,9 @@ export function useStatusQuery(options = {}) {
|
|
| 47 |
// ['status'],vue-query 自动重拉(refetchOnMount + dedup)。
|
| 48 |
//
|
| 49 |
// 返回 useRotateStream 的所有 ref,组件可同时拿来渲染"实时进度"面板。
|
| 50 |
-
export function useStatusInvalidator() {
|
| 51 |
const queryClient = useQueryClient()
|
| 52 |
-
const stream = useRotateStream()
|
| 53 |
const offTransition = stream.onTransition(() => {
|
| 54 |
// invalidateQueries 自身是 promise + dedup,不需要 debounce;
|
| 55 |
// 若 30s polling 与 SSE 重叠,vue-query 会合并成单次网络请求。
|
|
@@ -65,6 +65,7 @@ export const STATUS_LABELS = {
|
|
| 65 |
standby: 'Standby',
|
| 66 |
pending: 'Pending',
|
| 67 |
personal: 'Personal',
|
|
|
|
| 68 |
auth_invalid: '认证失效',
|
| 69 |
orphan: '孤立',
|
| 70 |
degraded_grace: 'Grace',
|
|
@@ -82,12 +83,12 @@ export const STATUS_STYLES = {
|
|
| 82 |
gradient: 'from-emerald-400/20 via-emerald-500/10 to-teal-500/15',
|
| 83 |
},
|
| 84 |
personal: {
|
| 85 |
-
base: 'bg-
|
| 86 |
-
text: 'text-
|
| 87 |
-
border: 'border-
|
| 88 |
-
dot: 'bg-
|
| 89 |
pulse: false,
|
| 90 |
-
gradient: 'from-
|
| 91 |
},
|
| 92 |
standby: {
|
| 93 |
base: 'bg-amber-500/[0.08]',
|
|
@@ -97,6 +98,14 @@ export const STATUS_STYLES = {
|
|
| 97 |
pulse: false,
|
| 98 |
gradient: 'from-amber-400/15 via-yellow-500/10 to-amber-600/15',
|
| 99 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
// GRACE — 橙→红渐变,grace_until 越接近 0 越红
|
| 101 |
degraded_grace: {
|
| 102 |
base: 'bg-orange-500/[0.10]',
|
|
@@ -147,12 +156,15 @@ export function statusStyle(s) {
|
|
| 147 |
return STATUS_STYLES[s] || STATUS_STYLES.pending
|
| 148 |
}
|
| 149 |
|
| 150 |
-
// F1 — "实际可用性" 派生
|
| 151 |
// 输入:account 对象(含 status, last_quota, grace_until 等)
|
| 152 |
// 输出:{ kind: 'usable'|'grace'|'standby'|'unusable', label, hint, tone }
|
| 153 |
export function computeUsability(acc) {
|
| 154 |
if (!acc) return { kind: 'unknown', label: '—', hint: '', tone: 'neutral' }
|
| 155 |
const s = acc.status
|
|
|
|
|
|
|
|
|
|
| 156 |
const q = acc.last_quota || {}
|
| 157 |
const noQuota = q.primary_total === 0 || q.no_quota === true
|
| 158 |
const hasError = q.error || q.status === 'auth_error'
|
|
|
|
| 47 |
// ['status'],vue-query 自动重拉(refetchOnMount + dedup)。
|
| 48 |
//
|
| 49 |
// 返回 useRotateStream 的所有 ref,组件可同时拿来渲染"实时进度"面板。
|
| 50 |
+
export function useStatusInvalidator(options = {}) {
|
| 51 |
const queryClient = useQueryClient()
|
| 52 |
+
const stream = options.stream || useRotateStream(options.streamOptions || {})
|
| 53 |
const offTransition = stream.onTransition(() => {
|
| 54 |
// invalidateQueries 自身是 promise + dedup,不需要 debounce;
|
| 55 |
// 若 30s polling 与 SSE 重叠,vue-query 会合并成单次网络请求。
|
|
|
|
| 65 |
standby: 'Standby',
|
| 66 |
pending: 'Pending',
|
| 67 |
personal: 'Personal',
|
| 68 |
+
disabled: 'Disabled',
|
| 69 |
auth_invalid: '认证失效',
|
| 70 |
orphan: '孤立',
|
| 71 |
degraded_grace: 'Grace',
|
|
|
|
| 83 |
gradient: 'from-emerald-400/20 via-emerald-500/10 to-teal-500/15',
|
| 84 |
},
|
| 85 |
personal: {
|
| 86 |
+
base: 'bg-sky-500/[0.08]',
|
| 87 |
+
text: 'text-sky-300',
|
| 88 |
+
border: 'border-sky-400/30',
|
| 89 |
+
dot: 'bg-sky-400',
|
| 90 |
pulse: false,
|
| 91 |
+
gradient: 'from-sky-400/20 via-blue-500/10 to-indigo-500/15',
|
| 92 |
},
|
| 93 |
standby: {
|
| 94 |
base: 'bg-amber-500/[0.08]',
|
|
|
|
| 98 |
pulse: false,
|
| 99 |
gradient: 'from-amber-400/15 via-yellow-500/10 to-amber-600/15',
|
| 100 |
},
|
| 101 |
+
disabled: {
|
| 102 |
+
base: 'bg-stone-500/[0.08]',
|
| 103 |
+
text: 'text-stone-700',
|
| 104 |
+
border: 'border-stone-300',
|
| 105 |
+
dot: 'bg-stone-500',
|
| 106 |
+
pulse: false,
|
| 107 |
+
gradient: 'from-stone-200/70 via-stone-100/50 to-slate-100/60',
|
| 108 |
+
},
|
| 109 |
// GRACE — 橙→红渐变,grace_until 越接近 0 越红
|
| 110 |
degraded_grace: {
|
| 111 |
base: 'bg-orange-500/[0.10]',
|
|
|
|
| 156 |
return STATUS_STYLES[s] || STATUS_STYLES.pending
|
| 157 |
}
|
| 158 |
|
| 159 |
+
// F1 — "实际可用性" 派生四档状态
|
| 160 |
// 输入:account 对象(含 status, last_quota, grace_until 等)
|
| 161 |
// 输出:{ kind: 'usable'|'grace'|'standby'|'unusable', label, hint, tone }
|
| 162 |
export function computeUsability(acc) {
|
| 163 |
if (!acc) return { kind: 'unknown', label: '—', hint: '', tone: 'neutral' }
|
| 164 |
const s = acc.status
|
| 165 |
+
if (s === 'disabled') {
|
| 166 |
+
return { kind: 'standby', label: '禁用', hint: '已从自动化流程排除', tone: 'neutral' }
|
| 167 |
+
}
|
| 168 |
const q = acc.last_quota || {}
|
| 169 |
const noQuota = q.primary_total === 0 || q.no_quota === true
|
| 170 |
const hasError = q.error || q.status === 'auth_error'
|
web/src/style.css
CHANGED
|
@@ -129,18 +129,17 @@ body {
|
|
| 129 |
.page-leave-active { transition: opacity 120ms ease-in, transform 120ms ease-in; }
|
| 130 |
.page-leave-to { opacity: 0; transform: translateY(-2px); }
|
| 131 |
|
| 132 |
-
/* ── 渐变按钮上的
|
| 133 |
.text-on-accent { color: #ffffff !important; }
|
| 134 |
|
| 135 |
/* ─────────────────────────────────────────────────────────────────
|
| 136 |
* § Compat Layer — 旧深色 utility → 明亮等价值
|
| 137 |
-
*
|
| 138 |
-
* 统一在此一次性翻转,避免
|
| 139 |
-
* 渐变按钮等需要
|
| 140 |
* ───────────────────────────────────────────────────────────────── */
|
| 141 |
@layer utilities {
|
| 142 |
/* 文字色翻转 */
|
| 143 |
-
.text-white { color: #0a0a0a; }
|
| 144 |
.text-gray-100 { color: #171717; }
|
| 145 |
.text-gray-200 { color: #262626; }
|
| 146 |
.text-gray-300 { color: #404040; }
|
|
@@ -148,7 +147,6 @@ body {
|
|
| 148 |
.text-gray-500 { color: #737373; }
|
| 149 |
.text-gray-600 { color: #a3a3a3; }
|
| 150 |
.text-gray-700 { color: #d4d4d4; }
|
| 151 |
-
.hover\:text-white:hover { color: #0a0a0a; }
|
| 152 |
.hover\:text-gray-100:hover { color: #171717; }
|
| 153 |
.hover\:text-gray-200:hover { color: #262626; }
|
| 154 |
.hover\:text-gray-300:hover { color: #404040; }
|
|
@@ -169,18 +167,8 @@ body {
|
|
| 169 |
.hover\:bg-white\/\[0\.07\]:hover { background-color: rgba(0,0,0,0.06); }
|
| 170 |
.hover\:bg-white\/\[0\.10\]:hover { background-color: rgba(0,0,0,0.07); }
|
| 171 |
|
| 172 |
-
/* black/30 输入框深色背景 → 浅灰 */
|
| 173 |
-
.bg-black\/30 { background-color: #f5f5f5; }
|
| 174 |
-
.bg-black\/40 { background-color: #efefef; }
|
| 175 |
-
.bg-black\/50 { background-color: #ebebeb; }
|
| 176 |
-
|
| 177 |
/* gray-700/800/900 灰阶(Settings 等组件硬编码深色 input)→ 浅灰 */
|
| 178 |
.bg-gray-700 { background-color: #ebebeb; }
|
| 179 |
-
.bg-gray-800 { background-color: #f5f5f5; }
|
| 180 |
-
.bg-gray-900 { background-color: #fafafa; }
|
| 181 |
-
.bg-gray-800\/30 { background-color: rgba(0,0,0,0.02); }
|
| 182 |
-
.bg-gray-800\/40 { background-color: rgba(0,0,0,0.03); }
|
| 183 |
-
.bg-gray-800\/60 { background-color: rgba(0,0,0,0.04); }
|
| 184 |
.hover\:bg-gray-700:hover { background-color: #e5e5e5; }
|
| 185 |
.disabled\:bg-gray-700:disabled { background-color: #f5f5f5; }
|
| 186 |
.disabled\:hover\:bg-gray-700:disabled:hover { background-color: #f5f5f5; }
|
|
@@ -213,16 +201,12 @@ body {
|
|
| 213 |
.text-rose-300 { color: #be123c; }
|
| 214 |
.text-orange-200 { color: #9a3412; }
|
| 215 |
.text-orange-300 { color: #c2410c; }
|
| 216 |
-
.text-violet-100, .text-violet-200 { color: #5b21b6; }
|
| 217 |
-
.text-violet-300 { color: #6d28d9; }
|
| 218 |
.text-indigo-100, .text-indigo-200 { color: #3730a3; }
|
| 219 |
.text-indigo-300 { color: #4338ca; }
|
| 220 |
.text-blue-200 { color: #1e3a8a; }
|
| 221 |
.text-blue-300 { color: #1d4ed8; }
|
| 222 |
.text-yellow-200 { color: #854d0e; }
|
| 223 |
.text-yellow-300 { color: #a16207; }
|
| 224 |
-
.text-fuchsia-100, .text-fuchsia-200 { color: #86198f; }
|
| 225 |
-
.text-fuchsia-300 { color: #a21caf; }
|
| 226 |
.text-cyan-100, .text-cyan-200 { color: #155e75; }
|
| 227 |
.text-cyan-300 { color: #0e7490; }
|
| 228 |
.text-sky-100, .text-sky-200 { color: #0c4a6e; }
|
|
|
|
| 129 |
.page-leave-active { transition: opacity 120ms ease-in, transform 120ms ease-in; }
|
| 130 |
.page-leave-to { opacity: 0; transform: translateY(-2px); }
|
| 131 |
|
| 132 |
+
/* ── 渐变按钮上的前景色固定(绕过旧深色兼容层)──────── */
|
| 133 |
.text-on-accent { color: #ffffff !important; }
|
| 134 |
|
| 135 |
/* ─────────────────────────────────────────────────────────────────
|
| 136 |
* § Compat Layer — 旧深色 utility → 明亮等价值
|
| 137 |
+
* 部分历史组件仍硬编码 text-gray-X / bg-white/[0.0X] / border-white/[X]。
|
| 138 |
+
* 统一在此一次性翻转,避免无关页面在迁移中破版。
|
| 139 |
+
* 渐变按钮等需要固定高对比前景的元素请使用 .text-on-accent。
|
| 140 |
* ───────────────────────────────────────────────────────────────── */
|
| 141 |
@layer utilities {
|
| 142 |
/* 文字色翻转 */
|
|
|
|
| 143 |
.text-gray-100 { color: #171717; }
|
| 144 |
.text-gray-200 { color: #262626; }
|
| 145 |
.text-gray-300 { color: #404040; }
|
|
|
|
| 147 |
.text-gray-500 { color: #737373; }
|
| 148 |
.text-gray-600 { color: #a3a3a3; }
|
| 149 |
.text-gray-700 { color: #d4d4d4; }
|
|
|
|
| 150 |
.hover\:text-gray-100:hover { color: #171717; }
|
| 151 |
.hover\:text-gray-200:hover { color: #262626; }
|
| 152 |
.hover\:text-gray-300:hover { color: #404040; }
|
|
|
|
| 167 |
.hover\:bg-white\/\[0\.07\]:hover { background-color: rgba(0,0,0,0.06); }
|
| 168 |
.hover\:bg-white\/\[0\.10\]:hover { background-color: rgba(0,0,0,0.07); }
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
/* gray-700/800/900 灰阶(Settings 等组件硬编码深色 input)→ 浅灰 */
|
| 171 |
.bg-gray-700 { background-color: #ebebeb; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
.hover\:bg-gray-700:hover { background-color: #e5e5e5; }
|
| 173 |
.disabled\:bg-gray-700:disabled { background-color: #f5f5f5; }
|
| 174 |
.disabled\:hover\:bg-gray-700:disabled:hover { background-color: #f5f5f5; }
|
|
|
|
| 201 |
.text-rose-300 { color: #be123c; }
|
| 202 |
.text-orange-200 { color: #9a3412; }
|
| 203 |
.text-orange-300 { color: #c2410c; }
|
|
|
|
|
|
|
| 204 |
.text-indigo-100, .text-indigo-200 { color: #3730a3; }
|
| 205 |
.text-indigo-300 { color: #4338ca; }
|
| 206 |
.text-blue-200 { color: #1e3a8a; }
|
| 207 |
.text-blue-300 { color: #1d4ed8; }
|
| 208 |
.text-yellow-200 { color: #854d0e; }
|
| 209 |
.text-yellow-300 { color: #a16207; }
|
|
|
|
|
|
|
| 210 |
.text-cyan-100, .text-cyan-200 { color: #155e75; }
|
| 211 |
.text-cyan-300 { color: #0e7490; }
|
| 212 |
.text-sky-100, .text-sky-200 { color: #0c4a6e; }
|