feat(douyu): 支持账号批量撤回并刷新未分配状态

新增按勾选或标签批量撤回账号归属,撤回只处理确实已分配的账号。账号管理和分配管理增加可见时自动刷新、窗口聚焦刷新及手动刷新,解决批量登录完成后未分配账号延迟出现。
This commit is contained in:
yml2213
2026-11-06 13:25:32 +08:00
parent 4c1aff47da
commit c9fbbdb828
6 changed files with 431 additions and 35 deletions
+15 -7
View File
@@ -227,8 +227,8 @@ def _filter_accounts_query(
# 只是商城接口对它稳定失败 —— 那是下面 high_value_never 那一档。
query = query.filter(Account.overview_queried_at.is_(None))
elif hv == "high_value_never":
# 从没成功读到官方 storehome:商城接口失败 / 没配 manualID。
# 高价值三态里的「unknown」要求 checked_at 非空(查过但没结论),
# 从未形成高价值结论:商城接口失败 / 没配 manualID / 新账号尚未查询。
# 高价值三态里的「unknown」要求 checked_at 非空(形成过 unknown 结论),
# 所以这一档必须单独筛,否则这批账号在界面上**无处可寻**。
query = query.filter(Account.high_value_checked_at.is_(None))
elif hv == "high_value_unknown":
@@ -819,7 +819,7 @@ def accounts_summary(
"can_bairimeng": int(hv_row[1] or 0) if hv_row else 0,
"can_mengtu": int(hv_row[2] or 0) if hv_row else 0,
"unqueried": int(hv_row[3] or 0) if hv_row else 0,
# 从没成功读到官方商城结果的账号数(与「查过但没结论」分开)。
# 从未形成高价值结论的账号数(与「形成过 unknown/过期结论」分开)。
"high_value_never": int(hv_row[4] or 0) if hv_row else 0,
"high_value_unknown": int(hv_row[5] or 0) if hv_row else 0,
"high_points": int(hv_row[6] or 0) if hv_row else 0,
@@ -1082,14 +1082,22 @@ def batch_assign_accounts(
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
)
# 先取出账号对象(批量时间线要逐个写事件,光有 count 不够)
target_accounts = db.query(Account).filter(Account.id.in_(ids)).all()
# 先取出账号对象(批量时间线要逐个写事件,光有 count 不够)。
# 「撤回」只处理当前确实有归属的账号;显式勾选里混入未分配账号时,
# 不应把它们算进撤回数量,也不该写一条假的「取消分配」时间线。
target_query = db.query(Account).filter(Account.id.in_(ids))
if req.assigned_to is None:
target_query = target_query.filter(Account.assigned_to.isnot(None))
target_accounts = target_query.all()
target_ids = [account.id for account in target_accounts]
if not target_ids:
raise HTTPException(status_code=400, detail="所选账号均未分配,无需撤回")
count = (
db.query(Account)
.filter(Account.id.in_(ids))
.filter(Account.id.in_(target_ids))
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).update(
db.query(DouyuTask).filter(DouyuTask.account_id.in_(target_ids)).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
target_user = (
+152 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Table, Alert, Space, Button, App, type MenuProps } from 'antd';
import { message } from '../utils/antdMessage';
@@ -34,6 +34,8 @@ import {
BatchImportModal,
BatchTagModal,
BatchAssignModal,
BatchUnassignModal,
type BatchUnassignMode,
ExchangeRecordsModal,
} from './accounts/components/AccountModals';
@@ -80,8 +82,17 @@ export default function AccountsPage() {
const [assignModalOpen, setAssignModalOpen] = useState(false);
const [assignTargetId, setAssignTargetId] = useState<number | undefined>(undefined);
const [assigning, setAssigning] = useState(false);
const [unassignModalOpen, setUnassignModalOpen] = useState(false);
const [unassignMode, setUnassignMode] = useState<BatchUnassignMode>('selected');
const [unassignTag, setUnassignTag] = useState('');
const [unassignTagCount, setUnassignTagCount] = useState<number | null>(null);
const [unassignTagCountLoading, setUnassignTagCountLoading] = useState(false);
const [unassigning, setUnassigning] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [searchText, setSearchText] = useState(() => initialFilters.searchText || '');
const [exporting, setExporting] = useState(false);
const accountRequestRef = useRef(0);
const unassignTagCountRequestRef = useRef(0);
// 按账号名批量选号:粘贴名单 -> 匹配 ID -> 自动勾选 -> 批量改标签
const [accountNameText, setAccountNameText] = useState('');
@@ -191,8 +202,9 @@ export default function AccountsPage() {
? Number(assignFilter.slice(5))
: undefined;
const loadAccounts = useCallback(async () => {
setLoading(true);
const loadAccounts = useCallback(async (silent = false) => {
const requestId = ++accountRequestRef.current;
if (!silent) setLoading(true);
try {
const params: Parameters<typeof accountApi.listPaged>[0] = {
page: currentPage,
@@ -222,12 +234,17 @@ export default function AccountsPage() {
}
const data = await accountApi.listPaged(params);
if (requestId !== accountRequestRef.current) return;
setAccounts(data.items);
setTotal(data.total);
} catch (e: unknown) {
message.error(getErrorMessage(e));
if (requestId === accountRequestRef.current && !silent) {
message.error(getErrorMessage(e));
}
} finally {
setLoading(false);
if (requestId === accountRequestRef.current) {
setLoading(false);
}
}
}, [
canViewFull,
@@ -290,11 +307,77 @@ export default function AccountsPage() {
loadAccounts();
}, [loadAccounts]);
useEffect(() => {
const refreshVisibleData = () => {
if (document.visibilityState !== 'visible') return;
void loadAccounts(true);
void loadSummary();
if (canAssign) void loadAssignUsers();
};
const timer = window.setInterval(refreshVisibleData, 15000);
const handleVisibility = () => {
if (document.visibilityState === 'visible') refreshVisibleData();
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', refreshVisibleData);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', refreshVisibleData);
};
}, [canAssign, loadAccounts, loadAssignUsers, loadSummary]);
useEffect(() => {
loadTags();
loadSummary();
}, [loadTags, loadSummary]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await Promise.allSettled([
loadAccounts(),
loadSummary(),
loadTags(),
...(canAssign ? [loadAssignUsers()] : []),
]);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
if (!unassignModalOpen || unassignMode !== 'tag' || !unassignTag) {
unassignTagCountRequestRef.current += 1;
setUnassignTagCount(null);
setUnassignTagCountLoading(false);
return;
}
const requestId = ++unassignTagCountRequestRef.current;
setUnassignTagCountLoading(true);
setUnassignTagCount(null);
accountApi
.listIds({
assign_status: 'assigned',
tag: unassignTag,
})
.then((result) => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCount(result.total);
}
})
.catch(() => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCount(null);
}
})
.finally(() => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCountLoading(false);
}
});
}, [unassignMode, unassignModalOpen, unassignTag]);
const selectedCount = selectedAllMatching ? total : selectedRowKeys.length;
const selectionPayload = useMemo(
() => ({
@@ -630,6 +713,42 @@ export default function AccountsPage() {
}
};
const handleBatchUnassign = async () => {
if (unassignMode === 'selected' && selectedCount === 0) {
message.warning('请先勾选账号,或先选择要撤回的标签');
return;
}
if (unassignMode === 'tag' && !unassignTag.trim()) {
message.warning('请选择要撤回的账号标签');
return;
}
setUnassigning(true);
try {
const result =
unassignMode === 'tag'
? await accountApi.batchAssignSelection({
all_matching: true,
assign_status: 'assigned',
tag: unassignTag,
assigned_to: null,
})
: await accountApi.batchAssignSelection({
...selectionPayload,
assigned_to: null,
});
message.success(result.message);
setUnassignModalOpen(false);
setUnassignTag('');
clearSelection();
await Promise.all([loadAccounts(), loadSummary()]);
if (canAssign) await loadAssignUsers();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setUnassigning(false);
}
};
const handleSetTag = async (accountId: number, tag: string) => {
try {
await accountApi.setTag(accountId, tag);
@@ -755,6 +874,8 @@ export default function AccountsPage() {
const columns = useMemo(
() =>
// 这些操作回调只会在点击/提交时读取请求序号 ref,渲染阶段不会访问。
// eslint-disable-next-line react-hooks/refs
buildAccountColumns({
navigate,
sortKey,
@@ -825,6 +946,11 @@ export default function AccountsPage() {
setAssignTargetId(undefined);
setAssignModalOpen(true);
}}
onOpenBatchUnassign={() => {
setUnassignMode(selectedCount > 0 ? 'selected' : 'tag');
setUnassignTag(tagFilter || '');
setUnassignModalOpen(true);
}}
canDelete={canDelete}
selectedAllMatching={selectedAllMatching}
onBatchDelete={handleBatchDelete}
@@ -836,6 +962,8 @@ export default function AccountsPage() {
setImportTag('');
setImportOpen(true);
}}
onRefresh={handleRefresh}
refreshing={refreshing}
setCurrentPage={setCurrentPage}
/>
@@ -1021,6 +1149,25 @@ export default function AccountsPage() {
onBatchAssign={handleBatchAssign}
/>
<BatchUnassignModal
open={unassignModalOpen}
onCancel={() => {
setUnassignModalOpen(false);
setUnassignTag('');
}}
onConfirm={handleBatchUnassign}
unassigning={unassigning}
selectedCount={selectedCount}
selectedAllMatching={selectedAllMatching}
mode={unassignMode}
setMode={setUnassignMode}
tag={unassignTag}
setTag={setUnassignTag}
tags={tags}
tagCount={unassignTagCount}
tagCountLoading={unassignTagCountLoading}
/>
<ExchangeRecordsModal
exAccount={exAccount}
exLoading={exLoading}
+63 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import {
Card, Table, Button, Select, Input, Tag, Row, Col, Statistic,
Space, Tabs, Typography, Badge, theme, Tooltip,
@@ -6,7 +6,7 @@ import {
import { message } from '../utils/antdMessage';
import {
UserOutlined, CheckCircleOutlined, TeamOutlined,
SwapOutlined, ClearOutlined, UsergroupAddOutlined,
SwapOutlined, ClearOutlined, UsergroupAddOutlined, ReloadOutlined,
} from '@ant-design/icons';
import {
accountApi,
@@ -36,6 +36,9 @@ export default function AssignmentsPage() {
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
const [assigning, setAssigning] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const summaryRequestRef = useRef(0);
const pagedRequestRef = useRef(0);
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('assignment_page_size');
return v ? Number(v) || 15 : 15;
@@ -43,14 +46,18 @@ export default function AssignmentsPage() {
const [currentPage, setCurrentPage] = useState(1);
// 加载分配概览统计
const loadSummary = useCallback(async () => {
const loadSummary = useCallback(async (silent = false) => {
const requestId = ++summaryRequestRef.current;
try {
const data = await accountApi.assignmentsSummary();
if (requestId !== summaryRequestRef.current) return [];
setSummary(data);
setSupportUsers(data.support_users);
return data.support_users;
} catch (e: unknown) {
message.error(getErrorMessage(e));
if (requestId === summaryRequestRef.current && !silent) {
message.error(getErrorMessage(e));
}
return [];
}
}, []);
@@ -66,8 +73,9 @@ export default function AssignmentsPage() {
}, []);
// 分页按需加载账号(服务端过滤与分页)
const loadPagedAccounts = useCallback(async () => {
setTableLoading(true);
const loadPagedAccounts = useCallback(async (silent = false) => {
const requestId = ++pagedRequestRef.current;
if (!silent) setTableLoading(true);
try {
const params: Parameters<typeof accountApi.listPaged>[0] = {
page: currentPage,
@@ -87,12 +95,17 @@ export default function AssignmentsPage() {
}
const data = await accountApi.listPaged(params);
if (requestId !== pagedRequestRef.current) return;
setPagedAccounts(data.items);
setTotalCount(data.total);
} catch (e: unknown) {
message.error(getErrorMessage(e));
if (requestId === pagedRequestRef.current && !silent) {
message.error(getErrorMessage(e));
}
} finally {
setTableLoading(false);
if (requestId === pagedRequestRef.current) {
setTableLoading(false);
}
}
}, [activeTab, selectedUser, currentPage, pageSize, searchText, filterTag]);
@@ -115,6 +128,34 @@ export default function AssignmentsPage() {
loadPagedAccounts();
}, [loadPagedAccounts]);
useEffect(() => {
const refreshVisibleData = () => {
if (document.visibilityState !== 'visible') return;
void loadSummary(true);
void loadPagedAccounts(true);
};
const timer = window.setInterval(refreshVisibleData, 15000);
const handleVisibility = () => {
if (document.visibilityState === 'visible') refreshVisibleData();
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', refreshVisibleData);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', refreshVisibleData);
};
}, [loadPagedAccounts, loadSummary]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await Promise.all([loadSummary(), loadPagedAccounts()]);
} finally {
setRefreshing(false);
}
};
// 切换客服
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
@@ -249,7 +290,12 @@ export default function AssignmentsPage() {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<h2 style={{ margin: 0, flexShrink: 0 }}>分配管理</h2>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}>分配管理</h2>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
刷新
</Button>
</div>
{/* 统计卡片 */}
<Row gutter={12} style={{ flexShrink: 0 }}>
@@ -270,12 +316,14 @@ export default function AssignmentsPage() {
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="未分配"
value={unassignedCount}
styles={{ content: { color: token.colorError } }}
prefix={<SwapOutlined />}
/>
<Tooltip title="仅统计持 CK 且 CK 未判失效、未停用/归档的账号。批量登录成功写入 CK 后会自动进入;本页每 15 秒刷新一次。">
<Statistic
title="未分配"
value={unassignedCount}
styles={{ content: { color: token.colorError } }}
prefix={<SwapOutlined />}
/>
</Tooltip>
</Card>
</Col>
<Col span={6}>
@@ -1,4 +1,15 @@
import { Modal, Button, Input, Select, Typography, Table, Spin, Space } from 'antd';
import {
Modal,
Alert,
Button,
Input,
Select,
Typography,
Table,
Spin,
Space,
Radio,
} from 'antd';
import type { AccountItem, AccountExchangeRecords, SupportUserItem } from '../../../api/modules';
import { EXCHANGE_SCOPES, EXCHANGE_COLUMNS } from '../types';
@@ -116,6 +127,115 @@ export interface BatchAssignModalProps {
onBatchAssign: (target: number | null) => void;
}
export type BatchUnassignMode = 'selected' | 'tag';
export interface BatchUnassignModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
unassigning: boolean;
selectedCount: number;
selectedAllMatching: boolean;
mode: BatchUnassignMode;
setMode: (mode: BatchUnassignMode) => void;
tag: string;
setTag: (tag: string) => void;
tags: string[];
tagCount: number | null;
tagCountLoading: boolean;
}
export function BatchUnassignModal({
open,
onCancel,
onConfirm,
unassigning,
selectedCount,
selectedAllMatching,
mode,
setMode,
tag,
setTag,
tags,
tagCount,
tagCountLoading,
}: BatchUnassignModalProps) {
const noSelection = selectedCount === 0;
const noTag = !tag.trim();
const noTagMatch = mode === 'tag' && tagCount === 0;
const description = selectedAllMatching
? `当前筛选下全部 ${selectedCount} 个账号`
: `已勾选的 ${selectedCount} 个账号`;
return (
<Modal
title="批量撤回已分配账号"
open={open}
onCancel={onCancel}
confirmLoading={unassigning}
okText="确认撤回"
okButtonProps={{
danger: true,
disabled: (mode === 'selected' && noSelection) || (mode === 'tag' && (noTag || noTagMatch)),
}}
onOk={onConfirm}
width={480}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="撤回只会清空账号归属,不会删除账号或 CK。"
description="撤回后的账号会回到「未分配」。未通过登录、没有 CK 的账号不会出现在分配管理页的未分配池中。"
/>
<Radio.Group
value={mode}
onChange={(event) => setMode(event.target.value as BatchUnassignMode)}
style={{ display: 'flex', gap: 20 }}
>
<Radio value="selected" disabled={noSelection}>
勾选/当前筛选账号
</Radio>
<Radio value="tag" disabled={tags.length === 0}>
按标签撤回
</Radio>
</Radio.Group>
{mode === 'selected' ? (
<Text>
{noSelection ? '请先勾选账号,或先点「全选当前筛选」。' : `将撤回${description}的分配归属。`}
</Text>
) : (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Select
showSearch
allowClear
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要撤回的账号标签"
value={tag || undefined}
onChange={(value) => setTag(value || '')}
options={tags.map((item) => ({ value: item, label: item }))}
/>
<Text type="secondary">
{!tag
? '选择标签后会统计该标签下当前仍处于已分配状态的账号。'
: tagCountLoading
? '正在统计该标签下的已分配账号…'
: tagCount === null
? '暂时无法统计,仍可提交后由后端确认。'
: tagCount === 0
? `标签「${tag}」下没有已分配账号。`
: `标签「${tag}」下共有 ${tagCount} 个已分配账号,将全部撤回。`}
</Text>
</Space>
)}
</Space>
</Modal>
);
}
export function BatchAssignModal({
open,
onCancel,
@@ -12,6 +12,8 @@ import {
ThunderboltOutlined,
ImportOutlined,
ClearOutlined,
ReloadOutlined,
UndoOutlined,
} from '@ant-design/icons';
import {
CK_OPTIONS,
@@ -46,6 +48,7 @@ export interface AccountToolbarProps {
onOpenBatchTag: () => void;
canAssign: boolean;
onOpenBatchAssign: () => void;
onOpenBatchUnassign: () => void;
canDelete: boolean;
selectedAllMatching: boolean;
onBatchDelete: () => void;
@@ -54,6 +57,8 @@ export interface AccountToolbarProps {
onBatchOverview: () => void;
canImport: boolean;
onOpenImport: () => void;
onRefresh: () => void;
refreshing: boolean;
setCurrentPage: (page: number) => void;
}
@@ -84,6 +89,7 @@ export function AccountToolbar({
onOpenBatchTag,
canAssign,
onOpenBatchAssign,
onOpenBatchUnassign,
canDelete,
selectedAllMatching,
onBatchDelete,
@@ -92,6 +98,8 @@ export function AccountToolbar({
onBatchOverview,
canImport,
onOpenImport,
onRefresh,
refreshing,
setCurrentPage,
}: AccountToolbarProps) {
return (
@@ -205,6 +213,9 @@ export function AccountToolbar({
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
</Button>
)}
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onRefresh}>
刷新
</Button>
{canTag && (
<Button
disabled={selectedCount === 0}
@@ -215,13 +226,23 @@ export function AccountToolbar({
</Button>
)}
{canAssign && (
<Button
disabled={selectedCount === 0}
icon={<UsergroupAddOutlined />}
onClick={onOpenBatchAssign}
>
批量分配
</Button>
<>
<Button
disabled={selectedCount === 0}
icon={<UsergroupAddOutlined />}
onClick={onOpenBatchAssign}
>
批量分配
</Button>
<Button
disabled={selectedCount === 0 && tags.length === 0}
icon={<UndoOutlined />}
onClick={onOpenBatchUnassign}
title="可撤回勾选账号,也可按标签撤回全部已分配账号"
>
批量撤回
</Button>
</>
)}
{canDelete && (
<Popconfirm