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
+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