mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-08-12 07:54:24 +08:00
feat(proxy): add batch delete functionality for proxies and enhance verification code handling
This commit is contained in:
@@ -345,13 +345,16 @@ class LoginEngine(RegistrationEngine):
|
||||
return result
|
||||
|
||||
self._log("10. 等待验证码...")
|
||||
code = self._get_verification_code()
|
||||
code, otp_phase = self._await_verification_code_with_resends(
|
||||
self._send_verification_code,
|
||||
timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...",
|
||||
non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...",
|
||||
)
|
||||
if not code:
|
||||
self._log("10. 验证码超时,重新发送...")
|
||||
if self._send_verification_code():
|
||||
code = self._get_verification_code()
|
||||
if not code:
|
||||
result.error_message = "获取验证码失败"
|
||||
result.error_message = (
|
||||
otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败"
|
||||
)
|
||||
result.error_code = otp_phase.error_code if otp_phase else ""
|
||||
return result
|
||||
|
||||
self._log("11. 验证验证码...")
|
||||
@@ -380,13 +383,16 @@ class LoginEngine(RegistrationEngine):
|
||||
return result
|
||||
|
||||
self._log("15. 等待验证码...")
|
||||
code = self._get_verification_code()
|
||||
code, otp_phase = self._await_verification_code_with_resends(
|
||||
self._send_verification_code_passwordless,
|
||||
timeout_retry_log_template="15. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...",
|
||||
non_openai_retry_log_template="15. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...",
|
||||
)
|
||||
if not code:
|
||||
self._log("15. 验证码超时,重新发送...")
|
||||
if self._send_verification_code_passwordless():
|
||||
code = self._get_verification_code()
|
||||
if not code:
|
||||
result.error_message = "获取验证码失败"
|
||||
result.error_message = (
|
||||
otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败"
|
||||
)
|
||||
result.error_code = otp_phase.error_code if otp_phase else ""
|
||||
return result
|
||||
|
||||
self._log("16. 验证验证码...")
|
||||
|
||||
@@ -7,15 +7,15 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from .openai.oauth import OAuthManager
|
||||
from .register import PhaseContext, RegistrationEngine
|
||||
from ..config.constants import (
|
||||
from core.openai.oauth import OAuthManager
|
||||
from core.register import PhaseContext, RegistrationEngine
|
||||
from config.constants import (
|
||||
CODEX_OAUTH_ORIGINATOR,
|
||||
CODEX_OAUTH_REDIRECT_URI,
|
||||
CODEX_OAUTH_SCOPE,
|
||||
)
|
||||
from ..config.settings import get_settings
|
||||
from ..services.base import BaseEmailService
|
||||
from config.settings import get_settings
|
||||
from services.base import BaseEmailService
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -4,28 +4,20 @@
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Optional, Dict, Any, Tuple, Callable
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple, Callable
|
||||
|
||||
from curl_cffi import requests as cffi_requests
|
||||
|
||||
from .http_client import OpenAIHTTPClient
|
||||
from .openai.oauth import OAuthManager, OAuthStart
|
||||
from .http_client import OpenAIHTTPClient, HTTPClientError
|
||||
from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
|
||||
from ..services.base import (
|
||||
EmailProviderBackoffState,
|
||||
OTP_NO_OPENAI_SENDER_ERROR,
|
||||
OTPNoOpenAISenderEmailServiceError,
|
||||
)
|
||||
from ..database import crud
|
||||
from ..database.session import get_db
|
||||
from ..config.constants import (
|
||||
OPENAI_API_ENDPOINTS,
|
||||
OPENAI_PAGE_TYPES,
|
||||
@@ -33,11 +25,16 @@ from ..config.constants import (
|
||||
OTP_CODE_PATTERN,
|
||||
DEFAULT_PASSWORD_LENGTH,
|
||||
PASSWORD_CHARSET,
|
||||
AccountStatus,
|
||||
TaskStatus,
|
||||
)
|
||||
from ..config.settings import get_settings
|
||||
|
||||
from ..database import crud
|
||||
from ..database.session import get_db
|
||||
from ..services import BaseEmailService
|
||||
from ..services.base import (
|
||||
EmailProviderBackoffState,
|
||||
OTP_NO_OPENAI_SENDER_ERROR,
|
||||
OTPNoOpenAISenderEmailServiceError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -639,6 +636,75 @@ class RegistrationEngine:
|
||||
)
|
||||
return code
|
||||
|
||||
def _await_verification_code_with_resends(
|
||||
self,
|
||||
resend_callback: Callable[[], bool],
|
||||
*,
|
||||
timeout_retry_log_template: str = "收件箱未找到验证码,第 {attempt} 次重新发送验证码...",
|
||||
non_openai_retry_log_template: str = "检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...",
|
||||
timeout_retry_status_template: Optional[str] = None,
|
||||
non_openai_retry_status_template: Optional[str] = None,
|
||||
step_index: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[PhaseResult]]:
|
||||
"""等待验证码,并在可重试场景下按独立预算触发重发。"""
|
||||
settings = get_settings()
|
||||
timeout_resend_max = settings.email_code_resend_max_retries
|
||||
non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries
|
||||
timeout_resend_used = 0
|
||||
non_openai_sender_resend_used = 0
|
||||
otp_phase_started_at = time.time()
|
||||
otp_phase: Optional[PhaseResult] = None
|
||||
|
||||
while True:
|
||||
code, otp_phase = self._phase_otp_secondary(
|
||||
PhaseContext(otp_sent_at=self._otp_sent_at),
|
||||
started_at=otp_phase_started_at,
|
||||
)
|
||||
if code:
|
||||
return code, otp_phase
|
||||
|
||||
if otp_phase and not otp_phase.retryable:
|
||||
return None, otp_phase
|
||||
|
||||
retry_error_code = otp_phase.error_code if otp_phase else ""
|
||||
retry_reason = (
|
||||
"non_openai_sender"
|
||||
if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR
|
||||
else "timeout"
|
||||
)
|
||||
|
||||
if retry_reason == "non_openai_sender":
|
||||
if non_openai_sender_resend_used >= non_openai_sender_resend_max:
|
||||
return None, otp_phase
|
||||
non_openai_sender_resend_used += 1
|
||||
resend_attempt = non_openai_sender_resend_used
|
||||
self._log(non_openai_retry_log_template.format(attempt=resend_attempt))
|
||||
status_template = non_openai_retry_status_template
|
||||
else:
|
||||
if timeout_resend_used >= timeout_resend_max:
|
||||
return None, otp_phase
|
||||
timeout_resend_used += 1
|
||||
resend_attempt = timeout_resend_used
|
||||
self._log(timeout_retry_log_template.format(attempt=resend_attempt))
|
||||
status_template = timeout_retry_status_template
|
||||
|
||||
if status_template:
|
||||
emit_kwargs = {}
|
||||
if step_index is not None:
|
||||
emit_kwargs["step_index"] = step_index
|
||||
self._emit_status(
|
||||
"otp_resend",
|
||||
status_template.format(attempt=resend_attempt),
|
||||
**emit_kwargs,
|
||||
)
|
||||
|
||||
if not resend_callback():
|
||||
self._log("重新发送验证码失败,跳过本次重试", "warning")
|
||||
|
||||
otp_phase_started_at = time.time()
|
||||
|
||||
return None, otp_phase
|
||||
|
||||
def _phase_otp_secondary(
|
||||
self,
|
||||
context: PhaseContext,
|
||||
@@ -1330,9 +1396,18 @@ class RegistrationEngine:
|
||||
if not self._submit_login_password_step():
|
||||
return None, None
|
||||
|
||||
code = self._get_verification_code()
|
||||
code, otp_phase = self._await_verification_code_with_resends(
|
||||
self._submit_login_password_step,
|
||||
timeout_retry_log_template="登录流程未找到验证码,第 {attempt} 次重新触发登录验证码...",
|
||||
non_openai_retry_log_template="登录流程检测到非 OpenAI 发件人干扰,第 {attempt} 次重新触发登录验证码...",
|
||||
timeout_retry_status_template="重新触发登录验证码(第 {attempt} 次)",
|
||||
non_openai_retry_status_template="重新触发登录验证码(非 OpenAI 发件人,第 {attempt} 次)",
|
||||
)
|
||||
if not code:
|
||||
self._log("登录流程获取验证码失败", "warning")
|
||||
self._log(
|
||||
otp_phase.error_message if otp_phase and otp_phase.error_message else "登录流程获取验证码失败",
|
||||
"warning",
|
||||
)
|
||||
return None, None
|
||||
|
||||
valid, consent_url = self._validate_verification_code_and_get_continue_url(code)
|
||||
@@ -1559,57 +1634,14 @@ class RegistrationEngine:
|
||||
# 10. 获取验证码(支持重发重试)
|
||||
self._log("10. 等待验证码...")
|
||||
self._emit_status("otp_secondary", "等待验证码邮件", step_index=10)
|
||||
otp_phase_started_at = time.time()
|
||||
settings = get_settings()
|
||||
timeout_resend_max = settings.email_code_resend_max_retries
|
||||
non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries
|
||||
timeout_resend_used = 0
|
||||
non_openai_sender_resend_used = 0
|
||||
code, otp_phase = None, None
|
||||
while True:
|
||||
code, otp_phase = self._phase_otp_secondary(
|
||||
PhaseContext(otp_sent_at=self._otp_sent_at),
|
||||
started_at=otp_phase_started_at,
|
||||
)
|
||||
if code:
|
||||
break
|
||||
|
||||
retry_error_code = otp_phase.error_code if otp_phase else ""
|
||||
retry_reason = (
|
||||
"non_openai_sender"
|
||||
if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR
|
||||
else "timeout"
|
||||
)
|
||||
|
||||
if retry_reason == "non_openai_sender":
|
||||
if non_openai_sender_resend_used >= non_openai_sender_resend_max:
|
||||
break
|
||||
non_openai_sender_resend_used += 1
|
||||
resend_attempt = non_openai_sender_resend_used
|
||||
self._log(
|
||||
f"10. 检测到非 OpenAI 发件人干扰,第 {resend_attempt} 次重新发送验证码..."
|
||||
)
|
||||
self._emit_status(
|
||||
"otp_resend",
|
||||
f"重新发送验证码(非 OpenAI 发件人,第 {resend_attempt} 次)",
|
||||
step_index=10,
|
||||
)
|
||||
else:
|
||||
if timeout_resend_used >= timeout_resend_max:
|
||||
break
|
||||
timeout_resend_used += 1
|
||||
resend_attempt = timeout_resend_used
|
||||
self._log(f"10. 收件箱未找到验证码,第 {resend_attempt} 次重新发送验证码...")
|
||||
self._emit_status(
|
||||
"otp_resend",
|
||||
f"重新发送验证码(第 {resend_attempt} 次)",
|
||||
step_index=10,
|
||||
)
|
||||
|
||||
if not self._send_verification_code():
|
||||
self._log("重新发送验证码失败,跳过本次重试", "warning")
|
||||
|
||||
otp_phase_started_at = time.time()
|
||||
code, otp_phase = self._await_verification_code_with_resends(
|
||||
self._send_verification_code,
|
||||
timeout_retry_log_template="10. 收件箱未找到验证码,第 {attempt} 次重新发送验证码...",
|
||||
non_openai_retry_log_template="10. 检测到非 OpenAI 发件人干扰,第 {attempt} 次重新发送验证码...",
|
||||
timeout_retry_status_template="重新发送验证码(第 {attempt} 次)",
|
||||
non_openai_retry_status_template="重新发送验证码(非 OpenAI 发件人,第 {attempt} 次)",
|
||||
step_index=10,
|
||||
)
|
||||
if not code:
|
||||
result.error_message = (
|
||||
otp_phase.error_message if otp_phase and otp_phase.error_message else "获取验证码失败"
|
||||
|
||||
@@ -532,6 +532,42 @@ def delete_proxy(db: Session, proxy_id: int) -> bool:
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def delete_proxies_by_ids(db: Session, proxy_ids: Iterable[int]) -> Dict[str, Any]:
|
||||
"""按 ID 批量删除代理配置。"""
|
||||
normalized_ids = []
|
||||
seen_ids: Set[int] = set()
|
||||
for proxy_id in proxy_ids:
|
||||
current_id = int(proxy_id)
|
||||
if current_id <= 0 or current_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(current_id)
|
||||
normalized_ids.append(current_id)
|
||||
|
||||
if not normalized_ids:
|
||||
return {
|
||||
"requested_count": 0,
|
||||
"deleted_count": 0,
|
||||
"not_found_ids": [],
|
||||
}
|
||||
|
||||
existing_ids = {
|
||||
proxy_id
|
||||
for (proxy_id,) in db.query(Proxy.id).filter(Proxy.id.in_(normalized_ids)).all()
|
||||
}
|
||||
not_found_ids = [proxy_id for proxy_id in normalized_ids if proxy_id not in existing_ids]
|
||||
|
||||
deleted_count = 0
|
||||
if existing_ids:
|
||||
deleted_count = db.query(Proxy).filter(Proxy.id.in_(existing_ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"requested_count": len(normalized_ids),
|
||||
"deleted_count": deleted_count,
|
||||
"not_found_ids": not_found_ids,
|
||||
}
|
||||
|
||||
def delete_disabled_proxies(db: Session) -> int:
|
||||
"""删除所有已禁用代理"""
|
||||
deleted = db.query(Proxy).filter(Proxy.enabled == False).delete(synchronize_session=False)
|
||||
|
||||
@@ -759,7 +759,7 @@ async def codex_auth_login(request: CodexAuthLoginRequest):
|
||||
log_queue.put(("log", msg))
|
||||
|
||||
def run_login():
|
||||
from ...core.codex_auth import CodexAuthEngine
|
||||
from core.openai.codex_auth import CodexAuthEngine
|
||||
try:
|
||||
engine = CodexAuthEngine(
|
||||
email=email,
|
||||
@@ -876,7 +876,7 @@ async def codex_auth_login_batch(request: CodexAuthBatchRequest):
|
||||
log_queue = queue.Queue()
|
||||
|
||||
def run_batch():
|
||||
from ...core.codex_auth import CodexAuthEngine
|
||||
from core.openai.codex_auth import CodexAuthEngine
|
||||
results = []
|
||||
|
||||
for i, acc_data in enumerate(accounts_data):
|
||||
|
||||
@@ -470,6 +470,11 @@ class ProxyCreateRequest(BaseModel):
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class ProxyBatchDeleteRequest(BaseModel):
|
||||
"""批量删除代理请求"""
|
||||
ids: List[int]
|
||||
|
||||
|
||||
class ProxyUpdateRequest(BaseModel):
|
||||
"""更新代理请求"""
|
||||
name: Optional[str] = None
|
||||
@@ -563,6 +568,35 @@ async def create_proxy_item(request: ProxyCreateRequest):
|
||||
return {"success": True, "proxy": proxy.to_dict()}
|
||||
|
||||
|
||||
@router.post("/proxies/batch-delete")
|
||||
async def batch_delete_proxies(request: ProxyBatchDeleteRequest):
|
||||
"""批量删除代理。"""
|
||||
if not request.ids:
|
||||
raise HTTPException(status_code=400, detail="请至少选择一个代理")
|
||||
|
||||
with get_db() as db:
|
||||
result = crud.delete_proxies_by_ids(db, request.ids)
|
||||
return {
|
||||
"success": True,
|
||||
"requested_count": result["requested_count"],
|
||||
"deleted_count": result["deleted_count"],
|
||||
"not_found_ids": result["not_found_ids"],
|
||||
"message": f"已删除 {result['deleted_count']} 个代理",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/proxies/delete-disabled")
|
||||
async def delete_disabled_proxy_items():
|
||||
"""删除所有已禁用代理。"""
|
||||
with get_db() as db:
|
||||
deleted_count = crud.delete_disabled_proxies(db)
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"已删除 {deleted_count} 个禁用代理",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proxies/{proxy_id}")
|
||||
async def get_proxy_item(proxy_id: int):
|
||||
"""获取单个代理"""
|
||||
|
||||
@@ -29,7 +29,9 @@ const elements = {
|
||||
selectAllServices: document.getElementById('select-all-services'),
|
||||
// 代理列表
|
||||
proxiesTable: document.getElementById('proxies-table'),
|
||||
selectAllProxies: document.getElementById('select-all-proxies'),
|
||||
addProxyBtn: document.getElementById('add-proxy-btn'),
|
||||
batchDeleteProxiesBtn: document.getElementById('batch-delete-proxies-btn'),
|
||||
testAllProxiesBtn: document.getElementById('test-all-proxies-btn'),
|
||||
deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'),
|
||||
batchImportProxyBtn: document.getElementById('batch-import-proxy-btn'),
|
||||
@@ -89,6 +91,8 @@ const elements = {
|
||||
|
||||
// 选中的服务 ID
|
||||
let selectedServiceIds = new Set();
|
||||
let selectedProxyIds = new Set();
|
||||
let disabledProxyCount = 0;
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -252,6 +256,20 @@ function initEventListeners() {
|
||||
elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies);
|
||||
}
|
||||
|
||||
if (elements.selectAllProxies) {
|
||||
elements.selectAllProxies.addEventListener('change', (e) => {
|
||||
toggleSelectAllProxies(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.batchDeleteProxiesBtn) {
|
||||
elements.batchDeleteProxiesBtn.addEventListener('click', handleBatchDeleteProxies);
|
||||
}
|
||||
|
||||
if (elements.deleteDisabledProxiesBtn) {
|
||||
elements.deleteDisabledProxiesBtn.addEventListener('click', handleDeleteDisabledProxies);
|
||||
}
|
||||
|
||||
if (elements.closeProxyModal) {
|
||||
elements.closeProxyModal.addEventListener('click', closeProxyModal);
|
||||
}
|
||||
@@ -845,12 +863,16 @@ function escapeHtml(text) {
|
||||
async function loadProxies() {
|
||||
try {
|
||||
const data = await api.get('/settings/proxies');
|
||||
syncSelectedProxyIds(data.proxies || []);
|
||||
renderProxies(data.proxies);
|
||||
} catch (error) {
|
||||
console.error('加载代理列表失败:', error);
|
||||
selectedProxyIds = new Set();
|
||||
disabledProxyCount = 0;
|
||||
updateProxyBatchActions();
|
||||
elements.proxiesTable.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">❌</div>
|
||||
<div class="empty-state-title">加载失败</div>
|
||||
@@ -864,9 +886,12 @@ async function loadProxies() {
|
||||
// 渲染代理列表
|
||||
function renderProxies(proxies) {
|
||||
if (!proxies || proxies.length === 0) {
|
||||
selectedProxyIds = new Set();
|
||||
disabledProxyCount = 0;
|
||||
updateProxyBatchActions();
|
||||
elements.proxiesTable.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-title">暂无代理</div>
|
||||
@@ -878,8 +903,20 @@ function renderProxies(proxies) {
|
||||
return;
|
||||
}
|
||||
|
||||
disabledProxyCount = proxies.filter((proxy) => proxy && proxy.enabled === false).length;
|
||||
|
||||
elements.proxiesTable.innerHTML = proxies.map(proxy => `
|
||||
<tr data-proxy-id="${proxy.id}">
|
||||
<td style="text-align:center;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="proxy-checkbox"
|
||||
data-id="${proxy.id}"
|
||||
${selectedProxyIds.has(proxy.id) ? 'checked' : ''}
|
||||
onchange="updateSelectedProxies()"
|
||||
aria-label="选择代理 ${proxy.id}"
|
||||
>
|
||||
</td>
|
||||
<td>${proxy.id}</td>
|
||||
<td>${escapeHtml(proxy.name)}</td>
|
||||
<td><span class="badge">${proxy.type.toUpperCase()}</span></td>
|
||||
@@ -911,6 +948,56 @@ function renderProxies(proxies) {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
updateProxyBatchActions();
|
||||
}
|
||||
|
||||
function syncSelectedProxyIds(proxies) {
|
||||
const validIds = new Set((proxies || []).map(proxy => proxy.id));
|
||||
selectedProxyIds = new Set([...selectedProxyIds].filter(id => validIds.has(id)));
|
||||
}
|
||||
|
||||
function toggleSelectAllProxies(checked) {
|
||||
document.querySelectorAll('.proxy-checkbox').forEach((checkbox) => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
updateSelectedProxies();
|
||||
}
|
||||
|
||||
function updateSelectedProxies() {
|
||||
selectedProxyIds = new Set(
|
||||
[...document.querySelectorAll('.proxy-checkbox:checked')]
|
||||
.map((checkbox) => parseInt(checkbox.dataset.id, 10))
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
);
|
||||
updateProxyBatchActions();
|
||||
}
|
||||
|
||||
function updateProxyBatchActions() {
|
||||
const proxyCheckboxes = [...document.querySelectorAll('.proxy-checkbox')];
|
||||
const checkedCount = selectedProxyIds.size;
|
||||
|
||||
if (elements.selectAllProxies) {
|
||||
const totalCount = proxyCheckboxes.length;
|
||||
const allChecked = totalCount > 0 && checkedCount === totalCount;
|
||||
elements.selectAllProxies.checked = allChecked;
|
||||
elements.selectAllProxies.indeterminate = checkedCount > 0 && checkedCount < totalCount;
|
||||
elements.selectAllProxies.disabled = totalCount === 0;
|
||||
}
|
||||
|
||||
if (elements.batchDeleteProxiesBtn) {
|
||||
elements.batchDeleteProxiesBtn.disabled = checkedCount === 0;
|
||||
elements.batchDeleteProxiesBtn.textContent = checkedCount > 0
|
||||
? `🗑️ 批量删除 (${checkedCount})`
|
||||
: '🗑️ 批量删除';
|
||||
}
|
||||
|
||||
if (elements.deleteDisabledProxiesBtn) {
|
||||
elements.deleteDisabledProxiesBtn.disabled = disabledProxyCount === 0;
|
||||
elements.deleteDisabledProxiesBtn.textContent = disabledProxyCount > 0
|
||||
? `🧹 删除禁用项 (${disabledProxyCount})`
|
||||
: '🧹 删除禁用项';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSettingsMoreMenu(btn) {
|
||||
@@ -1071,6 +1158,49 @@ async function deleteProxyItem(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDeleteProxies() {
|
||||
const ids = [...selectedProxyIds];
|
||||
if (ids.length === 0) {
|
||||
toast.error('请先选择要删除的代理');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm(`确定要批量删除选中的 ${ids.length} 个代理吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const result = await api.post('/settings/proxies/batch-delete', { ids });
|
||||
const missingCount = Array.isArray(result.not_found_ids) ? result.not_found_ids.length : 0;
|
||||
const summary = missingCount > 0
|
||||
? `${result.message},其中 ${missingCount} 个代理不存在或已被删除`
|
||||
: result.message;
|
||||
toast.success(summary || `已删除 ${ids.length} 个代理`);
|
||||
selectedProxyIds = new Set();
|
||||
await loadProxies();
|
||||
} catch (error) {
|
||||
toast.error('批量删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDisabledProxies() {
|
||||
if (disabledProxyCount === 0) {
|
||||
toast.error('当前没有可删除的禁用代理');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm(`确定要删除全部 ${disabledProxyCount} 个禁用代理吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const result = await api.post('/settings/proxies/delete-disabled');
|
||||
selectedProxyIds = new Set();
|
||||
toast.success(result.message || `已删除 ${result.deleted_count || 0} 个禁用代理`);
|
||||
await loadProxies();
|
||||
} catch (error) {
|
||||
toast.error('删除禁用代理失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试所有代理
|
||||
async function handleTestAllProxies() {
|
||||
elements.testAllProxiesBtn.disabled = true;
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<h3>代理列表</h3>
|
||||
<div style="display: flex; gap: var(--spacing-sm);">
|
||||
<button class="btn btn-secondary btn-sm" id="test-all-proxies-btn">🔌 测试全部</button>
|
||||
<button class="btn btn-danger btn-sm" id="batch-delete-proxies-btn" disabled>🗑️ 批量删除</button>
|
||||
<button class="btn btn-danger btn-sm" id="delete-disabled-proxies-btn" disabled>🧹 删除禁用项</button>
|
||||
<button class="btn btn-secondary btn-sm" id="batch-import-proxy-btn">📋 批量导入</button>
|
||||
<button class="btn btn-primary btn-sm" id="add-proxy-btn">➕ 添加代理</button>
|
||||
@@ -104,6 +105,9 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 42px; text-align: center;">
|
||||
<input type="checkbox" id="select-all-proxies" title="全选代理">
|
||||
</th>
|
||||
<th style="width: 50px;">ID</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
@@ -116,7 +120,7 @@
|
||||
</thead>
|
||||
<tbody id="proxies-table">
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🌐</div>
|
||||
<div class="empty-state-title">暂无代理</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.core.codex_auth as codex_auth_module
|
||||
from src.core.codex_auth import CodexAuthEngine
|
||||
import core.openai.codex_auth as codex_auth_module
|
||||
from core.openai.codex_auth import CodexAuthEngine
|
||||
from src.core.register import PhaseResult, RegistrationEngine
|
||||
from src.services import EmailServiceType
|
||||
|
||||
|
||||
153
tests/test_login_engine_otp_retry.py
Normal file
153
tests/test_login_engine_otp_retry.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import src.core.register as register_module
|
||||
from src.core.login import LoginEngine
|
||||
from src.core.register import PhaseResult
|
||||
from src.services import EmailServiceType
|
||||
|
||||
|
||||
class DummySettings:
|
||||
openai_client_id = "client-id"
|
||||
openai_auth_url = "https://auth.example.test"
|
||||
openai_token_url = "https://token.example.test"
|
||||
openai_redirect_uri = "https://callback.example.test"
|
||||
openai_scope = "openid profile email"
|
||||
email_code_timeout = 120
|
||||
email_code_poll_interval = 3
|
||||
email_code_resend_max_retries = 2
|
||||
email_code_non_openai_sender_resend_max_retries = 1
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
service_type = EmailServiceType.TEMPMAIL
|
||||
|
||||
def get_verification_code(self, **kwargs):
|
||||
raise AssertionError("unexpected direct email_service.get_verification_code call")
|
||||
|
||||
|
||||
def _success_phase() -> PhaseResult:
|
||||
return PhaseResult(
|
||||
phase=register_module.PHASE_OTP_SECONDARY,
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
def _failed_phase(error_code: str, error_message: str) -> PhaseResult:
|
||||
return PhaseResult(
|
||||
phase=register_module.PHASE_OTP_SECONDARY,
|
||||
success=False,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
retryable=True,
|
||||
next_action="resend_otp",
|
||||
)
|
||||
|
||||
|
||||
def _build_login_engine(monkeypatch) -> LoginEngine:
|
||||
monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings())
|
||||
engine = LoginEngine(email_service=FakeEmailService())
|
||||
engine.session = type("FakeSession", (), {"cookies": type("FakeCookies", (), {"get": staticmethod(lambda _name: None)})()})()
|
||||
return engine
|
||||
|
||||
|
||||
def _stub_login_success_path(monkeypatch, engine: LoginEngine):
|
||||
monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
|
||||
|
||||
def fake_create_email():
|
||||
engine.email = "tester@example.com"
|
||||
engine.email_info = {"service_id": "svc-1"}
|
||||
return True
|
||||
|
||||
def fake_start_oauth():
|
||||
engine.oauth_start = type("OAuthStart", (), {"auth_url": "https://auth.example.test/authorize"})()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(engine, "_create_email", fake_create_email)
|
||||
monkeypatch.setattr(engine, "_init_session", lambda: True)
|
||||
monkeypatch.setattr(engine, "_start_oauth", fake_start_oauth)
|
||||
monkeypatch.setattr(engine, "_get_device_id", lambda: "did-1")
|
||||
monkeypatch.setattr(engine, "_check_sentinel", lambda _did: None)
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_submit_signup_form",
|
||||
lambda _did, _sen_token: type("SignupResult", (), {"success": True, "error_message": ""})(),
|
||||
)
|
||||
monkeypatch.setattr(engine, "_register_password", lambda: (True, "pass-123"))
|
||||
monkeypatch.setattr(engine, "_validate_verification_code", lambda _code: True)
|
||||
monkeypatch.setattr(engine, "_create_user_account", lambda: True)
|
||||
monkeypatch.setattr(engine, "_follow_login_redirects", lambda _url: True)
|
||||
monkeypatch.setattr(engine, "_submit_login_form", lambda _did, _sen_token: True)
|
||||
monkeypatch.setattr(engine, "_get_workspace_id", lambda: "ws-1")
|
||||
monkeypatch.setattr(engine, "_select_workspace", lambda _workspace_id: "https://auth.example.test/continue")
|
||||
monkeypatch.setattr(engine, "_follow_redirects", lambda _url: "https://callback.example.test?code=abc&state=xyz")
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_handle_oauth_callback",
|
||||
lambda _url: {
|
||||
"account_id": "acct-1",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"id_token": "id-token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(engine, "close", lambda: None)
|
||||
|
||||
|
||||
def test_login_engine_run_uses_retryable_waiter_for_both_otp_steps(monkeypatch):
|
||||
engine = _build_login_engine(monkeypatch)
|
||||
_stub_login_success_path(monkeypatch, engine)
|
||||
|
||||
send_calls = []
|
||||
wait_callbacks = []
|
||||
|
||||
def send_signup_code():
|
||||
send_calls.append("signup")
|
||||
return True
|
||||
|
||||
def send_passwordless_code():
|
||||
send_calls.append("passwordless")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(engine, "_send_verification_code", send_signup_code)
|
||||
monkeypatch.setattr(engine, "_send_verification_code_passwordless", send_passwordless_code)
|
||||
|
||||
wait_results = iter([
|
||||
("111111", _success_phase()),
|
||||
("222222", _success_phase()),
|
||||
])
|
||||
|
||||
def fake_wait_for_code(resend_callback, **_kwargs):
|
||||
wait_callbacks.append(resend_callback.__name__)
|
||||
return next(wait_results)
|
||||
|
||||
monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_code)
|
||||
|
||||
result = engine.run()
|
||||
|
||||
assert result.success is True
|
||||
assert send_calls == ["signup", "passwordless"]
|
||||
assert wait_callbacks == ["send_signup_code", "send_passwordless_code"]
|
||||
assert result.workspace_id == "ws-1"
|
||||
assert result.account_id == "acct-1"
|
||||
|
||||
|
||||
def test_login_engine_run_preserves_non_openai_sender_error(monkeypatch):
|
||||
engine = _build_login_engine(monkeypatch)
|
||||
_stub_login_success_path(monkeypatch, engine)
|
||||
|
||||
monkeypatch.setattr(engine, "_send_verification_code", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_await_verification_code_with_resends",
|
||||
lambda _resend_callback, **_kwargs: (
|
||||
None,
|
||||
_failed_phase("OTP_NO_OPENAI_SENDER", "当前邮件批次未发现 OpenAI 发件人"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(engine, "close", lambda: None)
|
||||
|
||||
result = engine.run()
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_code == "OTP_NO_OPENAI_SENDER"
|
||||
assert result.error_message == "当前邮件批次未发现 OpenAI 发件人"
|
||||
|
||||
|
||||
152
tests/test_proxy_batch_delete.py
Normal file
152
tests/test_proxy_batch_delete.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.database import crud
|
||||
from src.database.session import DatabaseSessionManager
|
||||
from src.web.routes import settings as settings_routes
|
||||
|
||||
|
||||
def _build_fake_get_db(manager):
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
with manager.session_scope() as session:
|
||||
yield session
|
||||
|
||||
return fake_get_db
|
||||
|
||||
|
||||
def _create_proxy_ids(manager, total: int) -> list[int]:
|
||||
created_ids = []
|
||||
with manager.session_scope() as session:
|
||||
for index in range(total):
|
||||
proxy = crud.create_proxy(
|
||||
session,
|
||||
name=f"proxy-{index}",
|
||||
type="http",
|
||||
host=f"127.0.0.{index + 1}",
|
||||
port=8000 + index,
|
||||
)
|
||||
created_ids.append(proxy.id)
|
||||
return created_ids
|
||||
|
||||
|
||||
def test_batch_delete_proxies_removes_selected_ids(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
proxy_ids = _create_proxy_ids(manager, 3)
|
||||
|
||||
result = asyncio.run(
|
||||
settings_routes.batch_delete_proxies(
|
||||
settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], proxy_ids[2]])
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["requested_count"] == 2
|
||||
assert result["deleted_count"] == 2
|
||||
assert result["not_found_ids"] == []
|
||||
|
||||
with manager.session_scope() as session:
|
||||
remaining_ids = [proxy.id for proxy in crud.get_proxies(session)]
|
||||
|
||||
assert remaining_ids == [proxy_ids[1]]
|
||||
|
||||
|
||||
def test_batch_delete_proxies_reports_missing_ids(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-batch-delete-missing.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
proxy_ids = _create_proxy_ids(manager, 1)
|
||||
|
||||
result = asyncio.run(
|
||||
settings_routes.batch_delete_proxies(
|
||||
settings_routes.ProxyBatchDeleteRequest(ids=[proxy_ids[0], 99999, proxy_ids[0]])
|
||||
)
|
||||
)
|
||||
|
||||
assert result["requested_count"] == 2
|
||||
assert result["deleted_count"] == 1
|
||||
assert result["not_found_ids"] == [99999]
|
||||
|
||||
|
||||
def test_batch_delete_proxies_requires_non_empty_selection():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
settings_routes.batch_delete_proxies(
|
||||
settings_routes.ProxyBatchDeleteRequest(ids=[])
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "请至少选择一个代理"
|
||||
|
||||
|
||||
def test_delete_disabled_proxy_items_only_removes_disabled_records(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
with manager.session_scope() as session:
|
||||
enabled_proxy = crud.create_proxy(
|
||||
session,
|
||||
name="enabled-proxy",
|
||||
type="http",
|
||||
host="127.0.0.10",
|
||||
port=8010,
|
||||
enabled=True,
|
||||
)
|
||||
crud.create_proxy(
|
||||
session,
|
||||
name="disabled-proxy-1",
|
||||
type="http",
|
||||
host="127.0.0.11",
|
||||
port=8011,
|
||||
enabled=False,
|
||||
)
|
||||
crud.create_proxy(
|
||||
session,
|
||||
name="disabled-proxy-2",
|
||||
type="http",
|
||||
host="127.0.0.12",
|
||||
port=8012,
|
||||
enabled=False,
|
||||
)
|
||||
enabled_proxy_id = enabled_proxy.id
|
||||
|
||||
result = asyncio.run(settings_routes.delete_disabled_proxy_items())
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 2
|
||||
|
||||
with manager.session_scope() as session:
|
||||
remaining = [(proxy.id, proxy.enabled) for proxy in crud.get_proxies(session)]
|
||||
|
||||
assert remaining == [(enabled_proxy_id, True)]
|
||||
|
||||
|
||||
def test_delete_disabled_proxy_items_is_noop_when_none_exist(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/proxy-delete-disabled-empty.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
monkeypatch.setattr(settings_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
_create_proxy_ids(manager, 2)
|
||||
|
||||
result = asyncio.run(settings_routes.delete_disabled_proxy_items())
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 0
|
||||
assert result["message"] == "已删除 0 个禁用代理"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ def test_run_keeps_timeout_budget_after_non_openai_sender_resend(monkeypatch):
|
||||
assert len(send_calls) == 4
|
||||
|
||||
|
||||
def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch):
|
||||
def test_advance_login_authorization_sets_otp_anchor_before_retryable_wait(monkeypatch):
|
||||
email_service = FakeEmailService(code=None)
|
||||
engine = _build_engine(monkeypatch, email_service)
|
||||
engine.oauth_start = object()
|
||||
@@ -264,11 +264,12 @@ def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monk
|
||||
|
||||
monkeypatch.setattr(engine, "_submit_login_password_step", fake_submit_login_password_step)
|
||||
|
||||
def fake_get_verification_code():
|
||||
def fake_wait_for_verification_code(resend_callback, **_kwargs):
|
||||
seen_anchors.append(engine._otp_sent_at)
|
||||
return None
|
||||
assert resend_callback is engine._submit_login_password_step
|
||||
return None, _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender")
|
||||
|
||||
monkeypatch.setattr(engine, "_get_verification_code", fake_get_verification_code)
|
||||
monkeypatch.setattr(engine, "_await_verification_code_with_resends", fake_wait_for_verification_code)
|
||||
|
||||
workspace_id, callback_url = engine._advance_login_authorization()
|
||||
|
||||
|
||||
112
tests/test_settings_email_code.py
Normal file
112
tests/test_settings_email_code.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.web.routes import settings as settings_routes
|
||||
|
||||
|
||||
class DummySecret:
|
||||
def __init__(self, value: str = ""):
|
||||
self._value = value
|
||||
|
||||
def get_secret_value(self) -> str:
|
||||
return self._value
|
||||
|
||||
|
||||
def _build_settings(**overrides):
|
||||
data = {
|
||||
"proxy_enabled": False,
|
||||
"proxy_type": "http",
|
||||
"proxy_host": "127.0.0.1",
|
||||
"proxy_port": 7890,
|
||||
"proxy_username": "",
|
||||
"proxy_password": DummySecret(""),
|
||||
"proxy_dynamic_enabled": False,
|
||||
"proxy_dynamic_api_url": "",
|
||||
"proxy_dynamic_api_key_header": "Authorization",
|
||||
"proxy_dynamic_result_field": "data.proxy",
|
||||
"proxy_dynamic_api_key": DummySecret(""),
|
||||
"registration_max_retries": 3,
|
||||
"registration_timeout": 120,
|
||||
"registration_default_password_length": 12,
|
||||
"registration_sleep_min": 5,
|
||||
"registration_sleep_max": 30,
|
||||
"webui_host": "127.0.0.1",
|
||||
"webui_port": 15555,
|
||||
"debug": False,
|
||||
"webui_access_password": DummySecret(""),
|
||||
"tempmail_base_url": "https://mail.example.test",
|
||||
"tempmail_timeout": 30,
|
||||
"tempmail_max_retries": 3,
|
||||
"email_code_timeout": 120,
|
||||
"email_code_poll_interval": 3,
|
||||
"email_code_resend_max_retries": 2,
|
||||
"email_code_non_openai_sender_resend_max_retries": 1,
|
||||
}
|
||||
data.update(overrides)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
def test_get_all_settings_includes_non_openai_sender_resend_budget(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
settings_routes,
|
||||
"get_settings",
|
||||
lambda: _build_settings(email_code_non_openai_sender_resend_max_retries=4),
|
||||
)
|
||||
|
||||
result = asyncio.run(settings_routes.get_all_settings())
|
||||
|
||||
assert result["email_code"]["timeout"] == 120
|
||||
assert result["email_code"]["resend_max_retries"] == 2
|
||||
assert result["email_code"]["non_openai_sender_resend_max_retries"] == 4
|
||||
|
||||
|
||||
def test_update_email_code_settings_persists_non_openai_sender_budget(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings_routes,
|
||||
"update_settings",
|
||||
lambda **kwargs: captured.update(kwargs),
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
settings_routes.update_email_code_settings(
|
||||
settings_routes.EmailCodeSettings(
|
||||
timeout=180,
|
||||
poll_interval=5,
|
||||
resend_max_retries=3,
|
||||
non_openai_sender_resend_max_retries=2,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert captured == {
|
||||
"email_code_timeout": 180,
|
||||
"email_code_poll_interval": 5,
|
||||
"email_code_resend_max_retries": 3,
|
||||
"email_code_non_openai_sender_resend_max_retries": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_update_email_code_settings_rejects_invalid_non_openai_sender_budget():
|
||||
try:
|
||||
asyncio.run(
|
||||
settings_routes.update_email_code_settings(
|
||||
settings_routes.EmailCodeSettings(
|
||||
timeout=120,
|
||||
poll_interval=3,
|
||||
resend_max_retries=2,
|
||||
non_openai_sender_resend_max_retries=11,
|
||||
)
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail == "非 OpenAI 发件人重发次数必须在 0-10 之间"
|
||||
return
|
||||
|
||||
raise AssertionError("expected HTTPException for invalid non_openai_sender_resend_max_retries")
|
||||
|
||||
239
tests/test_settings_proxy_batch_delete.cjs
Normal file
239
tests/test_settings_proxy_batch_delete.cjs
Normal file
@@ -0,0 +1,239 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SETTINGS_JS_PATH = path.join(__dirname, '..', 'static', 'js', 'settings.js');
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(...items) {
|
||||
items.forEach((item) => values.add(item));
|
||||
},
|
||||
remove(...items) {
|
||||
items.forEach((item) => values.delete(item));
|
||||
},
|
||||
contains(item) {
|
||||
return values.has(item);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub(overrides = {}) {
|
||||
return {
|
||||
value: '',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: createClassList(),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
reset() {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSandbox() {
|
||||
const elements = new Map();
|
||||
const proxyCheckboxes = [];
|
||||
const toastCalls = [];
|
||||
const apiCalls = [];
|
||||
|
||||
function getElement(id) {
|
||||
if (!elements.has(id)) {
|
||||
elements.set(id, createElementStub({ id }));
|
||||
}
|
||||
return elements.get(id);
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
__proxyCheckboxes: proxyCheckboxes,
|
||||
__toastCalls: toastCalls,
|
||||
__apiCalls: apiCalls,
|
||||
document: {
|
||||
getElementById(id) {
|
||||
return getElement(id);
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.proxy-checkbox') {
|
||||
return proxyCheckboxes;
|
||||
}
|
||||
if (selector === '.proxy-checkbox:checked') {
|
||||
return proxyCheckboxes.filter((checkbox) => checkbox.checked);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
},
|
||||
window: null,
|
||||
api: {
|
||||
get: async () => ({ proxies: [] }),
|
||||
post: async (url, payload) => {
|
||||
apiCalls.push({ url, payload });
|
||||
if (url === '/settings/proxies/delete-disabled') {
|
||||
return { success: true, deleted_count: 2, message: '已删除 2 个禁用代理' };
|
||||
}
|
||||
return { success: true, message: `已删除 ${payload.ids.length} 个代理`, not_found_ids: [] };
|
||||
},
|
||||
patch: async () => ({ success: true }),
|
||||
delete: async () => ({ success: true }),
|
||||
},
|
||||
toast: {
|
||||
success(message) {
|
||||
toastCalls.push({ type: 'success', message });
|
||||
},
|
||||
error(message) {
|
||||
toastCalls.push({ type: 'error', message });
|
||||
},
|
||||
info(message) {
|
||||
toastCalls.push({ type: 'info', message });
|
||||
},
|
||||
},
|
||||
confirm: async () => true,
|
||||
format: {
|
||||
date(value) {
|
||||
return value || '-';
|
||||
},
|
||||
number(value) {
|
||||
return String(value ?? 0);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fs.readFileSync(SETTINGS_JS_PATH, 'utf8'), sandbox, { filename: 'settings.js' });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('updateSelectedProxies updates batch delete button state', () => {
|
||||
const sandbox = createSandbox();
|
||||
sandbox.__proxyCheckboxes.push(
|
||||
createElementStub({ checked: true, dataset: { id: '1' } }),
|
||||
createElementStub({ checked: false, dataset: { id: '2' } }),
|
||||
createElementStub({ checked: true, dataset: { id: '3' } }),
|
||||
);
|
||||
|
||||
vm.runInContext('updateSelectedProxies()', sandbox);
|
||||
|
||||
const batchDeleteBtn = sandbox.document.getElementById('batch-delete-proxies-btn');
|
||||
const selectAllProxies = sandbox.document.getElementById('select-all-proxies');
|
||||
|
||||
assert.equal(batchDeleteBtn.disabled, false);
|
||||
assert.equal(batchDeleteBtn.textContent, '🗑️ 批量删除 (2)');
|
||||
assert.equal(selectAllProxies.checked, false);
|
||||
assert.equal(selectAllProxies.indeterminate, true);
|
||||
});
|
||||
|
||||
test('handleBatchDeleteProxies posts selected proxy ids and reloads list', async () => {
|
||||
const sandbox = createSandbox();
|
||||
sandbox.__proxyCheckboxes.push(
|
||||
createElementStub({ checked: true, dataset: { id: '11' } }),
|
||||
createElementStub({ checked: true, dataset: { id: '12' } }),
|
||||
);
|
||||
|
||||
vm.runInContext('updateSelectedProxies()', sandbox);
|
||||
vm.runInContext('loadProxies = async () => { globalThis.__reloaded = true; }', sandbox);
|
||||
|
||||
await vm.runInContext('handleBatchDeleteProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [
|
||||
{
|
||||
url: '/settings/proxies/batch-delete',
|
||||
payload: { ids: [11, 12] },
|
||||
},
|
||||
]);
|
||||
assert.equal(sandbox.__reloaded, true);
|
||||
assert.equal(sandbox.__toastCalls.at(-1).type, 'success');
|
||||
});
|
||||
|
||||
test('handleBatchDeleteProxies rejects empty selection without api call', async () => {
|
||||
const sandbox = createSandbox();
|
||||
|
||||
await vm.runInContext('handleBatchDeleteProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
|
||||
assert.equal(sandbox.__toastCalls.at(-1).type, 'error');
|
||||
assert.equal(sandbox.__toastCalls.at(-1).message, '请先选择要删除的代理');
|
||||
});
|
||||
|
||||
test('handleBatchDeleteProxies stops when user cancels confirmation', async () => {
|
||||
const sandbox = createSandbox();
|
||||
sandbox.confirm = async () => false;
|
||||
sandbox.__proxyCheckboxes.push(
|
||||
createElementStub({ checked: true, dataset: { id: '21' } }),
|
||||
);
|
||||
|
||||
vm.runInContext('updateSelectedProxies()', sandbox);
|
||||
await vm.runInContext('handleBatchDeleteProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
|
||||
assert.equal(sandbox.__toastCalls.length, 0);
|
||||
});
|
||||
|
||||
test('updateProxyBatchActions enables delete-disabled button based on disabled count', () => {
|
||||
const sandbox = createSandbox();
|
||||
|
||||
vm.runInContext('disabledProxyCount = 3; updateProxyBatchActions()', sandbox);
|
||||
|
||||
const deleteDisabledBtn = sandbox.document.getElementById('delete-disabled-proxies-btn');
|
||||
assert.equal(deleteDisabledBtn.disabled, false);
|
||||
assert.equal(deleteDisabledBtn.textContent, '🧹 删除禁用项 (3)');
|
||||
});
|
||||
|
||||
test('handleDeleteDisabledProxies posts dedicated route and reloads list', async () => {
|
||||
const sandbox = createSandbox();
|
||||
vm.runInContext('disabledProxyCount = 2', sandbox);
|
||||
vm.runInContext('loadProxies = async () => { globalThis.__reloadedDisabled = true; }', sandbox);
|
||||
|
||||
await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), [
|
||||
{
|
||||
url: '/settings/proxies/delete-disabled',
|
||||
},
|
||||
]);
|
||||
assert.equal(sandbox.__reloadedDisabled, true);
|
||||
assert.equal(sandbox.__toastCalls.at(-1).message, '已删除 2 个禁用代理');
|
||||
});
|
||||
|
||||
test('handleDeleteDisabledProxies rejects when there are no disabled proxies', async () => {
|
||||
const sandbox = createSandbox();
|
||||
|
||||
await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
|
||||
assert.equal(sandbox.__toastCalls.at(-1).message, '当前没有可删除的禁用代理');
|
||||
});
|
||||
|
||||
test('handleDeleteDisabledProxies stops when user cancels confirmation', async () => {
|
||||
const sandbox = createSandbox();
|
||||
sandbox.confirm = async () => false;
|
||||
vm.runInContext('disabledProxyCount = 2', sandbox);
|
||||
|
||||
await vm.runInContext('handleDeleteDisabledProxies()', sandbox);
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.__apiCalls)), []);
|
||||
assert.equal(sandbox.__toastCalls.length, 0);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user