From ed15701392a67c2f525eeb8c87df2fd9e53b088e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 8 Nov 2026 13:17:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E4=BF=AE=E6=AD=A3=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=A0=81=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=B4=A6=E5=8F=B7=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/weibo/__init__.py | 2 + core/weibo/client.py | 81 ++++ ...博微豆-CK接口与兑换链路确认-2026-11-07.md | 43 ++- tests/test_weibo_accounts.py | 38 ++ tests/test_weibo_client.py | 56 +++ web/backend/routers/weibo.py | 27 +- web/backend/schemas.py | 2 + web/backend/services/weibo_service.py | 38 ++ web/frontend/src/api/types.ts | 2 + web/frontend/src/api/weibo.ts | 4 +- web/frontend/src/pages/WeiboAccountsPage.tsx | 352 +++++++++++++++--- 11 files changed, 591 insertions(+), 54 deletions(-) diff --git a/core/weibo/__init__.py b/core/weibo/__init__.py index 279e735..4589bdd 100644 --- a/core/weibo/__init__.py +++ b/core/weibo/__init__.py @@ -5,6 +5,7 @@ from .client import ( WeiboApiError, WeiboAuthError, WeiboBindError, + WeiboBindQrCode, WeiboClient, WeiboError, WeiboExchangeError, @@ -24,6 +25,7 @@ __all__ = [ "WeiboApiError", "WeiboAuthError", "WeiboBindError", + "WeiboBindQrCode", "WeiboClient", "WeiboError", "WeiboExchangeError", diff --git a/core/weibo/client.py b/core/weibo/client.py index 890b439..d652736 100644 --- a/core/weibo/client.py +++ b/core/weibo/client.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import secrets import time from dataclasses import dataclass from html.parser import HTMLParser @@ -25,6 +26,7 @@ WEIBO_API_BASE = "https://vipclub.e.weibo.com/aj" WEIBO_VPLUS_API_BASE = "https://vipclub.weibo.com/aj" WEIBO_PAY_BASE = "https://pay.sc.weibo.com" WEIBO_SCHEME_URL = "https://livelinkbind.game.qq.com/livelink" +WEIBO_QRCODE_URL = "https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode" WEIBO_MOBILE_UA = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Weibo" @@ -103,6 +105,14 @@ class WeiboPaymentStatus: sub_status: str +@dataclass(frozen=True, slots=True) +class WeiboBindQrCode: + """来联返回的小程序码图片。""" + + image_src: str + token: str = "" + + @dataclass(frozen=True, slots=True) class WeiboExchangeResult: goods_id: int @@ -264,6 +274,19 @@ def extract_bind_raw_url(bind_url: str) -> str: return raw_url +def _livelink_qrcode_image_src(qrcode: str) -> str: + """把来联返回的二维码内容转成可直接渲染的图片地址。""" + + text = (qrcode or "").strip() + if not text: + return "" + lower = text.lower() + if lower.startswith(("https://", "http://", "data:image/")): + return text + mime = "image/png" if text.startswith("iVBOR") else "image/jpeg" + return f"data:{mime};base64,{text}" + + class WeiboClient: """微博微豆基础客户端。""" @@ -1000,3 +1023,61 @@ class WeiboClient: if not scheme: raise WeiboBindError("来联接口没有返回 urlScheme") return scheme + + def generate_bind_qrcode( + self, + bind_url: str, + account_type: str, + ) -> WeiboBindQrCode: + """生成微信或 QQ 可直接扫码的小程序码图片。""" + + if account_type not in {"wx", "qq"}: + raise ValueError("account_type 只能是 wx 或 qq") + raw_url = extract_bind_raw_url(bind_url) + query = parse_qs(raw_url, keep_blank_values=True) + live_plat_id = str((query.get("livePlatId") or [""])[0]) + act_id = str((query.get("actId") or [""])[0]) + from_id = str((query.get("fromId") or [""])[0]) + if not live_plat_id or not act_id: + raise WeiboBindError("bind_url 缺少 livePlatId 或 actId") + if len(from_id) > 32: + from_id = "" + + suffix = "".join( + secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(8) + ) + session_id = f"{int(time.time())}_{suffix}" + payload = { + "queryString": f"{raw_url}&sessionId={session_id}", + "accountType": account_type, + } + try: + data = self._request_json( + "POST", + WEIBO_QRCODE_URL, + headers={ + "Content-Type": "application/json;charset=UTF-8", + "Accept": "application/json, text/plain, */*", + "Origin": "https://livelink.qq.com", + "Referer": "https://livelink.qq.com/", + "Gap-Plat-Id": live_plat_id, + "Gap-Act-Id": act_id, + "Gap-User-From": "pc", + "Gap-From-Id": from_id, + "Vulcan-AccType": "auth_in_h5", + }, + json=payload, + ) + except WeiboTransportError as exc: + raise WeiboBindError(f"请求来联二维码失败: {exc}") from exc + if data.get("iRet") != 0: + raise WeiboBindError(f"生成绑定二维码失败: {data.get('sMsg') or data}") + raw_jdata = data.get("jData") + jdata: dict[str, Any] = raw_jdata if isinstance(raw_jdata, dict) else {} + image_src = _livelink_qrcode_image_src(str(jdata.get("qrcode") or "")) + if not image_src: + raise WeiboBindError("来联接口没有返回二维码图片") + return WeiboBindQrCode( + image_src=image_src, + token=str(jdata.get("qrcodeToken") or ""), + ) diff --git a/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md index af7ec6c..1764ae9 100644 --- a/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md +++ b/docs/微博/微博微豆-CK接口与兑换链路确认-2026-11-07.md @@ -27,9 +27,10 @@ CK + F -> GET /aj/mall/home 获取用户、绑定状态、余额、任务、商城入口 | +-> 未绑定:game_role.bind_url - | -> POST GenMiniappScheme + | -> POST GenMiniappScheme(H5 直接拉起小程序) | -> 微信/QQ urlScheme - | -> 生成绑定二维码 + | -> 或 POST getMiniAppQrcode(可直接扫码的小程序码) + | -> 展示微信/QQ 小程序码图片 | +-> 助威得微豆: GET /aj/vmember/gfsupport 获取助力目标 uid/vuid @@ -209,7 +210,7 @@ QQ 请求体: } ``` -二维码必须编码 `jData.urlScheme` 的完整字符串,不应编码原 `livelink.qq.com` 页面地址。 +`GenMiniappScheme` 返回的是 H5 页面内“打开小程序”按钮使用的 Scheme,不等于可扫码的小程序码。它不应作为二维码图片内容,也不应把原 `livelink.qq.com` 页面地址当二维码内容。 #### 3.1.2 Scheme 格式 @@ -266,6 +267,42 @@ Content-Type: application/json 本次没有抓到 `is_bound=false` 的独立样本,因此未绑定场景下 `bind_url` 的值仍需下一次抓包确认;但当前已绑定响应中同样存在 `bind_url`,且同一 `GenMiniappScheme` 链路已实测可用。 +#### 3.1.4 真正可扫的微信/QQ 小程序码 + +2026-11-08 的 QQ 绑定反馈确认:`https://livelink.qq.com/h5-open-mp/qq?` 是 H5 入口页。用户在 HAR 中复制的链接正好等于该入口页,页面加载后再从 `location.search` 取内层 Query,并在页面内调用 `GenMiniappScheme`。把 H5 入口地址或 Scheme 字符串直接编码成二维码,会走浏览器/深链分支,不等价于扫码绑定。 + +真正的小程序码接口是: + +```http +POST https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode +Content-Type: application/json;charset=UTF-8 +Origin: https://livelink.qq.com +Referer: https://livelink.qq.com/ +Gap-Plat-Id: +Gap-Act-Id: +Gap-User-From: pc +Vulcan-AccType: auth_in_h5 +``` + +请求体: + +```json +{ + "queryString": "&sessionId=<秒级时间戳_随机串>", + "accountType": "wx" +} +``` + +`accountType` 只能是 `wx` 或 `qq`。实测两个渠道返回不同的官方小程序码图片: + +- `wx`:微信小程序码,图片右下角为微信图标。 +- `qq`:QQ 小程序码,图片右下角为 QQ 图标。 +- 响应 `jData.qrcode` 是 base64 图片,前端保存为 `data:image/...;base64,...` 后直接 `` 展示。 +- 响应 `jData.qrcodeToken` 可用于后续调用 `/api/qrcode/poolingQrcodeStatus` 查询扫码状态。 +- 同一绑定链接连续刷新可能返回 `iRet=-1140603`、`sMsg=刷新二维码频率过快,请间隔5秒后重试`。前端应让用户先选渠道,再只生成当前渠道,不要一次预生成两个渠道。 + +HAR `qq-二维码-1.har` 的 `switchPlatToken` 请求体 `rawUrl` 与用户复制的 H5 Query 一致;随后 `bindPlatUser` 返回 `iRet=0`,说明内层参数本身被来联接受。问题在二维码载体混用了 H5 入口/URL Scheme,而不是 `actId/code/faceUrl/nonce/sig/t` 被重新编码。 + ## 4. 助威得微豆与充值 ### 4.1 助威入口 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 86af236..05b2c92 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -11,6 +11,7 @@ from sqlalchemy import text from core.weibo import ( WeiboAuthError, WeiboBindError, + WeiboBindQrCode, WeiboExchangeResult, WeiboHomeSnapshot, WeiboPaymentStatus, @@ -90,6 +91,17 @@ class StubWeiboClient: ) return prefix + def generate_bind_qrcode(self, bind_url: str, account_type: str): + self.calls.append(("qrcode", account_type)) + error = self.scheme_errors.get(account_type) + if error: + raise WeiboBindError(error) + mime = "png" if account_type == "wx" else "jpeg" + return WeiboBindQrCode( + image_src=f"data:image/{mime};base64,test-{account_type}", + token=f"token-{account_type}", + ) + @pytest.fixture def current_user(db): @@ -333,6 +345,32 @@ def test_bind_qrcodes_returns_both_channels_and_partial_error(db, current_user): assert by_channel["qq"]["error"] == "QQ Scheme 暂时不可用" +def test_bind_qrcodes_selected_channel_returns_scannable_image(db, current_user): + account = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=b", tag=""), + db=db, + current=current_user, + ).accounts[0] + + result = create_bind_qrcodes( + account.id, + channel="qq", + db=db, + current=current_user, + ) + + dumped = result.model_dump() + assert len(dumped["schemes"]) == 1 + qq = dumped["schemes"][0] + assert qq["channel"] == "qq" + assert qq["qrcode_image"] == "data:image/jpeg;base64,test-qq" + assert qq["qrcode_token"] == "token-qq" + assert qq["scheme"].startswith("mqqapi://miniapp/open") + assert [call for call in StubWeiboClient.calls if call[0] == "qrcode"] == [ + ("qrcode", "qq") + ] + + def test_bind_qrcodes_rejects_bound_account_without_rebind(db, current_user): account = import_cookies( WeiboCookieImport(text="SCF=a; SUB=b", tag=""), diff --git a/tests/test_weibo_client.py b/tests/test_weibo_client.py index 0436f1c..3ce5ed5 100644 --- a/tests/test_weibo_client.py +++ b/tests/test_weibo_client.py @@ -11,6 +11,7 @@ from core.weibo import ( WeiboApiError, WeiboAuthError, WeiboBindError, + WeiboBindQrCode, WeiboClient, WeiboExchangeError, WeiboExchangeUncertainError, @@ -187,6 +188,61 @@ def test_generate_bind_scheme_rejects_missing_url_scheme(): client.generate_bind_scheme(BIND_URL, "qq") +def test_generate_bind_qrcode_posts_channel_and_preserves_raw_query(): + session = FakeSession( + [ + { + "iRet": 0, + "sMsg": "ok", + "jData": { + "qrcode": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + "qrcodeToken": "token-1", + }, + } + ] + ) + client = WeiboClient(session=session) + + qrcode = client.generate_bind_qrcode(BIND_URL, "qq") + + assert isinstance(qrcode, WeiboBindQrCode) + assert qrcode.image_src == ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + ) + assert qrcode.token == "token-1" + method, url, kwargs = session.calls[0] + assert method == "POST" + assert url == "https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode" + body = kwargs["json"] + raw_url = extract_bind_raw_url(BIND_URL) + assert body["queryString"].startswith(f"{raw_url}&sessionId=") + assert body["accountType"] == "qq" + assert kwargs["headers"]["Gap-Act-Id"] == "16722" + assert kwargs["headers"]["Gap-Plat-Id"] == "weiyouxi" + assert kwargs["headers"]["Gap-User-From"] == "pc" + assert "Cookie" not in kwargs["headers"] + + +def test_generate_bind_qrcode_supports_jpeg_and_server_rate_limit(): + jpeg_session = FakeSession( + [ + { + "iRet": 0, + "sMsg": "ok", + "jData": {"qrcode": "/9j/4AAQSkZJRgABAQ", "qrcodeToken": "token-2"}, + } + ] + ) + qrcode = WeiboClient(session=jpeg_session).generate_bind_qrcode(BIND_URL, "wx") + assert qrcode.image_src == "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ" + + rate_limited = FakeSession( + [{"iRet": -1140603, "sMsg": "刷新二维码频率过快,请间隔5秒后重试"}] + ) + with pytest.raises(WeiboBindError, match="间隔5秒"): + WeiboClient(session=rate_limited).generate_bind_qrcode(BIND_URL, "qq") + + def test_mall_exchange_and_point_queries(): session = FakeSession( [ diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 2d9fac4..52fe404 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -43,6 +43,7 @@ from ..services.weibo_service import ( disable_weibo_account, enable_weibo_account, exchange_weibo_goods, + generate_weibo_bind_qrcode, generate_weibo_bind_schemes, import_weibo_cookies, query_weibo_exchange_records, @@ -349,12 +350,14 @@ def refresh_account( @router.post("/accounts/{account_id}/bind-qrcodes", response_model=WeiboBindQrOut) def create_bind_qrcodes( account_id: int, + channel: str = Query("", pattern="^(wx|qq)?$"), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """生成微信和 QQ 绑定 Scheme,供前端直接渲染二维码。""" _require_weibo_perm(current, "weibo:bind") + selected_channel = channel.strip() if isinstance(channel, str) else "" account = _get_account(db, account_id) _ensure_enabled(account) snapshot = _refresh_or_http(account, db) @@ -370,15 +373,29 @@ def create_bind_qrcodes( if not snapshot.bind_url: raise HTTPException(status_code=409, detail="微博没有返回当前账号的绑定链接") - schemes = [ - WeiboBindSchemeOut(**item) - for item in generate_weibo_bind_schemes(account, snapshot.bind_url) + if selected_channel: + items = [ + generate_weibo_bind_qrcode(account, snapshot.bind_url, selected_channel) + ] + else: + # 不传 channel 时保留旧接口:一次返回微信、QQ 两个 Scheme。 + items = generate_weibo_bind_schemes(account, snapshot.bind_url) + schemes = [WeiboBindSchemeOut(**item) for item in items] + successful = [ + item + for item in schemes + if (item.qrcode_image if selected_channel else item.scheme) ] - successful = [item for item in schemes if item.scheme] if not successful: + prefix = ( + f"{schemes[0].label}绑定二维码生成失败" + if selected_channel and schemes + else "微信和 QQ 绑定二维码都生成失败" + ) raise HTTPException( status_code=502, - detail="微信和 QQ 绑定二维码都生成失败: " + detail=prefix + + ": " + "; ".join(f"{item.label}={item.error}" for item in schemes if item.error), ) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index c43924a..fce26b5 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -522,6 +522,8 @@ class WeiboBindSchemeOut(BaseModel): channel: str label: str scheme: str = "" + qrcode_image: str = "" + qrcode_token: str = "" error: str = "" diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 38f341f..0317c64 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -222,6 +222,44 @@ def generate_weibo_bind_schemes( return result +def generate_weibo_bind_qrcode( + account: WeiboAccount, + bind_url: str, + channel: str, +) -> dict[str, str]: + """生成指定渠道的小程序码,并尽力附带可复制的 Scheme。""" + + _ = account + label = {"wx": "微信", "qq": "QQ"}[channel] + client = WeiboClient() + try: + qrcode = client.generate_bind_qrcode(bind_url, channel) + except (WeiboError, ValueError) as exc: + return { + "channel": channel, + "label": label, + "scheme": "", + "qrcode_image": "", + "qrcode_token": "", + "error": str(exc), + } + + scheme = "" + try: + scheme = client.generate_bind_scheme(bind_url, channel) + except (WeiboError, ValueError): + # 二维码图片是主结果,Scheme 仅用于复制,不影响扫码绑定。 + pass + return { + "channel": channel, + "label": label, + "scheme": scheme, + "qrcode_image": qrcode.image_src, + "qrcode_token": qrcode.token, + "error": "", + } + + def _effective_activity(account: WeiboAccount, fallback: str) -> str: return account.activity or fallback diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index ce985f1..ad38c41 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -788,6 +788,8 @@ export interface WeiboBindScheme { channel: 'wx' | 'qq'; label: string; scheme: string; + qrcode_image: string; + qrcode_token: string; error: string; } diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index 291f9a1..c958f2f 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -55,11 +55,11 @@ export const weiboApi = { api.post( `/weibo/accounts/${accountId}/refresh`, ), - bindQrcodes: (accountId: number) => + bindQrcodes: (accountId: number, channel: 'wx' | 'qq') => api.post( `/weibo/accounts/${accountId}/bind-qrcodes`, undefined, - { timeout: 60000 }, + { params: { channel }, timeout: 60000 }, ), mallList: (accountId: number) => api.post( diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index eecc689..c546442 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -10,6 +10,7 @@ import { Modal, Popconfirm, QRCode, + Segmented, Select, Space, Spin, @@ -25,6 +26,8 @@ import { DeleteOutlined, AppstoreOutlined, CheckCircleOutlined, + CopyOutlined, + DownloadOutlined, EditOutlined, ImportOutlined, QrcodeOutlined, @@ -48,6 +51,7 @@ 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'; const { Text, Title } = Typography; const { TextArea } = Input; @@ -97,6 +101,8 @@ export default function WeiboAccountsPage() { const [loading, setLoading] = useState(false); const [refreshingId, setRefreshingId] = useState(null); const [mutatingId, setMutatingId] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [batchBusy, setBatchBusy] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importing, setImporting] = useState(false); @@ -111,6 +117,8 @@ export default function WeiboAccountsPage() { const [qrAccount, setQrAccount] = useState(null); const [qrLoading, setQrLoading] = useState(false); const [qrResult, setQrResult] = useState(null); + const [qrChannel, setQrChannel] = useState<'wx' | 'qq'>('wx'); + const [qrError, setQrError] = useState(''); const [featureOpen, setFeatureOpen] = useState(false); const [featureAccount, setFeatureAccount] = useState(null); @@ -148,6 +156,9 @@ export default function WeiboAccountsPage() { }); setItems(data.items); setTotal(data.total); + setSelectedIds((current) => + current.filter((id) => data.items.some((item) => item.id === id)), + ); } catch (error) { message.error(getErrorMessage(error)); } finally { @@ -205,27 +216,63 @@ export default function WeiboAccountsPage() { } }; - const openQr = async (account: WeiboAccountItem) => { + const openQr = (account: WeiboAccountItem) => { if (!account.is_enabled) { message.warning('账号已停用,请先启用'); return; } setQrAccount(account); setQrResult(null); + setQrChannel('wx'); + setQrError(''); setQrOpen(true); + }; + + const generateQrCode = async () => { + if (!qrAccount) return; setQrLoading(true); + setQrError(''); try { - const result = await weiboApi.bindQrcodes(account.id); + const result = await weiboApi.bindQrcodes(qrAccount.id, qrChannel); setQrResult(result); } catch (error) { - message.error(getErrorMessage(error)); - setQrOpen(false); - void loadAccounts(); + setQrResult(null); + setQrError(getErrorMessage(error)); } finally { setQrLoading(false); } }; + const handleQrChannelChange = (value: 'wx' | 'qq') => { + setQrChannel(value); + setQrResult(null); + setQrError(''); + }; + + const copyQrScheme = async () => { + const scheme = qrResult?.schemes.find((item) => item.channel === qrChannel); + if (!scheme?.scheme) return; + try { + await copyTextToClipboard(scheme.scheme); + message.success(`${scheme.label} Scheme 已复制`); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const downloadQrImage = () => { + const image = qrResult?.schemes.find( + (item) => item.channel === qrChannel, + )?.qrcode_image; + if (!image) return; + const link = document.createElement('a'); + link.href = image; + link.download = `weibo-bind-${qrChannel}.${image.includes('image/png') ? 'png' : 'jpg'}`; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + const handleDelete = async (account: WeiboAccountItem) => { try { await weiboApi.deleteAccount(account.id); @@ -298,6 +345,81 @@ export default function WeiboAccountsPage() { } }; + const batchRun = async ( + action: (account: WeiboAccountItem) => Promise, + successText: string, + accountsOverride?: WeiboAccountItem[], + ) => { + const selected = + accountsOverride ?? items.filter((item) => selectedIds.includes(item.id)); + if (selected.length === 0) { + message.warning('请先勾选账号'); + return; + } + setBatchBusy(true); + let success = 0; + const errors: string[] = []; + for (const account of selected) { + try { + await action(account); + success += 1; + } catch (error) { + errors.push(`${account.nickname || account.uid}:${getErrorMessage(error)}`); + } + } + setBatchBusy(false); + setSelectedIds([]); + await loadAccounts(); + if (errors.length === 0) { + message.success(`${successText} ${success} 个账号`); + } else { + message.warning( + `${successText} ${success} 个,失败 ${errors.length} 个:${errors.slice(0, 3).join(';')}`, + ); + } + }; + + const handleBatchRefresh = async () => { + const selected = items.filter( + (item) => selectedIds.includes(item.id) && item.is_enabled, + ); + if (selected.length !== selectedIds.length) { + message.info('已自动跳过停用账号'); + } + await batchRun( + async (account) => weiboApi.refreshAccount(account.id), + '刷新完成', + selected, + ); + }; + + const handleBatchDisable = async () => { + await batchRun( + async (account) => { + if (!account.is_enabled) return; + await weiboApi.disableAccount(account.id); + }, + '停用完成', + ); + }; + + const handleBatchEnable = async () => { + await batchRun( + async (account) => { + if (account.is_enabled) return; + await weiboApi.enableAccount(account.id); + }, + '启用完成', + ); + }; + + const handleBatchDelete = async () => { + await batchRun( + async (account) => weiboApi.deleteAccount(account.id), + '删除完成', + ); + }; + const loadMall = async (account: WeiboAccountItem) => { setMallLoading(true); try { @@ -433,6 +555,14 @@ export default function WeiboAccountsPage() { }; const columns: TableProps['columns'] = [ + { + title: '序号', + key: 'index', + width: 72, + align: 'center', + fixed: 'left', + render: (_, __, index) => (page - 1) * pageSize + index + 1, + }, { title: '微博账号', key: 'account', @@ -752,6 +882,9 @@ export default function WeiboAccountsPage() { { title: '过期时间', dataIndex: 'expire_time', width: 170 }, { title: '订单号', dataIndex: 'source_id' }, ]; + const activeQrScheme = qrResult?.schemes.find( + (item) => item.channel === qrChannel, + ); return ( @@ -826,6 +959,81 @@ export default function WeiboAccountsPage() { + {selectedIds.length > 0 && ( + + 已选 {selectedIds.length} 个账号 + + {canManage && ( + void handleBatchDisable()} + > + + + )} + {canManage && ( + + )} + {canDelete && ( + void handleBatchDelete()} + > + + + )} + + + )} +
setSelectedIds(keys.map(Number)), + }} + scroll={{ x: 1450 }} pagination={{ current: page, pageSize, @@ -930,51 +1142,103 @@ export default function WeiboAccountsPage() { title={qrAccount ? `绑定二维码 · ${qrAccount.nickname || qrAccount.uid}` : '绑定二维码'} open={qrOpen} footer={null} - width={620} + width={420} onCancel={() => setQrOpen(false)} destroyOnHidden > - {qrLoading ? ( -
- + +
+ + handleQrChannelChange(value as 'wx' | 'qq') + } + options={[ + { value: 'wx', label: '微信' }, + { value: 'qq', label: 'QQ' }, + ]} + />
- ) : qrResult ? ( - - {qrResult.schemes.map((scheme) => ( -
- {scheme.label} -
+ + {activeQrScheme?.label || (qrChannel === 'wx' ? '微信' : 'QQ')} + +
+ {qrLoading ? ( + + ) : activeQrScheme?.qrcode_image ? ( + {`${activeQrScheme.label}绑定二维码`} + ) : qrError || activeQrScheme?.error ? ( + + ) : ( +
-
- ))} - - ) : null} + 生成二维码 + + )} +
+ {(qrError || activeQrScheme?.error) && + !activeQrScheme?.qrcode_image && ( + + )} + {activeQrScheme?.qrcode_image && ( + + + {activeQrScheme.scheme && ( + + )} + + )} +
+