From f8ad6e1777a7439b73ffbf9f38e188da0ff45beb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 8 Oct 2026 11:08:41 +0800 Subject: [PATCH] =?UTF-8?q?perf(assignments):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=86=E9=85=8D=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=80=A7=E8=83=BD=E4=B8=8E=E8=A7=92=E6=A0=87=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端改造为服务端分页查询 (accountApi.listPaged),避免一次性拉取 1.6 万+ 账号导致页面卡死 - 优化后端 assignments_summary 聚合查询,消除大子查询临时表 - 修复 Badge 默认 99+ 限制,设置 overflowCount 支持展示完整账号数量 - 修复测试用例 test_business_events.py 当前时间跨天断言问题 --- tests/test_business_events.py | 9 +- web/backend/routers/accounts.py | 32 +- web/frontend/src/api/types.ts | 2 + web/frontend/src/pages/AssignmentsPage.tsx | 286 ++++++++++++------ .../src/pages/HuyaAssignmentsPage.tsx | 2 +- 5 files changed, 216 insertions(+), 115 deletions(-) diff --git a/tests/test_business_events.py b/tests/test_business_events.py index dc31564..79da8a0 100644 --- a/tests/test_business_events.py +++ b/tests/test_business_events.py @@ -454,8 +454,9 @@ class TestPointsExpireJob(_Base): def test_expired_points_are_recorded_once(self): from datetime import timedelta + now = datetime.now(UTC) self.account.points = 500 - self.account.points_expire_at = NOW - timedelta(days=1) + self.account.points_expire_at = now - timedelta(days=1) self.session.commit() out = self._run() @@ -472,8 +473,9 @@ class TestPointsExpireJob(_Base): def test_not_yet_expired_is_left_alone(self): from datetime import timedelta + now = datetime.now(UTC) self.account.points = 500 - self.account.points_expire_at = NOW + timedelta(days=3) + self.account.points_expire_at = now + timedelta(days=3) self.session.commit() assert self._run()["written"] == 0 @@ -484,8 +486,9 @@ class TestPointsExpireJob(_Base): """积分已经为 0 就没有东西可到期 —— 写一条 -0 只是噪声。""" from datetime import timedelta + now = datetime.now(UTC) self.account.points = 0 - self.account.points_expire_at = NOW - timedelta(days=1) + self.account.points_expire_at = now - timedelta(days=1) self.session.commit() out = self._run() diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 34e9441..3a5c0f6 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -124,7 +124,7 @@ def _filter_accounts_query( ): """复用列表筛选条件,供分页列表和全量批量操作保持一致。""" if _query_bool(has_cookie): - query = query.filter(Account.id.in_(cookie_account_ids_query(db))) + query = query.filter(Account.cookie.isnot(None), Account.cookie != "") if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"): query = query.filter(Account.assigned_to.isnot(None)) # 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、 @@ -1009,30 +1009,40 @@ def assignments_summary( current: User = Depends(require_permission("account:assign")), ): """分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。""" - cookie_subq = select(Account.id).where(cookie_holding_filter()) - cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery() - results = ( - db.query( - User.id, User.username, func.count(cookie_accounts.c.id).label("count") + db.query(User.id, User.username, func.count(Account.id).label("count")) + .outerjoin( + Account, + and_( + Account.assigned_to == User.id, + Account.cookie.isnot(None), + Account.cookie != "", + ), ) - .outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id) .filter(User.role == "support") .group_by(User.id, User.username) - .order_by(func.count(cookie_accounts.c.id).desc()) + .order_by(func.count(Account.id).desc()) .all() ) total_unassigned = ( - db.query(func.count(cookie_accounts.c.id)) - .filter(cookie_accounts.c.assigned_to.is_(None)) + db.query(func.count(Account.id)) + .filter( + Account.assigned_to.is_(None), + Account.cookie.isnot(None), + Account.cookie != "", + ) .scalar() ) or 0 + total_assigned = sum(int(cnt or 0) for _, _, cnt in results) + total_accounts = total_assigned + total_unassigned return { "support_users": [ - {"id": uid, "username": uname, "assigned_count": cnt} + {"id": uid, "username": uname, "assigned_count": int(cnt or 0)} for uid, uname, cnt in results ], "unassigned_count": total_unassigned, + "assigned_count": total_assigned, + "total_count": total_accounts, } diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index e2dedb2..a9a87e0 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -335,6 +335,8 @@ export interface AccountItem { export interface AssignmentsSummary { support_users: SupportUserItem[]; unassigned_count: number; + assigned_count?: number; + total_count?: number; } export interface SupportUserItem { diff --git a/web/frontend/src/pages/AssignmentsPage.tsx b/web/frontend/src/pages/AssignmentsPage.tsx index 03d7e8d..02980d5 100644 --- a/web/frontend/src/pages/AssignmentsPage.tsx +++ b/web/frontend/src/pages/AssignmentsPage.tsx @@ -1,14 +1,19 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Card, Table, Button, Select, Input, Tag, Row, Col, Statistic, - Space, Tabs, Typography, Badge, theme, + Space, Tabs, Typography, Badge, theme, Tooltip, } from 'antd'; import { message } from '../utils/antdMessage'; import { UserOutlined, CheckCircleOutlined, TeamOutlined, SwapOutlined, ClearOutlined, UsergroupAddOutlined, } from '@ant-design/icons'; -import { accountApi, type AccountItem, type SupportUserItem } from '../api/modules'; +import { + accountApi, + type AccountItem, + type SupportUserItem, + type AssignmentsSummary, +} from '../api/modules'; import { getErrorMessage } from '../utils/error'; const { Text } = Typography; @@ -17,13 +22,19 @@ const STORAGE_KEY_SELECTED_USER = 'assignments_selected_user_id'; export default function AssignmentsPage() { const { token } = theme.useToken(); - const [accounts, setAccounts] = useState([]); + const [summary, setSummary] = useState(null); const [supportUsers, setSupportUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); + const [allTags, setAllTags] = useState([]); + + // 表格分页与筛选状态 + const [pagedAccounts, setPagedAccounts] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [tableLoading, setTableLoading] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [searchText, setSearchText] = useState(''); const [filterTag, setFilterTag] = useState(undefined); - const [activeTab, setActiveTab] = useState('unassigned'); + const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned'); const [assigning, setAssigning] = useState(false); const [pageSize, setPageSize] = useState(() => { const v = localStorage.getItem('assignment_page_size'); @@ -31,83 +42,86 @@ export default function AssignmentsPage() { }); const [currentPage, setCurrentPage] = useState(1); - // 加载分配概览 - const loadSummary = async () => { + // 加载分配概览统计 + const loadSummary = useCallback(async () => { try { const data = await accountApi.assignmentsSummary(); + setSummary(data); setSupportUsers(data.support_users); return data.support_users; } catch (e: unknown) { message.error(getErrorMessage(e)); return []; } - }; + }, []); - // 加载账号(仅已成功登录过的) - const loadAccounts = async () => { + // 加载标签列表 + const loadTags = useCallback(async () => { try { - const all = await accountApi.list({ has_cookie: true, include_sensitive: false }); - setAccounts(all); + const tags = await accountApi.listTags(); + setAllTags(tags); + } catch { + // 忽略标签加载异常 + } + }, []); + + // 分页按需加载账号(服务端过滤与分页) + const loadPagedAccounts = useCallback(async () => { + setTableLoading(true); + try { + const params: Parameters[0] = { + page: currentPage, + page_size: pageSize, + has_cookie: true, + include_sensitive: false, + search: searchText.trim() || undefined, + tag: filterTag || undefined, + }; + + if (activeTab === 'unassigned') { + params.assign_status = 'unassigned'; + } else if (selectedUser) { + params.assigned_user_id = selectedUser.id; + } + + const data = await accountApi.listPaged(params); + setPagedAccounts(data.items); + setTotalCount(data.total); } catch (e: unknown) { message.error(getErrorMessage(e)); + } finally { + setTableLoading(false); } - }; + }, [activeTab, selectedUser, currentPage, pageSize, searchText, filterTag]); - // 初始化加载 + 自动选中客服 + // 初始化加载 useEffect(() => { const init = async () => { - loadAccounts(); + loadTags(); const users = await loadSummary(); if (users.length > 0) { const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER); - const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null; + const savedUser = savedId ? users.find((u) => u.id === Number(savedId)) : null; setSelectedUser(savedUser || users[0]); } }; init(); - }, []); + }, [loadSummary, loadTags]); - // 标签列表 - const allTags = useMemo(() => { - const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))]; - return tags.sort(); - }, [accounts]); - - // 未分配账号 - const unassignedAccounts = useMemo(() => { - return accounts.filter((a) => !a.assigned_to); - }, [accounts]); - - // 当前选中客服的已分配账号 - const assignedAccounts = useMemo(() => { - if (!selectedUser) return []; - return accounts.filter((a) => a.assigned_to === selectedUser.id); - }, [accounts, selectedUser]); - - // 当前显示的账号集合 - const displayAccounts = useMemo(() => { - let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts; - if (searchText) { - const s = searchText.toLowerCase(); - list = list.filter((a) => a.username.toLowerCase().includes(s)); - } - if (filterTag) { - list = list.filter((a) => (a.tag || '').trim() === filterTag); - } - return list; - }, [activeTab, unassignedAccounts, assignedAccounts, searchText, filterTag]); + // 当筛选或选中客服变化时触发分页请求 + useEffect(() => { + loadPagedAccounts(); + }, [loadPagedAccounts]); // 切换客服 const handleSelectUser = (user: SupportUserItem) => { setSelectedUser(user); setSelectedRowKeys([]); - setActiveTab('unassigned'); - setSearchText(''); - setFilterTag(undefined); + setCurrentPage(1); localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id)); }; - // 批量分配 + // 批量分配选中账号 const handleBatchAssign = async () => { if (selectedRowKeys.length === 0) { message.warning('请先选择账号'); @@ -122,10 +136,8 @@ export default function AssignmentsPage() { await accountApi.batchAssign(selectedRowKeys, selectedUser.id); message.success(`已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`); setSelectedRowKeys([]); - await loadAccounts(); - const users = await loadSummary(); - const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id); - if (refreshed) setSelectedUser(refreshed); + await loadSummary(); + await loadPagedAccounts(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -144,10 +156,8 @@ export default function AssignmentsPage() { await accountApi.batchAssign(selectedRowKeys, null); message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`); setSelectedRowKeys([]); - await loadAccounts(); - const users = await loadSummary(); - const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser?.id); - if (refreshed) setSelectedUser(refreshed); + await loadSummary(); + await loadPagedAccounts(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -155,31 +165,29 @@ export default function AssignmentsPage() { } }; - // 一键将未分配账号全部分配给选中客服 + // 一键将当前未分配(含搜索/标签筛选)全部分配给选中客服 const handleAssignAllUnassigned = async () => { if (!selectedUser) { message.warning('请先选择客服'); return; } - const ids = unassignedAccounts - .filter((a) => { - if (searchText && !a.username.toLowerCase().includes(searchText.toLowerCase())) return false; - if (filterTag && (a.tag || '').trim() !== filterTag) return false; - return true; - }) - .map((a) => a.id); - if (ids.length === 0) { - message.info('没有未分配的账号'); + if (totalCount === 0) { + message.info('当前筛选下没有未分配的账号'); return; } setAssigning(true); try { - await accountApi.batchAssign(ids, selectedUser.id); - message.success(`已将 ${ids.length} 个账号分配给 ${selectedUser.username}`); - await loadAccounts(); - const users = await loadSummary(); - const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id); - if (refreshed) setSelectedUser(refreshed); + const res = await accountApi.batchAssignSelection({ + all_matching: true, + assign_status: 'unassigned', + tag: filterTag || undefined, + search: searchText.trim() || undefined, + assigned_to: selectedUser.id, + }); + message.success(res.message || `已全部分配给 ${selectedUser.username}`); + setSelectedRowKeys([]); + await loadSummary(); + await loadPagedAccounts(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { @@ -187,18 +195,52 @@ export default function AssignmentsPage() { } }; - // 统计 - const assignedCount = accounts.filter((a) => !!a.assigned_to).length; - const unassignedCount = accounts.length - assignedCount; + // 统计数据 + const assignedCount = summary?.assigned_count ?? 0; + const unassignedCount = summary?.unassigned_count ?? 0; + const totalAccountCount = summary?.total_count ?? (assignedCount + unassignedCount); + + // 获取当前选中客服已分配总数 + const currentSelectedUserAssignedCount = + (selectedUser ? supportUsers.find((u) => u.id === selectedUser.id)?.assigned_count : 0) ?? + selectedUser?.assigned_count ?? + 0; const columns = [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '用户名', dataIndex: 'username' }, + { title: 'ID', dataIndex: 'id', width: 70 }, + { + title: '用户名', + dataIndex: 'username', + render: (text: string) => {text}, + }, { title: '标签', dataIndex: 'tag', - width: 100, - render: (tag: string) => tag ? {tag} : -, + width: 120, + render: (tag: string) => (tag ? {tag} : -), + }, + { + title: '积分 / 鱼翅', + dataIndex: 'points', + width: 140, + render: (_: unknown, record: AccountItem) => ( + + 积分 {record.points ?? '-'} + + / 鱼翅 {record.gold_balance ?? '-'} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (status: string, record: AccountItem) => ( + + {record.status_label || status || '-'} + + ), }, ]; @@ -208,22 +250,38 @@ export default function AssignmentsPage() { {/* 统计卡片 */} - - } /> - - } /> + } /> - } /> + } + /> - } /> + } + /> + + + + + } + /> @@ -236,7 +294,7 @@ export default function AssignmentsPage() { title="客服列表" size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }} - bodyStyle={{ flex: 1, overflow: 'auto', padding: '8px' }} + styles={{ body: { flex: 1, overflow: 'auto', padding: '8px' } }} >
{supportUsers.map((user) => { @@ -267,7 +325,14 @@ export default function AssignmentsPage() { {user.username} - + + +
); })} @@ -290,7 +355,7 @@ export default function AssignmentsPage() { <> 已选中客服: - {selectedUser.username}(已分配 {selectedUser.assigned_count} 个) + {selectedUser.username}(已分配 {currentSelectedUserAssignedCount} 个) ) : ( @@ -299,15 +364,27 @@ export default function AssignmentsPage() { } style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }} - bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 }} + styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 } }} > {/* 筛选栏 */} -
+
setSearchText(e.target.value)} + onChange={(e) => { + setSearchText(e.target.value); + setCurrentPage(1); + }} style={{ width: 200 }} size="small" /> @@ -316,7 +393,10 @@ export default function AssignmentsPage() { showSearch placeholder="按标签筛选" value={filterTag} - onChange={(v) => setFilterTag(v || undefined)} + onChange={(v) => { + setFilterTag(v || undefined); + setCurrentPage(1); + }} filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.trim().toLowerCase()) } @@ -330,17 +410,21 @@ export default function AssignmentsPage() { { setActiveTab(key); setSelectedRowKeys([]); }} + onChange={(key) => { + setActiveTab(key as 'unassigned' | 'assigned'); + setSelectedRowKeys([]); + setCurrentPage(1); + }} style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }} items={[ { key: 'unassigned', - label: `未分配 (${unassignedAccounts.length})`, + label: `未分配 (${unassignedCount})`, }, { key: 'assigned', label: selectedUser - ? `已分配给 ${selectedUser.username} (${assignedAccounts.length})` + ? `已分配给 ${selectedUser.username} (${currentSelectedUserAssignedCount})` : '已分配账号', disabled: !selectedUser, }, @@ -389,12 +473,14 @@ export default function AssignmentsPage() { {/* 表格 */} `共 ${t} 条`, @@ -403,7 +489,7 @@ export default function AssignmentsPage() { if (size !== pageSize) { setPageSize(size); localStorage.setItem('assignment_page_size', String(size)); - setCurrentPage(1); // 切换 pageSize 时重置到第1页 + setCurrentPage(1); } }, }} @@ -411,7 +497,7 @@ export default function AssignmentsPage() { selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as number[]), }} - scroll={{ y: 'calc(100vh - 500px)' }} + scroll={{ y: 'calc(100vh - 460px)' }} /> diff --git a/web/frontend/src/pages/HuyaAssignmentsPage.tsx b/web/frontend/src/pages/HuyaAssignmentsPage.tsx index be239b0..7b78647 100644 --- a/web/frontend/src/pages/HuyaAssignmentsPage.tsx +++ b/web/frontend/src/pages/HuyaAssignmentsPage.tsx @@ -253,7 +253,7 @@ export default function HuyaAssignmentsPage() { {user.username} - + ); })}