ZRainbow commited on
Commit
1f4cfcb
·
1 Parent(s): 3add1eb

feat: AutoTeam 大版本升级:安全轮换、并行、IPv6、指纹加固与前端重构

Browse files

- 安全轮换逻辑:先踢人再换人,降低轮转冲突和失败回弹

- 并行能力:同步、注册与目标分发链路并发化,整体吞吐更高

- IPv6 支持:接入 IPv6 代理池与状态面,扩展可用出口

- 更安全的指纹:统一浏览器上下文与启动/会话参数,减少特征暴露

- 轮转增强:速度与安全性双提升,轮转后凭证即时同步到配置目标

- 更好的前端:Dashboard / Settings / Sync / Task 视图全面重构,实时性、信息密度和可读性提升

- 同步与诊断:统一 sync_targets 分发层,增强主号、注册和轮转后的诊断与目标同步

Files changed (44) hide show
  1. .env.example +22 -0
  2. src/autoteam/accounts.py +29 -4
  3. src/autoteam/api.py +27 -7
  4. src/autoteam/codex_auth.py +1 -3
  5. src/autoteam/config.py +25 -0
  6. src/autoteam/cpa_sync.py +93 -30
  7. src/autoteam/invite.py +2 -5
  8. src/autoteam/manager.py +51 -1
  9. src/autoteam/sub2api_sync.py +1049 -0
  10. src/autoteam/sync_targets.py +278 -0
  11. src/autoteam/web/dist/assets/index-BSht6bmF.js +0 -0
  12. src/autoteam/web/dist/assets/index-BZ-R3x09.js +0 -0
  13. src/autoteam/web/dist/assets/{index-DHotZNsL.css → index-CbNAiyvN.css} +1 -1
  14. src/autoteam/web/dist/index.html +2 -2
  15. tests/unit/test_accounts.py +24 -0
  16. tests/unit/test_api_playwright_cleanup.py +59 -2
  17. tests/unit/test_cpa_sync.py +124 -0
  18. tests/unit/test_sub2api_sync.py +659 -0
  19. tests/unit/test_sync_targets.py +110 -0
  20. web/src/App.vue +41 -88
  21. web/src/api.js +5 -1
  22. web/src/components/AtButton.vue +6 -6
  23. web/src/components/Dashboard.vue +240 -94
  24. web/src/components/HealthDonut.vue +3 -3
  25. web/src/components/LogViewer.vue +36 -21
  26. web/src/components/MailProviderCard.vue +21 -21
  27. web/src/components/MasterHealthBanner.vue +34 -60
  28. web/src/components/OAuthPage.vue +21 -21
  29. web/src/components/PoolHealthCard.vue +33 -43
  30. web/src/components/Settings.vue +43 -49
  31. web/src/components/SetupPage.vue +25 -20
  32. web/src/components/Sidebar.vue +6 -6
  33. web/src/components/StatusBadge.vue +3 -2
  34. web/src/components/SyncPage.vue +5 -3
  35. web/src/components/TaskHistory.vue +30 -22
  36. web/src/components/TaskHistoryPage.vue +2 -2
  37. web/src/components/TaskPanel.vue +48 -37
  38. web/src/components/TasksPage.vue +1 -1
  39. web/src/components/TeamMembers.vue +18 -18
  40. web/src/components/ToastHost.vue +16 -15
  41. web/src/composables/useAppState.js +259 -0
  42. web/src/composables/useRotateStream.js +10 -1
  43. web/src/composables/useStatus.js +20 -8
  44. 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
- return json.loads(text)
 
 
91
  return []
92
 
93
 
94
  def save_accounts(accounts):
95
  """保存账号列表"""
96
- write_text(ACCOUNTS_FILE, json.dumps(accounts, indent=2, ensure_ascii=False))
 
97
 
98
 
99
  def find_account(accounts, email):
100
  """按邮箱查找账号"""
 
101
  for acc in accounts:
