diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index 82c9934..d64d8f9 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -118,6 +118,37 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 - 支持单个/批量移出工作台,不删除账号池数据。 - 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。 +### 5.1 工作台批量充值(2026-11-11) + +批量充值只放在「微博工作台」,不替换现有单账号充值: + +1. 工作台勾选账号后点批量化栏的「批量充值」,填写本批统一金额(1~1000 元)。 +2. 每个账号调用一次现有 `POST /api/weibo/accounts/{id}/recharge/orders`, + 因此每个账号对应一张独立微博支付订单和一个支付宝二维码; + 微博没有原生批量下单接口,不能用一个二维码给多个账号充值。 +3. 前端受控并发为 3,单次最多 20 个账号;停用账号直接显示跳过原因, + 单个账号下单失败不影响其它账号。 +4. 支付码弹窗按账号分卡片展示二维码、金额、预计微豆、支付状态和到账状态; + 支持放大、复制支付链接、单项/批量刷新状态。 +5. 支付状态成功不等于微豆到账。刷新状态时会继续查询微豆明细, + 只有 `source_id == 充值 order_id` 且 `point_num > 0` 才显示「已到账」。 +6. 批量支付码弹窗打开时默认每 8 秒自动查询待支付/待确认到账项,最多 3 个并发; + 页面不可见或弹窗关闭时暂停,用户可手动关闭「自动查询」。 + 微博没有独立扫码事件,自动查询从二维码可用后开始,支付成功后继续核对微豆到账。 +7. 二维码和支付链接只保存在当前页面内存,不写 localStorage 或数据库; + 刷新页面后需要重新生成。后续如果要求后台继续生成、刷新恢复或单次超过 20 个账号, + 再增加持久化批次任务和 WebSocket 进度。 + +### 5.2 从外部单协作复制账号标识 + +「微博外部单协作」列表和详情里的微博用户名、UID 都提供复制按钮。 +勾选多单后可一次性复制所选用户名或 UID,每行一个并自动去重。 + +工作台「导入账号」弹窗默认提供「批量粘贴」页签,支持换行、空格、逗号、 +中文逗号、分号分隔;后端先按 UID 精确匹配,再按用户名、角色名兜底解析, +重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、 +被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。 + ## 6. 数据隔离边界 - 工作台列表和增删接口都按当前登录用户过滤。 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index ac85aeb..4ff2047 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -32,6 +32,7 @@ from web.backend.routers.weibo import ( enable_account, get_account, import_cookies, + import_workbench_accounts_by_text, list_account_ids, list_account_tags, list_accounts, @@ -52,6 +53,7 @@ from web.backend.schemas import ( WeiboPaymentStatusRequest, WeiboRechargeRequest, WeiboWorkbenchAccountsRequest, + WeiboWorkbenchBatchImportRequest, ) from web.backend.services import weibo_service @@ -476,6 +478,92 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): assert [item.uid for item in pool_a["items"]] == [first.uid] +def test_batch_import_workbench_accounts_reports_each_identifier(db, current_user): + other_user = User( + username="weibo-batch-import-other", + password_hash="hash", + role="support", + is_active=True, + ) + db.add(other_user) + db.commit() + + added = WeiboAccount( + uid="4014000901", + nickname="批量导入新账号", + cookie="SCF=a; SUB=added", + ) + existing = WeiboAccount( + uid="4014000902", + nickname="批量导入已存在", + cookie="SCF=b; SUB=existing", + ) + occupied = WeiboAccount( + uid="4014000903", + nickname="批量导入被占用", + cookie="SCF=c; SUB=occupied", + ) + disabled = WeiboAccount( + uid="4014000904", + nickname="批量导入停用", + cookie="SCF=d; SUB=disabled", + is_enabled=False, + ) + duplicate_a = WeiboAccount( + uid="4014000905", + nickname="批量导入重名", + cookie="SCF=e; SUB=duplicate-a", + ) + duplicate_b = WeiboAccount( + uid="4014000906", + nickname="批量导入重名", + cookie="SCF=f; SUB=duplicate-b", + ) + db.add_all([added, existing, occupied, disabled, duplicate_a, duplicate_b]) + db.commit() + db.add_all( + [ + WeiboWorkbenchAccount(user_id=current_user.id, account_id=existing.id), + WeiboWorkbenchAccount(user_id=other_user.id, account_id=occupied.id), + ] + ) + db.commit() + + result = import_workbench_accounts_by_text( + WeiboWorkbenchBatchImportRequest( + text=( + f"{added.nickname} {added.uid} {existing.uid},{occupied.uid}\n" + "不存在账号;批量导入重名 批量导入停用" + ) + ), + db=db, + current=current_user, + ).model_dump() + + assert result["requested"] == 7 + assert result["added"] == 1 + assert result["already_added"] == 1 + assert result["failed"] == 5 + assert result["account_ids"] == [added.id] + statuses = {item["identifier"]: item["status"] for item in result["items"]} + assert statuses == { + added.nickname: "added", + added.uid: "duplicate", + existing.uid: "already_added", + occupied.uid: "unavailable", + "不存在账号": "not_found", + "批量导入重名": "ambiguous", + "批量导入停用": "disabled", + } + workbench_ids = { + account_id + for (account_id,) in db.query(WeiboWorkbenchAccount.account_id) + .filter(WeiboWorkbenchAccount.user_id == current_user.id) + .all() + } + assert workbench_ids == {existing.id, added.id} + + def test_batch_tag_updates_selected_accounts(db, current_user): first = WeiboAccount( uid="4014000201", diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 2bd984e..e3e34df 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger @@ -41,6 +42,9 @@ from ..schemas import ( WeiboRechargeOrderOut, WeiboRechargeRequest, WeiboWorkbenchAccountsRequest, + WeiboWorkbenchBatchImportItem, + WeiboWorkbenchBatchImportOut, + WeiboWorkbenchBatchImportRequest, ) from ..services.audit_service import record_audit from ..services.weibo_service import ( @@ -61,6 +65,7 @@ from ..services.weibo_service import ( router = APIRouter(prefix="/api/weibo", tags=["微博微豆"]) WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip() +WEIBO_WORKBENCH_BATCH_IMPORT_MAX = 200 def _require_weibo_perm(user: User, permission: str) -> None: @@ -319,6 +324,175 @@ def list_workbench_pool( return _paginated_accounts(query, page=page, page_size=page_size) +@router.post( + "/workbench/accounts/batch-import", + response_model=WeiboWorkbenchBatchImportOut, +) +def import_workbench_accounts_by_text( + req: WeiboWorkbenchBatchImportRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """按 UID/用户名/角色批量解析账号并加入当前用户工作台。""" + + _require_weibo_perm(current, "weibo:workbench") + identifiers: list[str] = [] + for token in re.split(r"[\s,,;;]+", req.text.strip()): + value = token.strip() + if value and value not in identifiers: + identifiers.append(value) + if not identifiers: + raise HTTPException(status_code=400, detail="请先粘贴微博用户名或 UID") + if len(identifiers) > WEIBO_WORKBENCH_BATCH_IMPORT_MAX: + raise HTTPException( + status_code=400, + detail=f"一次最多导入 {WEIBO_WORKBENCH_BATCH_IMPORT_MAX} 个账号", + ) + + visible_query = _visible_weibo_accounts_query(db.query(WeiboAccount), current) + accounts = visible_query.filter( + or_( + WeiboAccount.uid.in_(identifiers), + WeiboAccount.nickname.in_(identifiers), + WeiboAccount.game_role_name.in_(identifiers), + ) + ).all() + + def grouped(field: str) -> dict[str, list[WeiboAccount]]: + result: dict[str, list[WeiboAccount]] = {} + for account in accounts: + value = str(getattr(account, field) or "").strip() + if value: + result.setdefault(value, []).append(account) + return result + + by_uid = grouped("uid") + by_nickname = grouped("nickname") + by_role = grouped("game_role_name") + account_ids = [account.id for account in accounts] + occupancy = { + account_id: user_id + for account_id, user_id in db.query( + WeiboWorkbenchAccount.account_id, + WeiboWorkbenchAccount.user_id, + ) + .filter(WeiboWorkbenchAccount.account_id.in_(account_ids)) + .all() + } + + items: list[WeiboWorkbenchBatchImportItem] = [] + to_add: list[WeiboAccount] = [] + planned_ids: set[int] = set() + for identifier in identifiers: + candidates = by_uid.get(identifier) or by_nickname.get(identifier) or [] + if not candidates: + candidates = by_role.get(identifier) or [] + if not candidates: + items.append( + WeiboWorkbenchBatchImportItem( + identifier=identifier, + status="not_found", + message="当前可见账号池中没有匹配项", + ) + ) + continue + if len(candidates) > 1 and identifier not in by_uid: + items.append( + WeiboWorkbenchBatchImportItem( + identifier=identifier, + status="ambiguous", + message=f"匹配到 {len(candidates)} 个账号,请改用 UID", + ) + ) + continue + + account = candidates[0] + base = { + "identifier": identifier, + "account_id": account.id, + "uid": account.uid, + "nickname": account.nickname, + } + occupant = occupancy.get(account.id) + if not account.is_enabled: + items.append( + WeiboWorkbenchBatchImportItem( + status="disabled", + message="账号已停用", + **base, + ) + ) + elif occupant is not None and occupant != current.id: + items.append( + WeiboWorkbenchBatchImportItem( + status="unavailable", + message="账号已在其他人的工作台中使用", + **base, + ) + ) + elif occupant == current.id: + items.append( + WeiboWorkbenchBatchImportItem( + status="already_added", + message="账号已在本工作台", + **base, + ) + ) + elif account.id in planned_ids: + items.append( + WeiboWorkbenchBatchImportItem( + status="duplicate", + message="同一账号在本次名单中重复", + **base, + ) + ) + else: + planned_ids.add(account.id) + to_add.append(account) + items.append( + WeiboWorkbenchBatchImportItem( + status="added", + message="已加入工作台", + **base, + ) + ) + + try: + db.add_all( + [ + WeiboWorkbenchAccount(user_id=current.id, account_id=account.id) + for account in to_add + ] + ) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=409, + detail="部分账号刚被其他工作台占用,请刷新后重试", + ) from exc + if to_add: + _record_audit_safely( + db, + current, + action="weibo:workbench:batch_import", + target=f"weibo_user:{current.id}", + detail={"account_ids": [account.id for account in to_add]}, + ) + + added = sum(item.status == "added" for item in items) + already_added = sum(item.status == "already_added" for item in items) + failed = len(items) - added - already_added + return WeiboWorkbenchBatchImportOut( + requested=len(identifiers), + added=added, + already_added=already_added, + failed=failed, + account_ids=[account.id for account in to_add], + items=items, + ) + + @router.get("/workbench/tags") def list_workbench_tags( db: Session = Depends(get_db), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index f7e6421..9359a89 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -521,6 +521,28 @@ class WeiboWorkbenchAccountsRequest(BaseModel): account_ids: list[int] = Field(default_factory=list) +class WeiboWorkbenchBatchImportRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=100_000) + + +class WeiboWorkbenchBatchImportItem(BaseModel): + identifier: str + status: str + message: str = "" + account_id: int | None = None + uid: str = "" + nickname: str = "" + + +class WeiboWorkbenchBatchImportOut(BaseModel): + requested: int + added: int + already_added: int + failed: int + account_ids: list[int] = Field(default_factory=list) + items: list[WeiboWorkbenchBatchImportItem] = Field(default_factory=list) + + class WeiboBatchAssignRequest(BaseModel): account_ids: list[int] = Field(min_length=1) assigned_to: int | None = None diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 2e16a1b..e194bfc 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -814,6 +814,31 @@ export interface WeiboWorkbenchMutationResult { success: boolean; } +export interface WeiboWorkbenchBatchImportItem { + identifier: string; + status: + | 'added' + | 'already_added' + | 'not_found' + | 'ambiguous' + | 'unavailable' + | 'disabled' + | 'duplicate'; + message: string; + account_id: number | null; + uid: string; + nickname: string; +} + +export interface WeiboWorkbenchBatchImportResult { + requested: number; + added: number; + already_added: number; + failed: number; + account_ids: number[]; + items: WeiboWorkbenchBatchImportItem[]; +} + export interface WeiboBatchTagResult { updated: number; tag: string; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index fa345d4..ac237a3 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -15,6 +15,7 @@ import type { WeiboRechargeOrderResult, WeiboImportResult, WeiboWorkbenchMutationResult, + WeiboWorkbenchBatchImportResult, } from './types'; export const weiboApi = { @@ -122,6 +123,11 @@ export const weiboApi = { '/weibo/workbench/accounts', { account_ids: accountIds }, ), + batchImportWorkbenchAccounts: (text: string) => + api.post( + '/weibo/workbench/accounts/batch-import', + { text }, + ), removeWorkbenchAccounts: (accountIds: number[]) => api.post( '/weibo/workbench/accounts/remove', diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 90e78e3..5ef754e 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, AutoComplete, @@ -37,6 +37,7 @@ import { ReloadOutlined, SearchOutlined, StopOutlined, + ZoomInOutlined, } from '@ant-design/icons'; import { weiboApi, @@ -49,12 +50,14 @@ import { type WeiboPaymentStatusResult, type WeiboPointHistoryResult, type WeiboRechargeOrderResult, + type WeiboWorkbenchBatchImportResult, } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; import { copyTextToClipboard } from '../utils/clipboard'; +import { runWithConcurrency } from '../utils/concurrency'; const { Text, Title } = Typography; const { TextArea } = Input; @@ -72,7 +75,39 @@ const PAYMENT_STATUS_LABELS: Record = { PAY_STATUS_CLOSED: '订单关闭', }; +const WORKBENCH_IMPORT_STATUS: Record< + WeiboWorkbenchBatchImportResult['items'][number]['status'], + { label: string; color: string } +> = { + added: { label: '已导入', color: 'success' }, + already_added: { label: '已存在', color: 'blue' }, + not_found: { label: '未找到', color: 'error' }, + ambiguous: { label: '重名', color: 'warning' }, + unavailable: { label: '被占用', color: 'warning' }, + disabled: { label: '已停用', color: 'default' }, + duplicate: { label: '名单重复', color: 'default' }, +}; + const WEIBO_MALL_CACHE_KEY = 'weibo_workbench_mall_cache_v1'; +const WEIBO_BATCH_RECHARGE_MAX = 20; +const WEIBO_BATCH_RECHARGE_CONCURRENCY = 3; +const WEIBO_BATCH_RECHARGE_POLL_INTERVAL_MS = 8_000; + +type BatchRechargePhase = 'queued' | 'creating' | 'ready' | 'error'; +type BatchCreditPhase = '' | 'checking' | 'pending' | 'credited' | 'error'; + +interface BatchRechargeEntry { + account: WeiboAccountItem; + phase: BatchRechargePhase; + error: string; + order: WeiboRechargeOrderResult | null; + paymentStatus: WeiboPaymentStatusResult | null; + statusError: string; + checkingStatus: boolean; + creditPhase: BatchCreditPhase; + creditPoints: number | null; + creditError: string; +} function readMallCache(accountId: number): WeiboMallListResult | null { try { @@ -161,6 +196,13 @@ export default function WeiboAccountsPage({ const [poolPageSize, setPoolPageSize] = useState(10); const [poolSearch, setPoolSearch] = useState(''); const [poolSelectedIds, setPoolSelectedIds] = useState([]); + const [workbenchImportTab, setWorkbenchImportTab] = useState<'paste' | 'pool'>( + 'paste', + ); + const [batchImportText, setBatchImportText] = useState(''); + const [batchImportLoading, setBatchImportLoading] = useState(false); + const [batchImportResult, setBatchImportResult] = + useState(null); const [importOpen, setImportOpen] = useState(false); const [importing, setImporting] = useState(false); @@ -201,6 +243,24 @@ export default function WeiboAccountsPage({ const [paymentStatusLoading, setPaymentStatusLoading] = useState(false); const [paymentStatus, setPaymentStatus] = useState(null); + const [batchRechargeConfigOpen, setBatchRechargeConfigOpen] = useState(false); + const [batchRechargeAmount, setBatchRechargeAmount] = useState(1); + const [batchRechargeOpen, setBatchRechargeOpen] = useState(false); + const [batchRechargeCreating, setBatchRechargeCreating] = useState(false); + const [batchRechargeCheckingAll, setBatchRechargeCheckingAll] = + useState(false); + const [batchRechargeAutoPolling, setBatchRechargeAutoPolling] = + useState(true); + const [batchRechargeEntries, setBatchRechargeEntries] = useState< + BatchRechargeEntry[] + >([]); + const [batchRechargeZoom, setBatchRechargeZoom] = + useState(null); + const batchRechargeEntriesRef = useRef([]); + const batchRechargePollingRef = useRef(false); + const batchRechargeCheckRef = useRef< + (entry: BatchRechargeEntry) => Promise + >(async () => {}); const loadAccounts = useCallback(async () => { setLoading(true); @@ -328,10 +388,42 @@ export default function WeiboAccountsPage({ const openWorkbenchImport = () => { setPoolSearch(''); setPoolSelectedIds([]); + setWorkbenchImportTab('paste'); + setBatchImportText(''); + setBatchImportResult(null); setPoolOpen(true); void loadWorkbenchPool(1, poolPageSize, ''); }; + const handleBatchImportToWorkbench = async () => { + const text = batchImportText.trim(); + if (!text) { + message.warning('请先粘贴微博用户名或 UID'); + return; + } + setBatchImportLoading(true); + setBatchImportResult(null); + try { + const result = await weiboApi.batchImportWorkbenchAccounts(text); + setBatchImportResult(result); + await loadAccounts(); + await loadKnownTags(); + if (result.failed > 0) { + message.warning( + `批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个,失败 ${result.failed} 个`, + ); + } else { + message.success( + `批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个`, + ); + } + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBatchImportLoading(false); + } + }; + const handleImportToWorkbench = async () => { if (poolSelectedIds.length === 0) { message.warning('请先选择账号'); @@ -808,6 +900,262 @@ export default function WeiboAccountsPage({ await loadPointHistory(featureAccount); }; + const patchBatchRechargeEntry = ( + accountId: number, + patch: Partial, + ) => { + setBatchRechargeEntries((current) => + current.map((entry) => + entry.account.id === accountId ? { ...entry, ...patch } : entry, + ), + ); + }; + + const openBatchRecharge = () => { + if (!isWorkbench || !canRecharge || selectedIds.length === 0) return; + if (batchRechargeCreating) { + message.info('上一批支付码仍在生成,请等待完成或查看支付码弹窗'); + return; + } + if (selectedIds.length > WEIBO_BATCH_RECHARGE_MAX) { + message.warning( + `批量充值一次最多 ${WEIBO_BATCH_RECHARGE_MAX} 个账号,请缩小选择范围`, + ); + return; + } + setBatchRechargeAmount(1); + setBatchRechargeConfigOpen(true); + }; + + const startBatchRecharge = async () => { + if (batchRechargeCreating) return; + const selected = items.filter((item) => selectedIds.includes(item.id)); + if (selected.length === 0) { + message.warning('请先勾选账号'); + return; + } + if (selected.length > WEIBO_BATCH_RECHARGE_MAX) { + message.warning( + `批量充值一次最多 ${WEIBO_BATCH_RECHARGE_MAX} 个账号,请缩小选择范围`, + ); + return; + } + const amount = Math.max( + 1, + Math.min(1000, Math.trunc(Number(batchRechargeAmount) || 1)), + ); + const entries: BatchRechargeEntry[] = selected.map((account) => ({ + account, + phase: account.is_enabled ? 'queued' : 'error', + error: account.is_enabled ? '' : '账号已停用', + order: null, + paymentStatus: null, + statusError: '', + checkingStatus: false, + creditPhase: '', + creditPoints: null, + creditError: '', + })); + + setBatchRechargeAmount(amount); + setBatchRechargeConfigOpen(false); + setBatchRechargeEntries(entries); + setBatchRechargeOpen(true); + setBatchRechargeAutoPolling(true); + setBatchRechargeCreating(true); + + await runWithConcurrency( + entries, + WEIBO_BATCH_RECHARGE_CONCURRENCY, + async (entry) => { + if (!entry.account.is_enabled) return; + patchBatchRechargeEntry(entry.account.id, { phase: 'creating' }); + try { + const order = await weiboApi.createRechargeOrder( + entry.account.id, + amount, + ); + patchBatchRechargeEntry(entry.account.id, { + phase: 'ready', + order, + error: '', + }); + } catch (error) { + patchBatchRechargeEntry(entry.account.id, { + phase: 'error', + error: getErrorMessage(error), + }); + } + }, + ); + + setBatchRechargeCreating(false); + const disabledCount = entries.filter( + (entry) => !entry.account.is_enabled, + ).length; + message.info( + `批量下单完成:${selected.length - disabledCount} 个启用账号已处理,${disabledCount} 个停用账号已跳过;失败原因见支付码弹窗`, + ); + }; + + const checkBatchRechargeEntry = async (entry: BatchRechargeEntry) => { + if (!entry.order) return; + const order = entry.order; + const accountId = entry.account.id; + patchBatchRechargeEntry(accountId, { + checkingStatus: true, + statusError: '', + }); + try { + const status = await weiboApi.rechargeStatus( + accountId, + order.pay_id, + ); + patchBatchRechargeEntry(accountId, { + paymentStatus: status, + statusError: '', + }); + if (status.status !== 'PAY_STATUS_SUCCESS') { + patchBatchRechargeEntry(accountId, { + creditPhase: '', + creditPoints: null, + creditError: '', + }); + return; + } + + patchBatchRechargeEntry(accountId, { + creditPhase: 'checking', + creditError: '', + }); + try { + const history = await weiboApi.pointHistory(accountId, 1, 100); + const matched = history.items.find( + (item) => + item.source_id === order.order_id && Number(item.point_num) > 0, + ); + if (!matched) { + patchBatchRechargeEntry(accountId, { + creditPhase: 'pending', + creditPoints: null, + }); + return; + } + + patchBatchRechargeEntry(accountId, { + creditPhase: 'credited', + creditPoints: matched.point_num, + }); + try { + const refreshed = await weiboApi.refreshAccount(accountId); + setItems((current) => + current.map((item) => + item.id === refreshed.id ? refreshed : item, + ), + ); + setFeatureAccount((current) => + current?.id === refreshed.id ? refreshed : current, + ); + patchBatchRechargeEntry(accountId, { account: refreshed }); + } catch { + // 支付和到账已经确认;账号资料刷新失败不改变本次充值结论。 + } + } catch (error) { + patchBatchRechargeEntry(accountId, { + creditPhase: 'error', + creditError: getErrorMessage(error), + }); + } + } catch (error) { + patchBatchRechargeEntry(accountId, { + statusError: getErrorMessage(error), + }); + } finally { + patchBatchRechargeEntry(accountId, { checkingStatus: false }); + } + }; + + const refreshAllBatchRechargeStatuses = async () => { + const targets = batchRechargeEntries.filter((entry) => entry.order); + if (targets.length === 0) return; + setBatchRechargeCheckingAll(true); + try { + await runWithConcurrency( + targets, + WEIBO_BATCH_RECHARGE_CONCURRENCY, + checkBatchRechargeEntry, + ); + } finally { + setBatchRechargeCheckingAll(false); + } + }; + + useEffect(() => { + batchRechargeCheckRef.current = checkBatchRechargeEntry; + }); + + useEffect(() => { + batchRechargeEntriesRef.current = batchRechargeEntries; + }, [batchRechargeEntries]); + + useEffect(() => { + if (!batchRechargeOpen || !batchRechargeAutoPolling) return; + + const poll = async () => { + if ( + batchRechargePollingRef.current || + document.visibilityState !== 'visible' + ) { + return; + } + const targets = batchRechargeEntriesRef.current.filter( + (entry) => + entry.phase === 'ready' && + entry.order && + !entry.checkingStatus && + entry.creditPhase !== 'credited' && + entry.paymentStatus?.status !== 'PAY_STATUS_FAILED' && + entry.paymentStatus?.status !== 'PAY_STATUS_CLOSED', + ); + if (targets.length === 0) return; + batchRechargePollingRef.current = true; + try { + await runWithConcurrency( + targets, + WEIBO_BATCH_RECHARGE_CONCURRENCY, + (entry) => batchRechargeCheckRef.current(entry), + ); + } finally { + batchRechargePollingRef.current = false; + } + }; + + const initialTimer = window.setTimeout(() => void poll(), 2_000); + const timer = window.setInterval( + () => void poll(), + WEIBO_BATCH_RECHARGE_POLL_INTERVAL_MS, + ); + return () => { + window.clearTimeout(initialTimer); + window.clearInterval(timer); + }; + }, [batchRechargeAutoPolling, batchRechargeOpen]); + + const copyBatchRechargeLink = async (entry: BatchRechargeEntry) => { + const link = + entry.order?.alipay_qr_code || entry.order?.desktop_pay_url || ''; + if (!link) { + message.warning('没有可复制的支付链接'); + return; + } + try { + await copyTextToClipboard(link); + message.success('支付链接已复制'); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + const centerHeader = () => ({ style: { textAlign: 'center' as const } }); const columns: TableProps['columns'] = [ @@ -1236,6 +1584,18 @@ export default function WeiboAccountsPage({ () => knownTags.map((tag) => ({ value: tag })), [knownTags], ); + const batchRechargeReadyCount = batchRechargeEntries.filter( + (entry) => entry.phase === 'ready', + ).length; + const batchRechargePendingPaymentCount = batchRechargeEntries.filter( + (entry) => + entry.phase === 'ready' && + (!entry.paymentStatus || + entry.paymentStatus.status === 'PAY_STATUS_PENDING'), + ).length; + const batchRechargeCreditedCount = batchRechargeEntries.filter( + (entry) => entry.creditPhase === 'credited', + ).length; return ( @@ -1313,6 +1673,17 @@ export default function WeiboAccountsPage({ + {isWorkbench && canRecharge && batchRechargeEntries.length > 0 && ( + + )} {isWorkbench && ( + {isWorkbench && canRecharge && ( + + )} {!isWorkbench && canManage && ( + + + + 预计每个账号到账{' '} + {(Math.max(1, Number(batchRechargeAmount) || 1) * 100).toLocaleString()}{' '} + 微豆。单次最多 {WEIBO_BATCH_RECHARGE_MAX} 个账号,并发生成上限{' '} + {WEIBO_BATCH_RECHARGE_CONCURRENCY}。 + + + + + setBatchRechargeOpen(false)} + footer={ + + + setBatchRechargeAutoPolling(event.target.checked) + } + > + 自动查询 + + + 待支付 {batchRechargePendingPaymentCount} · 已到账{' '} + {batchRechargeCreditedCount} + + + + + + } + > + + + + + 生成中{' '} + {batchRechargeEntries.filter( + (entry) => + entry.phase === 'queued' || entry.phase === 'creating', + ).length} + + 可用支付码 {batchRechargeReadyCount} + + 失败{' '} + { + batchRechargeEntries.filter((entry) => entry.phase === 'error') + .length + } + + + 已支付{' '} + { + batchRechargeEntries.filter( + (entry) => + entry.paymentStatus?.status === 'PAY_STATUS_SUCCESS', + ).length + } + + +
+ {batchRechargeEntries.map((entry) => { + const order = entry.order; + const payment = entry.paymentStatus; + const paymentColor = + payment?.status === 'PAY_STATUS_SUCCESS' + ? 'success' + : payment?.status === 'PAY_STATUS_FAILED' || + payment?.status === 'PAY_STATUS_CLOSED' + ? 'error' + : 'processing'; + return ( +
+ + {entry.account.nickname || entry.account.uid} + +
+ + UID {entry.account.uid} + +
+ {entry.phase === 'queued' && ( +
+ +
+ 排队中 +
+
+ )} + {entry.phase === 'creating' && ( +
+ +
+ 正在创建订单并解析二维码 +
+
+ )} + {entry.phase === 'error' && ( + + )} + {entry.phase === 'ready' && order && ( + + {order.alipay_qr_code ? ( +
+ +
+ ) : ( + + )} + + {order.amount_yuan} 元 · 预计{' '} + {order.single_reward.toLocaleString()} 微豆 + + + {payment + ? PAYMENT_STATUS_LABELS[payment.status] || payment.status + : '待检查支付状态'} + + {payment?.status === 'PAY_STATUS_SUCCESS' && ( + + {entry.creditPhase === 'credited' + ? `已到账 +${entry.creditPoints ?? 0}` + : entry.creditPhase === 'pending' + ? '支付成功,微豆暂未查到' + : entry.creditPhase === 'error' + ? '到账查询失败' + : '正在核对到账'} + + )} + {entry.creditError && ( + + {entry.creditError} + + )} + {entry.statusError && ( + + {entry.statusError} + + )} + + {order.alipay_qr_code && ( + + )} + + + +
+ )} +
+ ); + })} +
+
+
+ + setBatchRechargeZoom(null)} + > + {batchRechargeZoom?.order?.alipay_qr_code && ( +
+
+ +
+

+ + + {batchRechargeZoom.order.amount_yuan} 元 + + + 预计 {batchRechargeZoom.order.single_reward.toLocaleString()}{' '} + 微豆 + + +

+ + {batchRechargeZoom.order.alipay_qr_code} + +
+ +
+
+ )} +
+ void handleImportToWorkbench()} + onOk={() => + void (workbenchImportTab === 'paste' + ? handleBatchImportToWorkbench() + : handleImportToWorkbench()) + } onCancel={() => setPoolOpen(false)} destroyOnHidden > - setPoolSearch(event.target.value)} - onSearch={(value) => void loadWorkbenchPool(1, poolPageSize, value)} - style={{ marginBottom: 12 }} - /> - - rowKey="id" - size="small" - loading={poolLoading} - dataSource={poolItems} - rowSelection={{ - selectedRowKeys: poolSelectedIds, - onChange: (keys) => setPoolSelectedIds(keys.map(Number)), - }} - columns={[ + setWorkbenchImportTab(key as 'paste' | 'pool')} + items={[ { - title: '微博账号', - render: (_, account) => ( - - {account.nickname || account.uid} - - UID {account.uid} - + key: 'paste', + label: '批量粘贴', + children: ( + + +