feat(weibo): 修正绑定小程序码并增加账号批量操作
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 ""),
|
||||
)
|
||||
|
||||
@@ -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?<inner_query>` 是 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: <inner_query.livePlatId>
|
||||
Gap-Act-Id: <inner_query.actId>
|
||||
Gap-User-From: pc
|
||||
Vulcan-AccType: auth_in_h5
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"queryString": "<path 内层原始 Query>&sessionId=<秒级时间戳_随机串>",
|
||||
"accountType": "wx"
|
||||
}
|
||||
```
|
||||
|
||||
`accountType` 只能是 `wx` 或 `qq`。实测两个渠道返回不同的官方小程序码图片:
|
||||
|
||||
- `wx`:微信小程序码,图片右下角为微信图标。
|
||||
- `qq`:QQ 小程序码,图片右下角为 QQ 图标。
|
||||
- 响应 `jData.qrcode` 是 base64 图片,前端保存为 `data:image/...;base64,...` 后直接 `<img>` 展示。
|
||||
- 响应 `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 助威入口
|
||||
|
||||
@@ -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=""),
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -522,6 +522,8 @@ class WeiboBindSchemeOut(BaseModel):
|
||||
channel: str
|
||||
label: str
|
||||
scheme: str = ""
|
||||
qrcode_image: str = ""
|
||||
qrcode_token: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -788,6 +788,8 @@ export interface WeiboBindScheme {
|
||||
channel: 'wx' | 'qq';
|
||||
label: string;
|
||||
scheme: string;
|
||||
qrcode_image: string;
|
||||
qrcode_token: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ export const weiboApi = {
|
||||
api.post<WeiboAccountItem, WeiboAccountItem>(
|
||||
`/weibo/accounts/${accountId}/refresh`,
|
||||
),
|
||||
bindQrcodes: (accountId: number) =>
|
||||
bindQrcodes: (accountId: number, channel: 'wx' | 'qq') =>
|
||||
api.post<WeiboBindQrResult, WeiboBindQrResult>(
|
||||
`/weibo/accounts/${accountId}/bind-qrcodes`,
|
||||
undefined,
|
||||
{ timeout: 60000 },
|
||||
{ params: { channel }, timeout: 60000 },
|
||||
),
|
||||
mallList: (accountId: number) =>
|
||||
api.post<WeiboMallListResult, WeiboMallListResult>(
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [mutatingId, setMutatingId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
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<WeiboAccountItem | null>(null);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrResult, setQrResult] = useState<WeiboBindQrResult | null>(null);
|
||||
const [qrChannel, setQrChannel] = useState<'wx' | 'qq'>('wx');
|
||||
const [qrError, setQrError] = useState('');
|
||||
|
||||
const [featureOpen, setFeatureOpen] = useState(false);
|
||||
const [featureAccount, setFeatureAccount] = useState<WeiboAccountItem | null>(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<unknown>,
|
||||
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<WeiboAccountItem>['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 (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
@@ -826,6 +959,81 @@ export default function WeiboAccountsPage() {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<Space
|
||||
wrap
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
>
|
||||
<Text strong>已选 {selectedIds.length} 个账号</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={batchBusy}
|
||||
onClick={() => void handleBatchRefresh()}
|
||||
>
|
||||
批量刷新
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title={`停用选中的 ${selectedIds.length} 个账号?`}
|
||||
description="停用后不能刷新、绑定、充值或兑换。"
|
||||
okText="批量停用"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void handleBatchDisable()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={batchBusy}
|
||||
>
|
||||
批量停用
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={batchBusy}
|
||||
onClick={() => void handleBatchEnable()}
|
||||
>
|
||||
批量启用
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Popconfirm
|
||||
title={`删除选中的 ${selectedIds.length} 个账号?`}
|
||||
description="会删除本地 CK 和历史资料,无法恢复。"
|
||||
okText="批量删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void handleBatchDelete()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={batchBusy}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button size="small" onClick={() => setSelectedIds([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
@@ -839,7 +1047,11 @@ export default function WeiboAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
scroll={{ x: 1360 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => 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 ? (
|
||||
<div style={{ minHeight: 300, display: 'grid', placeItems: 'center' }}>
|
||||
<Spin />
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Segmented
|
||||
value={qrChannel}
|
||||
onChange={(value) =>
|
||||
handleQrChannelChange(value as 'wx' | 'qq')
|
||||
}
|
||||
options={[
|
||||
{ value: 'wx', label: '微信' },
|
||||
{ value: 'qq', label: 'QQ' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : qrResult ? (
|
||||
<Space size={28} align="start" wrap style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{qrResult.schemes.map((scheme) => (
|
||||
<div key={scheme.channel} style={{ width: 240, textAlign: 'center' }}>
|
||||
<Text strong>{scheme.label}</Text>
|
||||
<div
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong>
|
||||
{activeQrScheme?.label || (qrChannel === 'wx' ? '微信' : 'QQ')}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
width: 300,
|
||||
minHeight: 300,
|
||||
margin: '12px auto 0',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{qrLoading ? (
|
||||
<Spin />
|
||||
) : activeQrScheme?.qrcode_image ? (
|
||||
<img
|
||||
src={activeQrScheme.qrcode_image}
|
||||
alt={`${activeQrScheme.label}绑定二维码`}
|
||||
style={{
|
||||
height: 240,
|
||||
marginTop: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: '#fff',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
width: 260,
|
||||
height: 260,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
) : qrError || activeQrScheme?.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="生成失败"
|
||||
description={
|
||||
qrError ||
|
||||
activeQrScheme?.error ||
|
||||
'该渠道没有返回绑定二维码'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<QrcodeOutlined />}
|
||||
onClick={() => void generateQrCode()}
|
||||
>
|
||||
{scheme.scheme ? (
|
||||
<QRCode
|
||||
value={scheme.scheme}
|
||||
size={210}
|
||||
bordered={false}
|
||||
color="#000"
|
||||
bgColor="#fff"
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="生成失败"
|
||||
description={scheme.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : null}
|
||||
生成二维码
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(qrError || activeQrScheme?.error) &&
|
||||
!activeQrScheme?.qrcode_image && (
|
||||
<Button
|
||||
style={{ marginTop: 12 }}
|
||||
icon={<QrcodeOutlined />}
|
||||
onClick={() => void generateQrCode()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
{activeQrScheme?.qrcode_image && (
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadQrImage}
|
||||
>
|
||||
下载二维码
|
||||
</Button>
|
||||
{activeQrScheme.scheme && (
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => void copyQrScheme()}
|
||||
>
|
||||
复制 Scheme
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
|
||||
Reference in New Issue
Block a user