102
- if acc["email"] == email:
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 [a for a in load_accounts() if a["status"] == STATUS_ACTIVE and not _is_main_account_email(a.get("email"))]
 
 
 
 
 
 
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": "主号 Codex 已同步到 CPA",
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.cpa_sync import sync_main_codex_to_cpa
 
 
 
 
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": "主号 Codex 已同步到 CPA",
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.cpa_sync import sync_to_cpa
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.cpa_sync import sync_to_cpa
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.cpa_sync import sync_to_cpa
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 登录并同步到 CPA。"""
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
- local_path = local_auth_by_email.get(email)
707
- if local_path is not None and local_path.exists() and local_path.name == name:
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, sync_main_codex_to_cpa, sync_to_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-BZ-R3x09.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DHotZNsL.css">
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, **_kwargs):
 
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, **_kwargs):
 
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-2xl p-8 w-full max-w-sm relative overflow-hidden">
8
- <div class="absolute -top-20 -right-20 w-56 h-56 rounded-full opacity-40 blur-3xl pointer-events-none"
9
- style="background: radial-gradient(circle, rgba(99, 102, 241, 0.45), transparent 60%);"></div>
10
- <div class="relative">
11
- <div class="text-[10px] uppercase tracking-[0.3em] text-indigo-300/70 mb-1">Account Operations</div>
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-500/10 text-rose-300 border border-rose-500/30">
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-white/[0.03] border border-white/10 rounded-xl text-sm text-white
24
- font-mono placeholder:text-gray-600 focus-ring focus:border-indigo-400/40 mb-4 transition" />
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-300 mb-4 px-3 py-2 rounded-xl border border-amber-500/20 bg-amber-500/5 w-fit animate-rise">
43
- <span class="animate-spin inline-block w-3.5 h-3.5 border-2 border-amber-300 border-t-transparent rounded-full"></span>
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
- :master-health="masterHealth" @refresh="refresh" @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
- @task-started="onTaskStarted" @refresh="refresh" @reload-master-health="reloadMasterHealth" />
 
64
 
65
  <SyncPage v-else-if="currentPage === 'sync'" key="sync"
66
  :running-task="busyTask" :admin-status="adminStatus"
67
- @task-started="onTaskStarted" @refresh="refresh" />
 
68
 
69
  <OAuthPage v-else-if="currentPage === 'oauth'" key="oauth"
70
- :manual-account-status="manualAccountStatus" @refresh="refresh" @progress="onAdminProgress" />
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="refresh" @admin-progress="onAdminProgress" @reload-master-health="reloadMasterHealth" />
81
  </Transition>
82
  </div>
83
 
@@ -86,8 +88,9 @@
86
  </template>
87
 
88
  <script setup>
89
- import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
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 status = ref(null)
113
- const adminStatus = ref(null)
114
- const codexStatus = ref(null)
115
- const manualAccountStatus = ref(null)
116
- const tasks = ref([])
117
- const loading = ref(false)
118
- const runningTask = ref(null)
 
 
 
 
 
 
 
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
- loading.value = true
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
- console.error('加载母号健康度失败:', e)
214
  } finally {
215
  masterHealthLoading.value = false
216
  }
217
  }
218
 
219
  function onTaskStarted() {
220
- startPolling(10000)
221
- refresh()
222
  }
223
 
224
  function onAdminProgress() {
225
- startPolling(10000)
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 stopPolling() {
240
- if (pollTimer) {
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-xl
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 绕过 §Compat text-white 翻转,保留前景
67
  return [
68
  'text-on-accent border border-indigo-500/30',
69
- 'bg-gradient-to-br from-indigo-500 via-indigo-600 to-violet-600',
70
  'shadow-card hover:shadow-ring-accent',
71
- 'hover:from-indigo-400 hover:via-indigo-500 hover:to-violet-500',
72
- 'active:from-indigo-600 active:via-indigo-700 active:to-violet-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-gradient-to-br from-rose-500 via-red-600 to-rose-700',
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-5 gap-3">
18
  <div v-for="card in cards" :key="card.label"
19
- class="glass-soft rounded-xl p-3.5 lift-hover relative overflow-hidden">
20
- <div class="absolute top-0 right-0 w-16 h-16 rounded-full opacity-20 blur-xl pointer-events-none"
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-2xl overflow-hidden">
32
- <div class="px-5 py-4 border-b border-white/[0.04] flex items-center justify-between gap-3 flex-wrap">
33
  <div>
34
- <h2 class="text-base font-bold text-white tracking-tight">账号列表</h2>
35
- <p class="text-[11px] text-gray-500 mt-0.5">{{ status.accounts?.length || 0 }} 个账号 · 实时配额来自 quota_cache</p>
 
 
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
- <svg viewBox="0 0 16 16" class="w-3.5 h-3.5"><path fill="currentColor" d="M5 6v6h1V6H5zm2 0v6h1V6H7zm2 0v6h1V6H9zm2 0v6h1V6h-1zM4 4l1-2h6l1 2h2v1H2V4h2zm1 1v9a2 2 0 002 2h2a2 2 0 002-2V5H5z"/></svg>
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
- <svg viewBox="0 0 16 16" class="w-3.5 h-3.5"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M3 8a5 5 0 018.5-3.5L13 3M13 8a5 5 0 01-8.5 3.5L3 13M13 3v3h-3M3 13v-3h3" stroke-linecap="round" stroke-linejoin="round"/></svg>
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-xl text-sm border animate-rise" :class="messageClass">
59
  {{ message }}
60
  </div>
61
  <div v-if="!adminReady"
62
- class="mx-5 mt-4 px-4 py-2.5 rounded-xl text-sm border bg-amber-500/10 text-amber-300 border-amber-500/30">
63
  请先在「设置」页完成管理员登录后,才能操作账号。
64
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  <div class="overflow-x-auto">
67
  <table class="w-full text-sm">
68
  <thead>
69
- <tr class="text-gray-500 text-left border-b border-white/[0.04] text-[10px] uppercase tracking-widest">
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 status.accounts" :key="acc.email"
93
- class="row-hoverable border-b border-white/[0.03] hover:bg-white/[0.02] group"
94
- :class="isSelected(acc.email) ? 'bg-indigo-500/[0.05]' : ''">
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-gray-600 font-mono text-xs">{{ String(i + 1).padStart(2, '0') }}</td>
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-gradient-to-br from-indigo-500/30 to-violet-500/30 text-indigo-100 border border-indigo-400/30">
109
  Master
110
  </span>
111
- <span class="font-mono text-[12px] text-gray-100">{{ acc.email }}</span>
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-gray-500 font-mono text-[11px]">{{ quotaReset(acc, 'primary') }}</td>
127
- <td class="px-4 py-3.5 text-gray-500 font-mono text-[11px]">{{ quotaReset(acc, 'weekly') }}</td>
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-500/10 text-amber-300 border border-amber-500/30"
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.status === 'active'"
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.status === 'active' || acc.status === 'personal' || acc.is_main_account"
160
  variant="ghost" size="sm"
161
  :disabled="actionEmail === acc.email"
162
  @click="exportCodexAuth(acc.email)">
163
  <template #icon>
164
- <svg viewBox="0 0 16 16" class="w-3.5 h-3.5"><path fill="currentColor" d="M8 1l3 3h-2v5H7V4H5l3-3zM3 12v-2h1v2h8v-2h1v2a1 1 0 01-1 1H4a1 1 0 01-1-1z"/></svg>
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-white/[0.04] p-5 bg-black/10">
184
  <div class="flex items-center justify-between mb-3">
185
  <div>
186
- <h2 class="text-sm font-bold text-white tracking-tight">注册失败明细</h2>
187
- <div class="text-[11px] text-gray-500 mt-0.5">未能入池的注册尝试 (add-phone / duplicate / OAuth 失败 等)</div>
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-white/[0.02] border-white/[0.06] text-gray-300">
196
- {{ cat }}: <span class="text-rose-300 font-mono ml-0.5 tabular">{{ cnt }}</span>
197
  </span>
198
  </div>
199
- <div class="overflow-x-auto rounded-xl border border-white/[0.04]">
 
 
 
 
200
  <table class="w-full text-sm">
201
- <thead class="text-[10px] uppercase tracking-widest text-gray-500 border-b border-white/[0.04] bg-white/[0.01]">
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-white/[0.03] text-xs">
211
  <tr v-if="!failuresItems.length">
212
- <td class="px-3 py-4 text-gray-600 italic" colspan="5">暂无失败记录</td>
213
  </tr>
214
- <tr v-for="(f, idx) in failuresItems" :key="idx" class="hover:bg-white/[0.02]">
215
- <td class="px-3 py-2 text-gray-500 font-mono">{{ fmtTs(f.timestamp) }}</td>
216
- <td class="px-3 py-2 text-gray-300 font-mono">{{ f.email || '-' }}</td>
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-gray-400">{{ f.reason }}</td>
222
- <td class="px-3 py-2 text-gray-600 font-mono text-[10px]">{{ fmtFailureExtra(f) }}</td>
223
  </tr>
224
  </tbody>
225
  </table>
@@ -228,36 +274,38 @@
228
 
229
  <!-- Codex 认证导出弹窗 -->
230
  <div v-if="exportData"
231
- class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-rise"
232
  @click.self="exportData = null">
233
- <div class="glass rounded-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
234
- <div class="px-5 py-4 border-b border-white/[0.04] flex items-center justify-between">
235
- <h3 class="text-white font-bold tracking-tight">Codex CLI 认证文件</h3>
236
- <button @click="exportData = null" class="text-gray-400 hover:text-white text-2xl leading-none transition">&times;</button>
 
 
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-500/10 border border-amber-500/30 rounded-xl text-sm text-amber-200 space-y-2">
240
  <div class="font-semibold">使用步骤:</div>
241
- <ol class="list-decimal list-inside space-y-1 text-xs text-amber-300/90">
242
  <li>退出当前 Codex CLI 会话</li>
243
- <li>删除旧文件:<code class="bg-black/40 px-1 rounded">rm ~/.codex/auth.json</code></li>
244
- <li>将下方内容保存到 <code class="bg-black/40 px-1 rounded">~/.codex/auth.json</code>(Windows: <code class="bg-black/40 px-1 rounded">%APPDATA%\codex\auth.json</code>)</li>
245
  <li>重新启动 Codex CLI</li>
246
  </ol>
247
- <div class="text-xs text-amber-300/60">导出后 Codex CLI 直连 OpenAI,不走 CPA 代理,响应更快。</div>
248
  </div>
249
  <div class="relative">
250
- <pre class="bg-black/60 border border-white/[0.06] rounded-xl p-4 text-xs font-mono text-gray-300 overflow-x-auto whitespace-pre">{{ exportJson }}</pre>
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-white/[0.04] flex justify-end gap-2">
258
  <AtButton variant="primary" @click="downloadExport">
259
  <template #icon>
260
- <svg viewBox="0 0 16 16" class="w-3.5 h-3.5"><path fill="currentColor" d="M8 11l-4-4h2.5V2h3v5H12L8 11zM3 13h10v1H3z"/></svg>
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-xl p-4 h-20 shimmer-bg"></div>
275
  </div>
276
- <div class="glass-soft rounded-2xl h-64 shimmer-bg"></div>
277
  </div>
278
  </template>
279
 
280
  <script setup>
281
- import { computed, onMounted, ref, watch } from 'vue'
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
- const failuresItems = ref([])
323
- const failuresCounts = ref({})
324
- const failuresLoading = ref(false)
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-500/10 text-rose-300 border-rose-500/30',
349
- duplicate_exhausted: 'bg-orange-500/10 text-orange-300 border-orange-500/30',
350
- register_failed: 'bg-amber-500/10 text-amber-300 border-amber-500/30',
351
- oauth_failed: 'bg-purple-500/10 text-purple-300 border-purple-500/30',
352
- kick_failed: 'bg-yellow-500/10 text-yellow-300 border-yellow-500/30',
353
- exception: 'bg-red-500/10 text-red-300 border-red-500/30',
354
- master_subscription_degraded: 'bg-orange-500/10 text-orange-300 border-orange-500/30',
355
  }
356
- return map[cat] || 'bg-gray-500/10 text-gray-400 border-gray-500/30'
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
- (props.status?.accounts || []).filter(a => !a.is_main_account).map(a => a.email)
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 props.status?.accounts || []) {
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 ?? (props.status.accounts || []).filter(a => a.status === 'degraded_grace').length
420
  return [
421
- { label: 'Active', value: s.active || 0, color: 'text-emerald-300', dot: 'rgb(52, 211, 153)', glow: 'radial-gradient(circle, rgba(52, 211, 153, 0.45), transparent 60%)' },
422
- { label: 'Standby', value: s.standby || 0, color: 'text-amber-300', dot: 'rgb(251, 191, 36)', glow: 'radial-gradient(circle, rgba(251, 191, 36, 0.40), transparent 60%)' },
423
- { label: 'Grace', value: dg, color: 'text-orange-300', dot: 'rgb(251, 146, 60)', glow: 'radial-gradient(circle, rgba(251, 146, 60, 0.40), transparent 60%)' },
424
- { label: 'Personal', value: s.personal || 0, color: 'text-violet-300', dot: 'rgb(167, 139, 250)', glow: 'radial-gradient(circle, rgba(167, 139, 250, 0.40), transparent 60%)' },
425
- { label: 'Total', value: s.total || 0, color: 'text-white', dot: 'rgb(99, 102, 241)', glow: 'radial-gradient(circle, rgba(99, 102, 241, 0.45), transparent 60%)' },
 
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-gray-500 leading-none">{{ centerLabel }}</div>
23
- <div class="text-2xl font-bold text-white tabular leading-none mt-1">{{ centerValue }}</div>
24
- <div v-if="centerHint" class="text-[10px] text-gray-500 mt-0.5">{{ centerHint }}</div>
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-white">日志</h2>
5
  <div class="flex items-center gap-3">
6
- <label class="flex items-center gap-2 text-sm text-gray-400">
7
- <input type="checkbox" v-model="autoScroll" class="rounded bg-gray-800 border-gray-700" />
 
8
  自动滚动
9
  </label>
10
- <button @click="fetchLogs" :disabled="loading"
11
- class="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-sm rounded-lg border border-gray-700 transition disabled:opacity-50">
 
 
12
  刷新
13
- </button>
14
  <button @click="clearLogs"
15
- class="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-sm rounded-lg border border-gray-700 transition text-gray-400 hover:text-white">
 
16
  清空
17
  </button>
18
  </div>
19
  </div>
20
 
21
  <div ref="logContainer"
22
- class="bg-gray-950 border border-gray-800 rounded-xl p-3 md:p-4 font-mono text-xs leading-relaxed h-[calc(100vh-200px)] md:h-[600px] overflow-y-auto">
23
- <div v-if="logs.length === 0" class="text-gray-600 text-center py-8">暂无日志</div>
24
  <div v-for="(log, i) in logs" :key="i"
25
- class="py-0.5 flex gap-3 hover:bg-gray-900/50">
26
- <span class="text-gray-600 shrink-0">{{ formatTime(log.time) }}</span>
27
  <span class="shrink-0 w-16"
28
  :class="{
29
- 'text-red-400': log.level === 'ERROR',
30
- 'text-yellow-400': log.level === 'WARNING',
31
- 'text-blue-400': log.level === 'INFO',
32
- 'text-gray-500': log.level === 'DEBUG',
33
  }">{{ log.level }}</span>
34
- <span class="text-gray-300 break-all">{{ log.message }}</span>
35
  </div>
36
  </div>
37
  </div>
38
  </template>
39
 
40
  <script setup>
41
- import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
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
- // 保留最新 1000
66
- if (logs.value.length > 1000) {
67
- logs.value = logs.value.slice(-1000)
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(fetchLogs, 3000)
 
 
 
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-gray-800/40 border border-gray-800 rounded">
17
  <div class="flex items-center justify-between mb-2">
18
- <span class="text-sm text-white">{{ mode === 'setup' ? '1. 邮箱后端' : '1. 后端类型' }}</span>
19
- <span class="text-xs" :class="state === 'PROVIDER' ? 'text-yellow-400' : 'text-green-400'">
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-blue-600 border-blue-500 text-white'
29
- : 'bg-gray-800 border-gray-700 text-gray-300'"
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-white">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,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-gray-800 border border-gray-700 rounded text-sm text-white" />
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-gray-800 border border-gray-700 rounded text-sm text-white" />
55
  <input
56
  v-model="form[passwordKey]"
57
  type="password"
58
  :placeholder="passwordPlaceholder"
59
- class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white" />
60
  <button
61
  @click="testConnection"
62
  :disabled="testing || !canTestConnection"
63
- class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs rounded">
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-white">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-gray-800 border border-gray-700 rounded text-sm text-white">
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-gray-800 border border-gray-700 rounded text-sm text-white" />
88
  <button
89
  @click="verifyDomain"
90
  :disabled="verifying || !selectedDomain"
91
- class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs rounded">
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
- const form = computed({
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-yellow-400'
158
- return 'text-green-400'
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-green-400' : 'text-yellow-400')
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-gray-800/20 border-gray-800'
176
- if (tgt < cur) return 'bg-green-900/10 border-green-900/40'
177
- return 'bg-blue-900/10 border-blue-700/40'
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-2xl border lift-hover"
14
- :class="[tone.border, 'shadow-2xl']" :style="containerStyle" role="alert">
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-xl"
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-xl flex items-center justify-center"
27
  :class="tone.iconWrap">
28
- <svg v-if="severity === 'critical'" viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2">
29
- <path d="M12 9v4M12 17h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z" stroke-linejoin="round" stroke-linecap="round"/>
30
- </svg>
31
- <svg v-else-if="severity === 'warning'" viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2">
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-black/30 border border-white/10"
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-500/30',
190
- tagText: 'text-amber-100',
191
- iconWrap: 'bg-amber-500/15 text-amber-300 border border-amber-400/30',
192
- title: 'text-amber-100',
193
- body: 'text-amber-100/80',
194
- border: 'border-amber-500/40',
195
- btn: 'bg-amber-500/15 hover:bg-amber-500/25 text-amber-100 border-amber-400/40',
 
196
  }
197
  }
198
  if (severity.value === 'info') {
199
  return {
200
  tag: 'STALE',
201
- tagBg: 'bg-slate-500/30',
202
- tagText: 'text-slate-100',
203
- iconWrap: 'bg-slate-500/15 text-slate-300 border border-slate-400/30',
204
- title: 'text-slate-100',
205
- body: 'text-slate-200/80',
206
- border: 'border-slate-500/30',
207
- btn: 'bg-slate-500/15 hover:bg-slate-500/25 text-slate-100 border-slate-400/30',
 
208
  }
209
  }
210
  return {
211
  tag: 'ALERT',
212
- tagBg: 'bg-rose-500/30',
213
- tagText: 'text-rose-100',
214
- iconWrap: 'bg-rose-500/15 text-rose-300 border border-rose-400/30',
215
- title: 'text-rose-100',
216
- body: 'text-rose-100/85',
217
- border: 'border-rose-500/40',
218
- btn: 'bg-rose-500/15 hover:bg-rose-500/25 text-rose-100 border-rose-400/40',
 
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="bg-gray-900 border border-gray-800 rounded-xl p-4">
4
  <div class="flex items-center justify-between gap-4 mb-4">
5
  <div>
6
- <h2 class="text-lg font-semibold text-white">OAuth 登录</h2>
7
- <p class="text-sm text-gray-400 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-yellow-500/10 text-yellow-300 border-yellow-500/20'
15
- : 'bg-gray-800 text-gray-400 border-gray-700'"
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-green-500/10 text-green-400 border-green-500/20"
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-red-500/10 text-red-400 border-red-500/20"
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-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg transition disabled:opacity-50"
44
  >
45
  {{ manualSubmitting ? '生成中...' : '生成 OAuth 链接' }}
46
  </button>
47
  </div>
48
 
49
  <div v-else class="space-y-4">
50
- <div class="text-sm text-gray-300">
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-500/10 text-blue-300 border-blue-500/20'
58
- : 'bg-amber-500/10 text-amber-300 border-amber-500/20'"
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-gray-500">OAuth 链接</div>
69
- <div class="p-3 bg-gray-800 border border-gray-700 rounded-lg text-xs font-mono break-all text-gray-200">
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-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg transition"
80
  >
81
  打开 OAuth 链接
82
  </a>
@@ -84,7 +84,7 @@
84
 
85
  <div
86
  v-if="manualAccountStatus?.callback_received"
87
- class="text-xs text-emerald-300"
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-gray-800 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500"
99
  />
100
  <button
101
  @click="submitManualCallback"
102
  :disabled="manualSubmitting || !manualCallbackUrl"
103
- class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg transition disabled:opacity-50"
104
  >
105
  {{ manualSubmitting ? '提交中...' : '提交回调 URL' }}
106
  </button>
107
  </div>
108
 
109
- <div v-if="manualSubmitting && manualSubmittingHint" class="text-xs text-emerald-300">
110
  {{ manualSubmittingHint }}
111
  </div>
112
 
@@ -114,7 +114,7 @@
114
  <button
115
  @click="cancelManualAccount"
116
  :disabled="manualSubmitting"
117
- class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-sm text-gray-200 rounded-lg border border-gray-700 transition disabled:opacity-50"
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-green-500/10 text-green-400 border-green-500/20'
163
- : 'bg-red-500/10 text-red-400 border-red-500/20'
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-2xl p-5 lg:p-6 lift-hover relative overflow-hidden">
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-xl p-3 border bg-white/[0.02] hover:bg-white/[0.05] transition-all"
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-xl border p-3 flex items-start gap-3"
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
- <svg viewBox="0 0 16 16" class="w-4 h-4">
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-300',
102
- valueClass: 'text-emerald-200',
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-300',
109
- valueClass: 'text-orange-200',
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-300',
116
- valueClass: 'text-amber-200',
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-300',
123
- valueClass: 'text-rose-200',
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: 'linear-gradient(135deg, rgba(251, 146, 60, 0.14), rgba(244, 63, 94, 0.10))',
151
- border: 'border-orange-500/30',
152
- iconWrap: 'bg-orange-500/15',
153
- iconFill: 'rgb(253, 186, 116)',
154
- text: 'text-orange-200',
155
  }
156
  }
157
  if (!m || m.healthy === true || r === 'active') {
158
  return {
159
- bg: 'linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(20, 184, 166, 0.05))',
160
- border: 'border-emerald-500/20',
161
- iconWrap: 'bg-emerald-500/15',
162
- iconFill: 'rgb(110, 231, 183)',
163
- text: 'text-emerald-200',
164
  }
165
  }
166
  if (r === 'subscription_cancelled') {
167
  return {
168
- bg: 'linear-gradient(135deg, rgba(251, 146, 60, 0.14), rgba(244, 63, 94, 0.10))',
169
- border: 'border-orange-500/30',
170
- iconWrap: 'bg-orange-500/15',
171
- iconFill: 'rgb(253, 186, 116)',
172
- text: 'text-orange-200',
173
  }
174
  }
175
  if (r === 'network_error') {
176
  return {
177
- bg: 'linear-gradient(135deg, rgba(100, 116, 139, 0.14), rgba(71, 85, 105, 0.10))',
178
- border: 'border-slate-500/25',
179
- iconWrap: 'bg-slate-500/15',
180
- iconFill: 'rgb(203, 213, 225)',
181
- text: 'text-slate-200',
182
  }
183
  }
184
  return {
185
- bg: 'linear-gradient(135deg, rgba(244, 63, 94, 0.14), rgba(220, 38, 38, 0.10))',
186
- border: 'border-rose-500/30',
187
- iconWrap: 'bg-rose-500/15',
188
- iconFill: 'rgb(253, 164, 175)',
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-300/70 mb-1">Configuration</div>
5
  <h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">设置</h2>
6
- <p class="text-sm text-gray-400 mt-1">管理员登录、巡检策略、邮箱后端、生命周期参数,皆可在此调整。</p>
7
  </div>
8
 
9
  <!-- F3 — 共享父级 master-health banner -->
@@ -13,20 +13,20 @@
13
  :loading="masterHealthLoading"
14
  @refresh="onReloadMasterHealth" />
15
 
16
- <div class="glass rounded-2xl 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-gray-400 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-green-500/10 text-green-400 border-green-500/20'
28
  : adminBusy
29
- ? 'bg-yellow-500/10 text-yellow-300 border-yellow-500/20'
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-green-400 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-gray-400 text-xs space-y-2">
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-xl p-4 bg-surface-hover">
122
  <div class="text-sm font-medium text-ink-950">或手动导入 session_token</div>
123
- <p class="text-xs text-gray-400 mt-1 mb-3">
124
  适合你已经在浏览器里拿到 <span class="font-mono">__Secure-next-auth.session-token</span> 的场景。系统会校验 token,并自动识别 workspace ID / 名称。
125
  </p>
126
- <div class="text-gray-400 text-xs space-y-2 mb-3">
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-gray-300">
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-gray-700 disabled:hover:bg-gray-700"
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-gray-300">
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-gray-700 disabled:hover:bg-gray-700"
255
  >
256
  {{ submitting ? '提交中...' : '确认组织选择' }}
257
  </button>
258
  </div>
259
 
260
- <div v-if="submitting && adminSubmittingHint" class="text-xs text-blue-300">
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-gray-300">
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-300">
318
  {{ codexSubmittingHint }}
319
  </div>
320
 
@@ -330,15 +330,15 @@
330
  </div>
331
  </div>
332
 
333
- <div class="glass rounded-2xl 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-green-400 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-gray-400 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,7 +346,7 @@
346
  </div>
347
  </div>
348
  <div>
349
- <label class="block text-sm text-gray-400 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,7 +354,7 @@
354
  </div>
355
  </div>
356
  <div>
357
- <label class="block text-sm text-gray-400 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,18 +375,18 @@
375
  </div>
376
 
377
  <!-- SPEC-1 §FR-008 / AC-017 — 邮箱后端切换 (Settings 复用 SetupPage 4 步状态机) -->
378
- <div class="glass rounded-2xl 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-gray-400 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-yellow-500/10 text-yellow-300 border-yellow-500/20'
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-2xl p-5">
427
  <div class="mb-4">
428
  <h2 class="text-lg font-semibold text-ink-950">账号生命周期</h2>
429
- <p class="text-sm text-gray-400 mt-1">
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-gray-400">{{ preferredSeatType === 'codex' ? '锁定 Codex 席位' : '优先 ChatGPT 完整席位' }}</span>
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-gray-400 mb-1">并发上限 (1-16)</div>
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-gray-400 mb-1">同号去重冷却 (分钟,1-1440)</div>
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-green-500/10 text-green-400 border-green-500/20'
597
- : 'bg-red-500/10 text-red-400 border-red-500/20'
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 (e) {
642
- console.error('加载巡检配置失败:', e)
643
- }
644
  try {
645
  const seat = await api.getPreferredSeatType()
646
  preferredSeatType.value = seat?.value || 'default'
647
- } catch (e) {
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 (e) {
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-green-500/10 text-green-400 border-green-500/20'
664
- : 'bg-red-500/10 text-red-400 border-red-500/20'
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-green-500/10 text-green-400 border-green-500/20'
886
- else if (type === 'warning') mailMessageClass.value = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
887
- else mailMessageClass.value = 'bg-red-500/10 text-red-400 border-red-500/20'
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(`⚠️ 探测邮箱回收失败,请到后台手动删除: ${payload.leakedProbe.email}`, 'warning')
899
  }
900
  }
901
 
902
  function onMailError(resp) {
903
  if (resp?.soft && resp.warnings) {
904
- setMailMessage('⚠️ ' + resp.warnings.join('; '), 'warning')
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="bg-gray-900 border border-gray-800 rounded-xl p-6 w-full max-w-2xl">
4
- <h1 class="text-xl font-bold text-white text-center mb-2">AutoTeam 初始配置</h1>
5
- <p class="text-sm text-gray-400 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,27 +10,28 @@
10
 
11
  <!-- 卡片 1+2+3:Mail Provider 选择 / 连接 / 域名(Round 7 P2.2 抽 MailProviderCard) -->
12
  <MailProviderCard
13
- v-model="form"
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-white mb-3">4. 其他配置</h2>
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-gray-400 mb-1">
25
  {{ field.prompt }}
26
  <span v-if="!field.optional" class="text-red-400">*</span>
27
- <span v-if="field.key === 'API_KEY'" class="text-gray-500 text-[10px] ml-1">(留空自动生成)</span>
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-gray-800 border border-gray-700 rounded text-sm text-white" />
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-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition">
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-gray-800/20 border-gray-800'
89
- if (tgt < cur) return 'bg-green-900/10 border-green-900/40'
90
- return 'bg-blue-900/10 border-blue-700/40'
91
  }
92
 
93
  function onMailStateChange(s) {
94
  state.value = s
95
  }
96
 
 
 
 
 
97
  function onMailVerified(payload) {
98
  if (payload?.leakedProbe) {
99
- message.value = `⚠️ 探测邮箱回收失败,请到后台手动删除: ${payload.leakedProbe.email}`
100
- messageClass.value = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
101
  }
102
  }
103
 
104
  function onMailError(resp) {
105
  if (resp?.soft && resp.warnings) {
106
- message.value = '⚠️ ' + resp.warnings.join('; ')
107
- messageClass.value = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20'
108
  return
109
  }
110
  message.value = `${resp.error_code || 'ERROR'}: ${resp.message || '未知错误'}` +
111
  (resp.hint ? ` — ${resp.hint}` : '')
112
- messageClass.value = 'bg-red-500/10 text-red-400 border-red-500/20'
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-green-500/10 text-green-400 border-green-500/20'
123
  setTimeout(() => emit('configured'), 1000)
124
  } catch (e) {
125
  message.value = e.message
126
- messageClass.value = 'bg-red-500/10 text-red-400 border-red-500/20'
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-red-500/10 text-red-400 border-red-500/20'
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-gradient-to-br from-indigo-500 to-violet-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,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-xl 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-gradient-to-b from-indigo-500 to-violet-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,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-xl 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-xl 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,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-gradient-to-r from-indigo-500 to-violet-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>
 
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-black/30`)
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(167, 139, 250, 0.22) 0%, rgba(217, 70, 239, 0.14) 100%)',
 
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-300/70 mb-1">Sync Center</div>
5
- <h2 class="text-2xl font-extrabold text-white tracking-tight">同步中心</h2>
6
- <p class="text-sm text-gray-400 mt-1">本地账号池对账、同步到 CPA、从 CPA 反向拉取认证文件。</p>
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 bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
3
- <div class="px-4 py-3 border-b border-gray-800">
4
- <h2 class="text-lg font-semibold text-white">任务历史</h2>
 
 
 
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-gray-400 text-left border-b border-gray-800">
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 tasks" :key="task.task_id"
26
- class="border-b border-gray-800/50 hover:bg-gray-800/30 transition">
27
- <td class="px-4 py-3 font-mono text-xs text-gray-400">{{ task.task_id }}</td>
28
  <td class="px-4 py-3">
29
- <span class="px-2 py-0.5 bg-gray-800 rounded text-xs font-medium text-gray-300">
30
  {{ task.command }}
31
  </span>
32
  </td>
33
- <td class="px-4 py-3 text-xs text-gray-400">{{ formatParams(task.params) }}</td>
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-gray-400">{{ formatTime(task.created_at) }}</td>
42
- <td class="px-4 py-3 text-xs text-gray-400">{{ duration(task) }}</td>
43
- <td class="px-4 py-3 text-xs max-w-xs truncate" :class="task.error ? 'text-red-400' : 'text-gray-400'">
44
  {{ task.error || formatResult(task.result) }}
45
  </td>
46
  </tr>
@@ -51,25 +54,30 @@
51
  </template>
52
 
53
  <script setup>
54
- defineProps({
 
 
55
  tasks: { type: Array, default: () => [] },
56
  })
57
 
 
 
 
58
  function taskStatusClass(s) {
59
  return {
60
- pending: 'text-gray-400',
61
- running: 'text-yellow-400',
62
- completed: 'text-green-400',
63
- failed: 'text-red-400',
64
- }[s] || 'text-gray-400'
65
  }
66
 
67
  function taskDotClass(s) {
68
  return {
69
- pending: 'bg-gray-400',
70
- completed: 'bg-green-400',
71
- failed: 'bg-red-400',
72
- }[s] || 'bg-gray-400'
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-white mb-2">任务历史</h2>
4
- <p class="text-sm text-gray-400 mb-6">
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-2xl 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-white tracking-tight">{{ panelTitle }}</h2>
6
- <p class="text-[11px] text-gray-500 mt-0.5">点击按钮提交后台任务,轮询返回结果</p>
7
  </div>
8
  <div v-if="runningTask" class="flex items-center gap-2 text-xs">
9
- <span class="text-gray-500 uppercase tracking-widest text-[10px]">Running</span>
10
- <span class="font-mono text-amber-300 px-2 py-0.5 rounded bg-amber-500/10 border border-amber-500/30">{{ runningTask.command }}</span>
11
- <span class="font-mono text-gray-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,7 +16,7 @@
16
  </div>
17
 
18
  <div v-if="showAdminHint"
19
- class="mb-4 px-4 py-2.5 rounded-xl text-sm border bg-amber-500/10 text-amber-300 border-amber-500/30">
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-xl 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
- <component :is="action.icon" class="w-4 h-4 shrink-0" :stroke-width="2" />
34
- {{ action.label }}
 
35
  </span>
36
  </button>
37
  </div>
38
 
39
  <!-- round-12 F2 — rotate 实时进度面板 (SSE) -->
40
- <div class="mt-4 rounded-xl border border-hairline bg-surface">
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-xl"
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-xl border border-white/[0.04] bg-white/[0.02] flex flex-wrap items-center gap-2 text-sm">
82
- <span class="text-[10px] uppercase tracking-widest text-gray-500 font-semibold mr-1">注册域名</span>
83
- <span class="text-gray-600">@</span>
84
  <input v-model="domainInput" type="text" placeholder="your-domain.com"
85
- class="flex-1 min-w-[180px] px-3 py-1.5 bg-black/30 border border-white/10 rounded-lg text-white text-sm font-mono focus-ring focus:border-indigo-400/40 transition" />
86
  <AtButton variant="primary" size="sm" :loading="domainBusy" :disabled="!domainInput" @click="saveDomain">
87
  保存并验证
88
  </AtButton>
89
- <span v-if="currentDomain" class="text-[11px] text-gray-500 font-mono">当前: @{{ currentDomain }}</span>
90
- <span v-if="domainMsg" class="ml-1 text-[11px] font-medium" :class="domainMsgOk ? 'text-emerald-300' : 'text-rose-300'">{{ domainMsg }}</span>
91
  </div>
92
 
93
  <!-- 参数输入 -->
94
  <div v-if="showParams"
95
- class="mt-4 p-3 rounded-xl border border-indigo-500/30 bg-indigo-500/5 flex items-center gap-3 animate-rise">
96
- <label class="text-[11px] uppercase tracking-widest text-indigo-300 font-semibold">{{ paramLabel }}</label>
97
  <input v-model.number="paramValue" type="number" min="1" :max="paramMax"
98
- class="w-24 px-3 py-1.5 bg-black/30 border border-white/10 rounded-lg text-white text-sm font-mono focus-ring focus:border-indigo-400/40 tabular" />
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-xl text-sm border animate-rise" :class="messageClass">
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 { useStatusInvalidator } from '../composables/useStatus.js'
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 实时进度 + vue-query invalidation
143
- // useStatusInvalidator 内部:SSE 事件 → invalidateQueries(['status'])
144
- // 它返回 useRotateStream reactive ref,模板里直接 v-for events
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: 'violet' },
183
- { key: 'fill-personal', group: 'pool', label: '生成免费号', icon: Coins, method: 'startFillPersonal', needParam: true, paramName: 'count', tone: 'fuchsia' },
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-500/10 text-amber-300 border-amber-500/30'
 
222
  } catch (e) {
223
  message.value = `停���失败: ${e.message}`
224
- messageClass.value = 'bg-rose-500/10 text-rose-300 border-rose-500/30'
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
- violet: 'text-violet-700 bg-violet-50 border-violet-200 hover:bg-violet-100',
306
- fuchsia: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-200 hover:bg-fuchsia-100',
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-500/10 text-emerald-300 border-emerald-500/30'
347
  emit('refresh')
348
  } else {
349
  const result = await api[action.method](param)
350
  message.value = `任务已提交: ${result.task_id}`
351
- messageClass.value = 'bg-blue-500/10 text-blue-300 border-blue-500/30'
352
  emit('task-started')
353
  }
354
  } catch (e) {
355
  message.value = e.message
356
- messageClass.value = 'bg-rose-500/10 text-rose-300 border-rose-500/30'
 
 
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-white 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>
 
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-white">Team 成员</h2>
5
  <button @click="fetchMembers" :disabled="loading"
6
- class="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-sm rounded-lg border border-gray-700 transition disabled:opacity-50">
7
  {{ loading ? '加载中...' : '刷新' }}
8
  </button>
9
  </div>
10
 
11
- <div v-if="error" class="mb-4 px-4 py-3 rounded-lg text-sm bg-red-500/10 text-red-400 border border-red-500/20">
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-gray-800 rounded-lg text-gray-300">成员: <span class="text-white font-medium">{{ data.total }}</span></span>
19
- <span v-if="data.invites > 0" class="px-3 py-1.5 bg-gray-800 rounded-lg text-gray-300">待接受邀请: <span class="text-yellow-400 font-medium">{{ data.invites }}</span></span>
20
  </div>
21
 
22
  <!-- 成员表格 -->
23
- <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
24
  <div class="overflow-x-auto">
25
  <table class="w-full text-sm">
26
  <thead>
27
- <tr class="text-gray-400 text-left border-b border-gray-800">
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-gray-800/50 hover:bg-gray-800/30 transition">
39
- <td class="px-4 py-3 text-gray-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-purple-500/10 text-purple-400': m.role === 'account-owner',
45
- 'bg-blue-500/10 text-blue-400': m.role === 'account-admin',
46
- 'bg-gray-500/10 text-gray-300': 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-yellow-500/10 text-yellow-400' : 'bg-green-500/10 text-green-400'">
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-blue-400' : 'text-gray-500'">
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-gray-800 text-gray-500 border-gray-700 cursor-not-allowed'
70
- : 'bg-rose-600/10 text-rose-400 border-rose-500/30 hover:bg-rose-600/20'"
71
  >
72
  {{ removingId === memberKey(m) ? '处理中...' : '移出' }}
73
  </button>
@@ -80,10 +80,10 @@
80
  </div>
81
 
82
  <!-- Loading -->
83
- <div v-else-if="loading" class="bg-gray-900 border border-gray-800 rounded-xl h-64 animate-pulse"></div>
84
 
85
  <!-- Empty -->
86
- <div v-else class="text-center text-gray-500 py-12">
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-xl border backdrop-blur-md shadow-2xl 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
- <svg v-if="t.tone === 'success'" viewBox="0 0 16 16" class="w-4 h-4 text-emerald-300"><path fill="currentColor" d="M6.5 11.4L3.6 8.5l1.1-1.1 1.8 1.8L11.3 4l1.1 1.1z"/></svg>
13
- <svg v-else-if="t.tone === 'error'" viewBox="0 0 16 16" class="w-4 h-4 text-rose-300"><path fill="currentColor" d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
14
- <svg v-else-if="t.tone === 'warning'" viewBox="0 0 16 16" class="w-4 h-4 text-amber-300"><path fill="currentColor" d="M8 1.5l7 12.5H1L8 1.5z"/></svg>
15
- <svg v-else viewBox="0 0 16 16" class="w-4 h-4 text-blue-300"><circle cx="8" cy="8" r="6" fill="currentColor" opacity="0.4"/><circle cx="8" cy="8" r="3" fill="currentColor"/></svg>
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-900/40 border-emerald-500/30',
34
- error: 'bg-rose-900/40 border-rose-500/30',
35
- warning: 'bg-amber-900/40 border-amber-500/30',
36
- info: 'bg-slate-900/60 border-slate-500/30',
37
- }[tone] || 'bg-slate-900/60 border-slate-500/30'
38
  }
39
  function titleColor(tone) {
40
  return {
41
- success: 'text-emerald-100',
42
- error: 'text-rose-100',
43
- warning: 'text-amber-100',
44
- info: 'text-slate-100',
45
- }[tone] || 'text-slate-100'
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(url)
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-violet-500/[0.08]',
86
- text: 'text-violet-300',
87
- border: 'border-violet-400/30',
88
- dot: 'bg-violet-400',
89
  pulse: false,
90
- gradient: 'from-violet-400/20 via-fuchsia-500/15 to-purple-500/15',
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
- /* ── 渐变按钮上的前景(绕过 §Compat text-white 翻转)──────── */
133
  .text-on-accent { color: #ffffff !important; }
134
 
135
  /* ─────────────────────────────────────────────────────────────────
136
  * § Compat Layer — 旧深色 utility → 明亮等价值
137
- * 21 组件仍硬编码 text-white / text-gray-X / bg-white/[0.0X] / border-white/[X]
138
- * 统一在此一次性翻转,避免逐文件改 21 套类名;视觉影响整体可控
139
- * 渐变按钮等需要保留白前景的元素请使用 .text-on-accent。
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; }