feat(weibo): 修正绑定小程序码并增加账号批量操作
This commit is contained in:
@@ -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