From 46f390a984b876cd11fc86bc8ca8db82450da6ba Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Mon, 16 Mar 2026 18:54:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=E5=A2=9E=E5=8A=A0=E5=85=A8?= =?UTF-8?q?=E9=80=89=E6=89=80=E6=9C=89=E9=A1=B5=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9CAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端增加全选所有页横幅和状态管理 - 后端批量API支持select_all参数和筛选条件传递 - 统一批量操作逻辑,支持全选和筛选条件组合 --- src/web/routes/accounts.py | 108 +++++++++++++++++++---- src/web/routes/payment.py | 27 +++++- static/js/accounts.js | 174 +++++++++++++++++++++++++++---------- 3 files changed, 245 insertions(+), 64 deletions(-) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 0055575..ecab653 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -60,7 +60,11 @@ class AccountUpdateRequest(BaseModel): class BatchDeleteRequest(BaseModel): """批量删除请求""" - ids: List[int] + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None class BatchUpdateRequest(BaseModel): @@ -71,6 +75,30 @@ class BatchUpdateRequest(BaseModel): # ============== Helper Functions ============== +def resolve_account_ids( + db, + ids: List[int], + select_all: bool = False, + status_filter: Optional[str] = None, + email_service_filter: Optional[str] = None, + search_filter: Optional[str] = None, +) -> List[int]: + """当 select_all=True 时查询全部符合条件的 ID,否则直接返回传入的 ids""" + if not select_all: + return ids + query = db.query(Account.id) + if status_filter: + query = query.filter(Account.status == status_filter) + if email_service_filter: + query = query.filter(Account.email_service == email_service_filter) + if search_filter: + pattern = f"%{search_filter}%" + query = query.filter( + (Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern)) + ) + return [row[0] for row in query.all()] + + def account_to_response(account: Account) -> AccountResponse: """转换 Account 模型为响应模型""" return AccountResponse( @@ -162,9 +190,9 @@ async def get_account_tokens(account_id: int): return { "id": account.id, "email": account.email, - "access_token": account.access_token[:50] + "..." if account.access_token else None, - "refresh_token": account.refresh_token[:50] + "..." if account.refresh_token else None, - "id_token": account.id_token[:50] + "..." if account.id_token else None, + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, "has_tokens": bool(account.access_token and account.refresh_token), } @@ -208,10 +236,14 @@ async def delete_account(account_id: int): async def batch_delete_accounts(request: BatchDeleteRequest): """批量删除账号""" with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) deleted_count = 0 errors = [] - for account_id in request.ids: + for account_id in ids: try: account = crud.get_account_by_id(db, account_id) if account: @@ -255,14 +287,22 @@ async def batch_update_accounts(request: BatchUpdateRequest): class BatchExportRequest(BaseModel): """批量导出请求""" - ids: List[int] + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/export/json") async def export_accounts_json(request: BatchExportRequest): """导出账号为 JSON 格式""" with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() export_data = [] for acc in accounts: @@ -304,7 +344,11 @@ async def export_accounts_csv(request: BatchExportRequest): import io with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() # 创建 CSV 内容 output = io.StringIO() @@ -357,7 +401,11 @@ async def export_accounts_cpa(request: BatchExportRequest): from ...core.cpa_upload import generate_token_json with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -427,8 +475,12 @@ class TokenRefreshRequest(BaseModel): class BatchRefreshRequest(BaseModel): """批量刷新请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None class TokenValidateRequest(BaseModel): @@ -438,8 +490,12 @@ class TokenValidateRequest(BaseModel): class BatchValidateRequest(BaseModel): """批量验证请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/{account_id}/refresh") @@ -478,7 +534,13 @@ async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: B "errors": [] } - for account_id in request.ids: + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: try: result = do_refresh(account_id, proxy) if result.success: @@ -523,7 +585,13 @@ async def batch_validate_tokens(request: BatchValidateRequest): "details": [] } - for account_id in request.ids: + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: try: is_valid, error = do_validate(account_id, proxy) results["details"].append({ @@ -555,8 +623,12 @@ class CPAUploadRequest(BaseModel): class BatchCPAUploadRequest(BaseModel): """批量 CPA 上传请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/{account_id}/upload-cpa") @@ -609,6 +681,12 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): # 使用传入的代理或全局代理配置 proxy = request.proxy if request.proxy else get_settings().proxy_url - results = batch_upload_to_cpa(request.ids, proxy) + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + results = batch_upload_to_cpa(ids, proxy) return results diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index f5a27c3..2518eba 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from ...database.session import get_db from ...database.models import Account from ...config.settings import get_settings +from .accounts import resolve_account_ids from ...core.payment import ( generate_plus_link, generate_team_link, @@ -48,8 +49,12 @@ class MarkSubscriptionRequest(BaseModel): class BatchCheckSubscriptionRequest(BaseModel): - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None class UploadTMRequest(BaseModel): @@ -57,7 +62,11 @@ class UploadTMRequest(BaseModel): class BatchUploadTMRequest(BaseModel): - ids: List[int] + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None # ============== 支付链接生成 ============== @@ -143,7 +152,11 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): results = {"success_count": 0, "failed_count": 0, "details": []} with get_db() as db: - for account_id in request.ids: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + for account_id in ids: account = db.query(Account).filter(Account.id == account_id).first() if not account: results["failed_count"] += 1 @@ -201,5 +214,11 @@ def batch_upload_tm(request: BatchUploadTMRequest): api_url = settings.tm_api_url api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" - results = batch_upload_to_team_manager(request.ids, api_url, api_key) + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + results = batch_upload_to_team_manager(ids, api_url, api_key) return results diff --git a/static/js/accounts.js b/static/js/accounts.js index 122ae21..3bf89bc 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -9,6 +9,8 @@ let pageSize = 20; let totalAccounts = 0; let selectedAccounts = new Set(); let isLoading = false; +let selectAllPages = false; // 是否选中了全部页 +let currentFilters = { status: '', email_service: '', search: '' }; // 当前筛选条件 // DOM 元素 const elements = { @@ -44,6 +46,7 @@ document.addEventListener('DOMContentLoaded', () => { loadAccounts(); initEventListeners(); updateBatchButtons(); // 初始化按钮状态 + renderSelectAllBanner(); }); // 事件监听 @@ -51,17 +54,20 @@ function initEventListeners() { // 筛选 elements.filterStatus.addEventListener('change', () => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }); elements.filterService.addEventListener('change', () => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }); // 搜索(防抖) elements.searchInput.addEventListener('input', debounce(() => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }, 300)); @@ -70,6 +76,7 @@ function initEventListeners() { if (e.key === 'Escape') { elements.searchInput.blur(); elements.searchInput.value = ''; + resetSelectAllPages(); loadAccounts(); } }); @@ -99,7 +106,7 @@ function initEventListeners() { // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); - // 全选 + // 全选(当前页) elements.selectAll.addEventListener('change', (e) => { const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); checkboxes.forEach(cb => { @@ -111,7 +118,11 @@ function initEventListeners() { selectedAccounts.delete(id); } }); + if (!e.target.checked) { + selectAllPages = false; + } updateBatchButtons(); + renderSelectAllBanner(); }); // 分页 @@ -204,21 +215,26 @@ async function loadAccounts() { `; + // 记录当前筛选条件 + currentFilters.status = elements.filterStatus.value; + currentFilters.email_service = elements.filterService.value; + currentFilters.search = elements.searchInput.value.trim(); + const params = new URLSearchParams({ page: currentPage, page_size: pageSize, }); - if (elements.filterStatus.value) { - params.append('status', elements.filterStatus.value); + if (currentFilters.status) { + params.append('status', currentFilters.status); } - if (elements.filterService.value) { - params.append('email_service', elements.filterService.value); + if (currentFilters.email_service) { + params.append('email_service', currentFilters.email_service); } - if (elements.searchInput.value.trim()) { - params.append('search', elements.searchInput.value.trim()); + if (currentFilters.search) { + params.append('search', currentFilters.search); } try { @@ -336,10 +352,24 @@ function renderAccounts(accounts) { selectedAccounts.add(id); } else { selectedAccounts.delete(id); + selectAllPages = false; } + // 同步全选框状态 + const allChecked = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); + const checkedCount = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length; + elements.selectAll.checked = allChecked.length > 0 && checkedCount === allChecked.length; + elements.selectAll.indeterminate = checkedCount > 0 && checkedCount < allChecked.length; updateBatchButtons(); + renderSelectAllBanner(); }); }); + + // 渲染后同步全选框状态 + const allCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); + const checkedCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked'); + elements.selectAll.checked = allCbs.length > 0 && checkedCbs.length === allCbs.length; + elements.selectAll.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length; + renderSelectAllBanner(); } // 切换密码显示 @@ -365,9 +395,73 @@ function updatePagination() { elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`; } +// 重置全选所有页状态 +function resetSelectAllPages() { + selectAllPages = false; + selectedAccounts.clear(); + updateBatchButtons(); + renderSelectAllBanner(); +} + +// 构建批量请求体(含 select_all 和筛选参数) +function buildBatchPayload(extraFields = {}) { + if (selectAllPages) { + return { + ids: [], + select_all: true, + status_filter: currentFilters.status || null, + email_service_filter: currentFilters.email_service || null, + search_filter: currentFilters.search || null, + ...extraFields + }; + } + return { ids: Array.from(selectedAccounts), ...extraFields }; +} + +// 获取有效选中数量(select_all 时用总数) +function getEffectiveCount() { + return selectAllPages ? totalAccounts : selectedAccounts.size; +} + +// 渲染全选横幅 +function renderSelectAllBanner() { + let banner = document.getElementById('select-all-banner'); + const totalPages = Math.ceil(totalAccounts / pageSize); + const currentPageSize = elements.table.querySelectorAll('input[type="checkbox"][data-id]').length; + const checkedOnPage = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length; + const allPageSelected = currentPageSize > 0 && checkedOnPage === currentPageSize; + + // 只在全选了当前页且有多页时显示横幅 + if (!allPageSelected || totalPages <= 1 || totalAccounts <= pageSize) { + if (banner) banner.remove(); + return; + } + + if (!banner) { + banner = document.createElement('div'); + banner.id = 'select-all-banner'; + banner.style.cssText = 'background:var(--primary-light,#e8f0fe);color:var(--primary-color,#1a73e8);padding:8px 16px;text-align:center;font-size:0.875rem;border-bottom:1px solid var(--border-color);'; + const tableContainer = document.querySelector('.table-container'); + if (tableContainer) tableContainer.insertAdjacentElement('beforebegin', banner); + } + + if (selectAllPages) { + banner.innerHTML = `已选中全部 ${totalAccounts} 条记录。`; + } else { + banner.innerHTML = `当前页已全选 ${checkedOnPage} 条。`; + } +} + +// 选中所有页 +function selectAllPagesAction() { + selectAllPages = true; + updateBatchButtons(); + renderSelectAllBanner(); +} + // 更新批量操作按钮 function updateBatchButtons() { - const count = selectedAccounts.size; + const count = getEffectiveCount(); elements.batchDeleteBtn.disabled = count === 0; elements.batchRefreshBtn.disabled = count === 0; elements.batchValidateBtn.disabled = count === 0; @@ -403,19 +497,17 @@ async function refreshToken(id) { // 批量刷新Token async function handleBatchRefresh() { - if (selectedAccounts.size === 0) return; + const count = getEffectiveCount(); + if (count === 0) return; - const confirmed = await confirm(`确定要刷新选中的 ${selectedAccounts.size} 个账号的Token吗?`); + const confirmed = await confirm(`确定要刷新选中的 ${count} 个账号的Token吗?`); if (!confirmed) return; elements.batchRefreshBtn.disabled = true; elements.batchRefreshBtn.textContent = '刷新中...'; try { - const result = await api.post('/accounts/batch-refresh', { - ids: Array.from(selectedAccounts) - }); - + const result = await api.post('/accounts/batch-refresh', buildBatchPayload()); toast.success(`成功刷新 ${result.success_count} 个,失败 ${result.failed_count} 个`); loadAccounts(); } catch (error) { @@ -427,16 +519,13 @@ async function handleBatchRefresh() { // 批量验证Token async function handleBatchValidate() { - if (selectedAccounts.size === 0) return; + if (getEffectiveCount() === 0) return; elements.batchValidateBtn.disabled = true; elements.batchValidateBtn.textContent = '验证中...'; try { - const result = await api.post('/accounts/batch-validate', { - ids: Array.from(selectedAccounts) - }); - + const result = await api.post('/accounts/batch-validate', buildBatchPayload()); toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`); loadAccounts(); } catch (error) { @@ -561,18 +650,17 @@ async function deleteAccount(id, email) { // 批量删除 async function handleBatchDelete() { - if (selectedAccounts.size === 0) return; + const count = getEffectiveCount(); + if (count === 0) return; - const confirmed = await confirm(`确定要删除选中的 ${selectedAccounts.size} 个账号吗?此操作不可恢复。`); + const confirmed = await confirm(`确定要删除选中的 ${count} 个账号吗?此操作不可恢复。`); if (!confirmed) return; try { - const result = await api.post('/accounts/batch-delete', { - ids: Array.from(selectedAccounts) - }); - + const result = await api.post('/accounts/batch-delete', buildBatchPayload()); toast.success(`成功删除 ${result.deleted_count} 个账号`); selectedAccounts.clear(); + selectAllPages = false; loadStats(); loadAccounts(); } catch (error) { @@ -582,12 +670,13 @@ async function handleBatchDelete() { // 导出账号 async function exportAccounts(format) { - if (selectedAccounts.size === 0) { + const count = getEffectiveCount(); + if (count === 0) { toast.warning('请先选择要导出的账号'); return; } - toast.info(`正在导出 ${selectedAccounts.size} 个账号...`); + toast.info(`正在导出 ${count} 个账号...`); try { const response = await fetch('/api/accounts/export/' + format, { @@ -595,9 +684,7 @@ async function exportAccounts(format) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - ids: Array.from(selectedAccounts) - }) + body: JSON.stringify(buildBatchPayload()) }); if (!response.ok) { @@ -661,18 +748,17 @@ async function uploadToCpa(id) { // 批量上传到CPA async function handleBatchUploadCpa() { - if (selectedAccounts.size === 0) return; + const count = getEffectiveCount(); + if (count === 0) return; - const confirmed = await confirm(`确定要将选中的 ${selectedAccounts.size} 个账号上传到CPA吗?`); + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗?`); if (!confirmed) return; elements.batchUploadCpaBtn.disabled = true; elements.batchUploadCpaBtn.textContent = '上传中...'; try { - const result = await api.post('/accounts/batch-upload-cpa', { - ids: Array.from(selectedAccounts) - }); + const result = await api.post('/accounts/batch-upload-cpa', buildBatchPayload()); let message = `成功: ${result.success_count}`; if (result.failed_count > 0) { @@ -714,17 +800,16 @@ async function markSubscription(id) { // 批量检测订阅状态 async function handleBatchCheckSubscription() { - if (selectedAccounts.size === 0) return; - const confirmed = await confirm(`确定要检测选中的 ${selectedAccounts.size} 个账号的订阅状态吗?`); + const count = getEffectiveCount(); + if (count === 0) return; + const confirmed = await confirm(`确定要检测选中的 ${count} 个账号的订阅状态吗?`); if (!confirmed) return; elements.batchCheckSubBtn.disabled = true; elements.batchCheckSubBtn.textContent = '检测中...'; try { - const result = await api.post('/payment/accounts/batch-check-subscription', { - ids: Array.from(selectedAccounts) - }); + const result = await api.post('/payment/accounts/batch-check-subscription', buildBatchPayload()); let message = `成功: ${result.success_count}`; if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; toast.success(message); @@ -755,17 +840,16 @@ async function uploadToTm(id) { // 批量上传到 Team Manager async function handleBatchUploadTm() { - if (selectedAccounts.size === 0) return; - const confirmed = await confirm(`确定要将选中的 ${selectedAccounts.size} 个账号上传到 Team Manager 吗?`); + const count = getEffectiveCount(); + if (count === 0) return; + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`); if (!confirmed) return; elements.batchUploadTmBtn.disabled = true; elements.batchUploadTmBtn.textContent = '上传中...'; try { - const result = await api.post('/payment/accounts/batch-upload-tm', { - ids: Array.from(selectedAccounts) - }); + const result = await api.post('/payment/accounts/batch-upload-tm', buildBatchPayload()); let message = `成功: ${result.success_count}`; if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;