feat(weibo): 修正绑定小程序码并增加账号批量操作

This commit is contained in:
yml2213
2026-11-08 13:17:29 +08:00
parent 79cfd4a634
commit ed15701392
11 changed files with 591 additions and 54 deletions
+2
View File
@@ -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",
+81
View File
@@ -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 助威入口
+38
View File
@@ -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=""),
+56
View File
@@ -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(
[
+22 -5
View File
@@ -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),
)
+2
View File
@@ -522,6 +522,8 @@ class WeiboBindSchemeOut(BaseModel):
channel: str
label: str
scheme: str = ""
qrcode_image: str = ""
qrcode_token: str = ""
error: str = ""
+38
View File
@@ -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
+2
View File
@@ -788,6 +788,8 @@ export interface WeiboBindScheme {
channel: 'wx' | 'qq';
label: string;
scheme: string;
qrcode_image: string;
qrcode_token: string;
error: string;
}
+2 -2
View File
@@ -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>(
+308 -44
View File
@@ -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