diff --git a/tests/test_account_assign_filter.py b/tests/test_account_assign_filter.py index bee9668..bb44ce4 100644 --- a/tests/test_account_assign_filter.py +++ b/tests/test_account_assign_filter.py @@ -213,6 +213,38 @@ class TestAllMatchingAssign(_Base): self.session.refresh(account) assert account.assigned_to is None + def test_unassign_all_matching_by_tag_only_touches_assigned_tag(self): + """按标签撤回:只动该标签下当前已分配的账号,未分配和别的标签都保留。""" + assigned_a = self.make_account( + "mut-a-assigned", tag="撤回标签", assigned_to=self.support.id + ) + assigned_a_2 = self.make_account( + "mut-a-assigned-2", tag="撤回标签", assigned_to=self.other.id + ) + unassigned_a = self.make_account("mut-a-free", tag="撤回标签") + assigned_b = self.make_account( + "mut-b-assigned", tag="保留标签", assigned_to=self.support.id + ) + + result = batch_assign_accounts( + BatchAssign( + all_matching=True, + assign_status="assigned", + tag="撤回标签", + assigned_to=None, + ), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 2 + for account in (assigned_a, assigned_a_2, unassigned_a, assigned_b): + self.session.refresh(account) + assert assigned_a.assigned_to is None + assert assigned_a_2.assigned_to is None + assert unassigned_a.assigned_to is None + assert assigned_b.assigned_to == self.support.id + def test_explicit_ids_still_work(self): """旧路径(显式勾选)不能被 all_matching 改动带坏。""" a = self.make_account("me-a") @@ -230,6 +262,26 @@ class TestAllMatchingAssign(_Base): assert a.assigned_to == self.support.id assert b.assigned_to is None + def test_explicit_unassign_counts_only_assigned_accounts(self): + """显式勾选混合未分配账号时,撤回只处理有归属的那些并返回真实数量。""" + assigned = self.make_account("mul-assigned", assigned_to=self.support.id) + unassigned = self.make_account("mul-free") + + result = batch_assign_accounts( + BatchAssign( + account_ids=[assigned.id, unassigned.id], + assigned_to=None, + ), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 1 + self.session.refresh(assigned) + self.session.refresh(unassigned) + assert assigned.assigned_to is None + assert unassigned.assigned_to is None + def test_all_matching_without_match_is_rejected(self): import pytest from fastapi import HTTPException diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9bc2435..ed704c6 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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 = ( diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 17f933b..92afb18 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -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(undefined); const [assigning, setAssigning] = useState(false); + const [unassignModalOpen, setUnassignModalOpen] = useState(false); + const [unassignMode, setUnassignMode] = useState('selected'); + const [unassignTag, setUnassignTag] = useState(''); + const [unassignTagCount, setUnassignTagCount] = useState(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[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} /> + { + 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} + /> + (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[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 (
-

分配管理

+
+

分配管理

+ +
{/* 统计卡片 */} @@ -270,12 +316,14 @@ export default function AssignmentsPage() { - } - /> + + } + /> + diff --git a/web/frontend/src/pages/accounts/components/AccountModals.tsx b/web/frontend/src/pages/accounts/components/AccountModals.tsx index 982fd53..b5b6c4e 100644 --- a/web/frontend/src/pages/accounts/components/AccountModals.tsx +++ b/web/frontend/src/pages/accounts/components/AccountModals.tsx @@ -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 ( + + + + + setMode(event.target.value as BatchUnassignMode)} + style={{ display: 'flex', gap: 20 }} + > + + 勾选/当前筛选账号 + + + 按标签撤回 + + + + {mode === 'selected' ? ( + + {noSelection ? '请先勾选账号,或先点「全选当前筛选」。' : `将撤回${description}的分配归属。`} + + ) : ( + +