feat(accounts): 账号列表按归属筛选,批量分配支持按筛选全选

原来只有 assigned_only(已分配),筛不出「未分配」,也看不到账号归谁。
新增 assign_status/assigned_user_id 可与标签、CK、搜索等叠加;
BatchAssign 支持 all_matching,「筛出未分配 → 一键分给某人」不必先勾选。
This commit is contained in:
yml2213
2026-10-07 09:13:54 +08:00
parent 5da12df16b
commit 281769b440
6 changed files with 436 additions and 15 deletions
+184
View File
@@ -0,0 +1,184 @@
"""账号管理的「归属筛选 + 快速分配」。
背景:账号列表原先只能用 `assigned_only` 表达「已分配」,**没有「未分配」**,
也看不到、筛不出「这个账号归谁」。于是「把一批还没主的账号分给客服」只能
靠人肉翻页勾选。
本文件守住两件事:
1. `GET /accounts` 的归属筛选(unassigned / assigned / 指定客服)能生效,
并且与标签等条件**同时**生效(综合筛选,不是互相覆盖);
2. `POST /accounts/batch-assign` 支持 `all_matching` —— 按当前筛选解析目标,
「筛出未分配 → 一键分给某人」不必先勾选。显式 `account_ids` 的旧路径不变。
"""
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, User
from web.backend.routers.accounts import batch_assign_accounts, list_accounts
from web.backend.schemas import BatchAssign
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="af-admin", password_hash="h", role="super_admin")
self.support = User(username="af-kf", password_hash="h", role="support")
self.other = User(username="af-kf2", password_hash="h", role="support")
self.session.add_all([self.admin, self.support, self.other])
self.session.commit()
def teardown_method(self):
self.session.close()
def make_account(self, name: str, **kwargs) -> Account:
account = Account(
username=name,
password="p",
email=f"{name}@e.com",
email_password="ep",
# 默认持有 CK —— 分配接口只接受「还拿着 CK」的账号
cookie=kwargs.pop("cookie", "acf_uid=1"),
**kwargs,
)
self.session.add(account)
self.session.commit()
return account
def names(self, **kwargs) -> list[str]:
"""按归属条件查列表,返回用户名(沿用路由真实调用方式)。
未显式传的参数保持默认值;`Query(...)` 默认对象由 `_query_str` /
`isinstance` 兜住,这正是路由函数能被直接调用的原因。
"""
params = {
"assigned_only": False,
"tag": "",
"has_cookie": False,
"search": "",
"page": 1,
"page_size": 50,
"include_sensitive": False,
"db": self.session,
"current": self.admin,
}
params.update(kwargs)
result = list_accounts(**params)
assert isinstance(result, dict)
return [item.username for item in result["items"]]
class TestAssignFilter(_Base):
def test_unassigned_only(self):
self.make_account("free-1")
self.make_account("owned-1", assigned_to=self.support.id)
assert self.names(assign_status="unassigned") == ["free-1"]
def test_assigned_only(self):
self.make_account("free-2")
self.make_account("owned-2", assigned_to=self.support.id)
assert self.names(assign_status="assigned") == ["owned-2"]
def test_filter_by_specific_support(self):
self.make_account("a-of-kf", assigned_to=self.support.id)
self.make_account("a-of-kf2", assigned_to=self.other.id)
self.make_account("a-free")
assert self.names(assigned_user_id=self.other.id) == ["a-of-kf2"]
def test_combines_with_tag(self):
"""综合筛选:归属 + 标签要**同时**生效,而不是后一个覆盖前一个。"""
self.make_account("tag-a-free", tag="10.1-测试")
self.make_account("tag-b-free", tag="10.2-测试")
self.make_account("tag-a-owned", tag="10.1-测试", assigned_to=self.support.id)
assert self.names(assign_status="unassigned", tag="10.1-测试") == ["tag-a-free"]
class TestAllMatchingAssign(_Base):
def test_assigns_every_matching_unassigned(self):
free = [self.make_account(f"mf-{i}") for i in range(3)]
# 已归别人:这一批里**不能**被动到
taken = self.make_account("mf-taken", assigned_to=self.other.id)
result = batch_assign_accounts(
BatchAssign(
all_matching=True,
assign_status="unassigned",
assigned_to=self.support.id,
),
db=self.session,
current=self.admin,
)
assert result["count"] == 3
for account in free:
self.session.refresh(account)
assert account.assigned_to == self.support.id
self.session.refresh(taken)
assert taken.assigned_to == self.other.id
def test_all_matching_respects_tag(self):
self.make_account("mt-a", tag="A")
self.make_account("mt-b", tag="B")
result = batch_assign_accounts(
BatchAssign(all_matching=True, tag="A", assigned_to=self.support.id),
db=self.session,
current=self.admin,
)
assert result["count"] == 1
assert self.names(assigned_user_id=self.support.id) == ["mt-a"]
def test_unassign_all_matching(self):
"""留空目标 = 取消分配,同样支持按筛选全选。"""
a = self.make_account("mu-a", assigned_to=self.support.id)
b = self.make_account("mu-b", assigned_to=self.support.id)
result = batch_assign_accounts(
BatchAssign(all_matching=True, assign_status="assigned", assigned_to=None),
db=self.session,
current=self.admin,
)
assert result["count"] == 2
for account in (a, b):
self.session.refresh(account)
assert account.assigned_to is None
def test_explicit_ids_still_work(self):
"""旧路径(显式勾选)不能被 all_matching 改动带坏。"""
a = self.make_account("me-a")
b = self.make_account("me-b")
result = batch_assign_accounts(
BatchAssign(account_ids=[a.id], assigned_to=self.support.id),
db=self.session,
current=self.admin,
)
assert result["count"] == 1
self.session.refresh(a)
self.session.refresh(b)
assert a.assigned_to == self.support.id
assert b.assigned_to is None
def test_all_matching_without_match_is_rejected(self):
import pytest
from fastapi import HTTPException
self.make_account("mn-a", assigned_to=self.other.id)
with pytest.raises(HTTPException) as exc:
batch_assign_accounts(
BatchAssign(
all_matching=True,
assign_status="unassigned",
assigned_to=self.support.id,
),
db=self.session,
current=self.admin,
)
assert exc.value.status_code == 400
+39 -8
View File
@@ -97,6 +97,8 @@ def _filter_accounts_query(
current: User,
*,
assigned_only: bool = False,
assign_status: str = "",
assigned_user_id: int | None = None,
tag: str | None = None,
has_cookie: bool = False,
search: str = "",
@@ -117,6 +119,16 @@ def _filter_accounts_query(
query = query.filter(Account.id.in_(cookie_account_ids_query(db)))
if assigned_only and user_has_permission(current, "account:view_all"):
query = query.filter(Account.assigned_to.isnot(None))
# 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、
# 搜索…)是**同时生效**的,这正是「综合筛选」的用法。
assign_value = _query_str(assign_status)
if assign_value == "unassigned":
query = query.filter(Account.assigned_to.is_(None))
elif assign_value == "assigned":
query = query.filter(Account.assigned_to.isnot(None))
# isinstance 兜住「测试直接调路由函数、Query 默认值未解析」的情况。
if isinstance(assigned_user_id, int) and not isinstance(assigned_user_id, bool):
query = query.filter(Account.assigned_to == assigned_user_id)
tag_value = _query_str(tag)
if tag_value:
query = query.filter(Account.tag == tag_value)
@@ -219,6 +231,8 @@ def _selected_account_ids(
_visible_accounts_query(db, current),
current,
assigned_only=req.assigned_only,
assign_status=req.assign_status,
assigned_user_id=req.assigned_user_id,
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
@@ -366,6 +380,10 @@ def _require_account_tag_permission(current: User) -> None:
@router.get("")
def list_accounts(
assigned_only: bool = Query(False),
assign_status: str = Query(
"", description="归属筛选:unassigned=未分配 / assigned=已分配;空=全部"
),
assigned_user_id: int | None = Query(None, description="只看某个客服名下的账号"),
tag: str = Query(None),
has_cookie: bool = Query(False),
search: str = Query(""),
@@ -406,6 +424,8 @@ def list_accounts(
_visible_accounts_query(db, current),
current,
assigned_only=assigned_only,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
tag=tag,
has_cookie=has_cookie,
search=search,
@@ -773,8 +793,14 @@ def batch_assign_accounts(
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:assign")),
):
"""批量分配/取消分配账号给客服。"""
if not req.account_ids:
"""批量分配/取消分配账号给客服。
`all_matching=True` 时按当前筛选条件解析目标(与批量打标签同一套语义)——
「筛出未分配 → 一键分给某人」不必先手动勾选,这正是账号管理页
「归属筛选 + 快速分配」的组合用法。
"""
ids = _selected_account_ids(db, current, req)
if not ids:
raise HTTPException(status_code=400, detail="请选择账号")
# 验证目标用户
@@ -790,7 +816,7 @@ def batch_assign_accounts(
invalid_ids = (
db.query(Account.id)
.filter(
Account.id.in_(req.account_ids),
Account.id.in_(ids),
Account.id.notin_(cookie_ids_query),
)
.all()
@@ -809,13 +835,13 @@ def batch_assign_accounts(
)
# 先取出账号对象(批量时间线要逐个写事件,光有 count 不够)
target_accounts = db.query(Account).filter(Account.id.in_(req.account_ids)).all()
target_accounts = db.query(Account).filter(Account.id.in_(ids)).all()
count = (
db.query(Account)
.filter(Account.id.in_(req.account_ids))
.filter(Account.id.in_(ids))
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(req.account_ids)).update(
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
target_user = (
@@ -825,13 +851,18 @@ def batch_assign_accounts(
)
target_name = target_user.username if target_user else ""
action_word = "分配" if req.assigned_to else "取消分配"
scope = "当前筛选下" if req.all_matching else "选中的"
title = f"批量分配:{target_name}" if target_name else "批量取消分配"
record_audit(
db,
current,
action="account:assign",
target=f"批量{action_word}{count}个账号",
detail={"assigned_to": req.assigned_to, "assigned_username": target_name},
detail={
"assigned_to": req.assigned_to,
"assigned_username": target_name,
"scope": "all_matching" if req.all_matching else "selected",
},
)
# 每个账号的时间线各留一条 —— 「这个账号什么时候被分配给谁」是账号级事实。
emit_bulk(
@@ -856,7 +887,7 @@ def batch_assign_accounts(
db.commit()
action = "分配" if req.assigned_to else "取消分配"
return {
"message": f"已批量{action} {count} 个账号",
"message": f"已批量{action}{scope} {count} 个账号",
"success": True,
"count": count,
}
+15 -5
View File
@@ -123,11 +123,6 @@ class AccountAssign(BaseModel):
assigned_to: int | None = None
class BatchAssign(BaseModel):
account_ids: list[int]
assigned_to: int | None = None # None=取消分配
class AccountBulkSelection(BaseModel):
"""批量操作选择范围:显式 ID 或当前筛选条件下全部账号。"""
@@ -136,6 +131,10 @@ class AccountBulkSelection(BaseModel):
search: str = ""
tag: str = ""
assigned_only: bool = False
#: 归属筛选:""=全部 / unassigned=未分配 / assigned=已分配
assign_status: str = ""
#: 只看某个客服名下的账号(与 assign_status 可叠加)
assigned_user_id: int | None = None
has_cookie: bool = False
status: str = ""
ck_status: str = ""
@@ -150,6 +149,17 @@ class AccountBulkSelection(BaseModel):
role_bind_status: str = ""
class BatchAssign(AccountBulkSelection):
"""批量分配 / 取消分配。
⚠️ `assigned_to` 是**目标**客服(None=取消分配),与父类的
`assigned_user_id`(筛选「谁名下的账号」)是两件事,别混。
`all_matching=True` 时按父类的筛选条件解析目标账号。
"""
assigned_to: int | None = None
class AccountBulkTag(AccountBulkSelection):
tag_value: str = ""
+6
View File
@@ -8,6 +8,7 @@ import type {
AccountOverview,
AssignmentsSummary,
BasicSummary,
BatchAssignRequest,
MessageCountResponse,
MessageDeletedResponse,
MessageResponse,
@@ -21,6 +22,8 @@ export const accountApi = {
listPaged: (
params: PageParams & {
assigned_only?: boolean;
assign_status?: string;
assigned_user_id?: number;
tag?: string;
has_cookie?: boolean;
include_sensitive?: boolean;
@@ -47,6 +50,9 @@ export const accountApi = {
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
api.post<MessageCountResponse, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
// 同一接口的选择集版本:可带筛选条件(all_matching),供「按筛选全选」分配
batchAssignSelection: (data: BatchAssignRequest) =>
api.post<MessageCountResponse, MessageCountResponse>('/accounts/batch-assign', data),
assignmentsSummary: () =>
api.get<AssignmentsSummary, AssignmentsSummary>('/accounts/assignments/summary'),
setRemark: (id: number, remark: string) =>
+9
View File
@@ -30,6 +30,10 @@ export interface AccountBulkSelection {
search?: string;
tag?: string;
assigned_only?: boolean;
/** 归属筛选:unassigned=未分配 / assigned=已分配;空=全部 */
assign_status?: string;
/** 只看某个客服名下的账号 */
assigned_user_id?: number;
has_cookie?: boolean;
status?: string;
ck_status?: string;
@@ -48,6 +52,11 @@ export interface AccountBulkTagRequest extends AccountBulkSelection {
tag_value: string;
}
/** 批量分配:`assigned_to` 是目标客服(null=取消分配),别与筛选用的 assigned_user_id 混 */
export interface BatchAssignRequest extends AccountBulkSelection {
assigned_to?: number | null;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
+183 -2
View File
@@ -33,8 +33,9 @@ import {
DownOutlined,
UpOutlined,
ClearOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary } from '../api/modules';
import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary, type SupportUserItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime, formatTimeShort } from '../utils/time';
import { getErrorMessage } from '../utils/error';
@@ -183,6 +184,13 @@ export default function AccountsPage() {
const [statusFilter, setStatusFilter] = useState<string>('');
const [ckFilter, setCkFilter] = useState<string>('');
const [bindFilter, setBindFilter] = useState<string>('');
// 归属筛选:'' | 'unassigned' | 'assigned' | 'user:<id>'
// 单独一个 Select 而不是两个,是因为「未分配 / 已分配 / 具体某个客服」互斥。
const [assignFilter, setAssignFilter] = useState<string>('');
const [assignUsers, setAssignUsers] = useState<SupportUserItem[]>([]);
const [assignModalOpen, setAssignModalOpen] = useState(false);
const [assignTargetId, setAssignTargetId] = useState<number | undefined>(undefined);
const [assigning, setAssigning] = useState(false);
const [searchText, setSearchText] = useState('');
// 高价值资产与高级自定义筛选状态
@@ -250,7 +258,15 @@ export default function AccountsPage() {
const canImport = can('account:import');
const canTag = can('account:tag') || canImport;
const canDelete = can('account:delete');
const canSelectRows = canTag || canDelete;
const canAssign = can('account:assign');
// 只有分配权限的人也要能勾选账号(否则没法批量分配)。
const canSelectRows = canTag || canDelete || canAssign;
// 一个 Select 的值拆成后端两个参数:归属状态 + 指定客服。
const assignStatusParam =
assignFilter === 'unassigned' || assignFilter === 'assigned' ? assignFilter : '';
const assignedUserIdParam = assignFilter.startsWith('user:')
? Number(assignFilter.slice(5))
: undefined;
const loadAccounts = useCallback(async () => {
setLoading(true);
@@ -265,6 +281,8 @@ export default function AccountsPage() {
if (statusFilter) params.status = statusFilter;
if (ckFilter) params.ck_status = ckFilter;
if (bindFilter) params.bind_status = bindFilter;
if (assignStatusParam) params.assign_status = assignStatusParam;
if (assignedUserIdParam !== undefined) params.assigned_user_id = assignedUserIdParam;
if (highValueFilter) params.high_value_filter = highValueFilter;
if (minPoints !== undefined) params.min_points = minPoints;
if (maxPoints !== undefined) params.max_points = maxPoints;
@@ -296,6 +314,8 @@ export default function AccountsPage() {
statusFilter,
ckFilter,
bindFilter,
assignStatusParam,
assignedUserIdParam,
highValueFilter,
minPoints,
maxPoints,
@@ -326,6 +346,19 @@ export default function AccountsPage() {
}
}, []);
const loadAssignUsers = useCallback(async () => {
try {
const data = await accountApi.assignmentsSummary();
setAssignUsers(data.support_users);
} catch {
// 客服列表拉不到不影响列表本身,只是分配时选不到人。
}
}, []);
useEffect(() => {
if (canAssign) loadAssignUsers();
}, [canAssign, loadAssignUsers]);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
@@ -344,6 +377,8 @@ export default function AccountsPage() {
status: statusFilter || undefined,
ck_status: ckFilter || undefined,
bind_status: bindFilter || undefined,
assign_status: assignStatusParam || undefined,
assigned_user_id: assignedUserIdParam,
high_value_filter: highValueFilter || undefined,
min_points: minPoints,
max_points: maxPoints,
@@ -360,6 +395,8 @@ export default function AccountsPage() {
statusFilter,
ckFilter,
bindFilter,
assignStatusParam,
assignedUserIdParam,
highValueFilter,
minPoints,
maxPoints,
@@ -383,6 +420,8 @@ export default function AccountsPage() {
statusFilter,
ckFilter,
bindFilter,
assignStatusParam,
assignedUserIdParam,
highValueFilter,
minPoints,
maxPoints,
@@ -442,6 +481,34 @@ export default function AccountsPage() {
}
};
// 快速分配:`target` 为目标客服,显式传 null 才是取消分配(不做「留空即取消」,
// 免得点错一下就把一批账号的归属清掉)。支持「全选当前筛选」,
// 所以「筛出未分配 → 一键分给某人」不用先手动勾选。
const handleBatchAssign = async (target: number | null) => {
if (selectedCount === 0) {
message.warning('请先选择账号,或先设置筛选条件后点「全选当前筛选」');
return;
}
setAssigning(true);
try {
const result = await accountApi.batchAssignSelection({
...selectionPayload,
assigned_to: target,
});
message.success(result.message);
setAssignModalOpen(false);
setAssignTargetId(undefined);
clearSelection();
loadAccounts();
loadSummary();
if (canAssign) loadAssignUsers();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const handleSetTag = async (accountId: number, tag: string) => {
try {
await accountApi.setTag(accountId, tag);
@@ -637,6 +704,35 @@ export default function AccountsPage() {
);
},
},
{
// 归属:看得见才管得着 —— 未分配的一眼可见,而且直接点就能分。
title: '分配',
dataIndex: 'assigned_username',
width: 110,
render: (name: string | null, record: AccountItem) => {
if (name) {
return <Tag color="green" style={{ marginInlineEnd: 0 }}>{name}</Tag>;
}
return canAssign ? (
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => {
// 快捷入口:只针对这一行,打开分配弹窗
setSelectedRowKeys([record.id]);
setSelectedAllMatching(false);
setAssignTargetId(undefined);
setAssignModalOpen(true);
}}
>
未分配
</Button>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>未分配</Text>
);
},
},
{
title: '积分 / 鱼翅',
dataIndex: 'points',
@@ -931,6 +1027,19 @@ export default function AccountsPage() {
},
];
// 归属筛选下拉:两个固定状态 + 各客服(客服列表拉不到时只剩固定项)。
const assignFilterOptions = useMemo(
() => [
{ value: 'unassigned', label: '未分配' },
{ value: 'assigned', label: '已分配' },
...assignUsers.map((user) => ({
value: `user:${user.id}`,
label: `客服:${user.username}(${user.assigned_count})`,
})),
],
[assignUsers],
);
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
@@ -968,6 +1077,17 @@ export default function AccountsPage() {
onChange={(val) => { setBindFilter(val || ''); setCurrentPage(1); }}
options={BIND_OPTIONS}
/>
<Select
allowClear
showSearch
optionFilterProp="label"
size="small"
placeholder="按分配筛选"
style={{ width: 140 }}
value={assignFilter || undefined}
onChange={(val) => { setAssignFilter(val || ''); setCurrentPage(1); }}
options={assignFilterOptions}
/>
<Input.Search
allowClear
size="small"
@@ -999,6 +1119,18 @@ export default function AccountsPage() {
批量打标签
</Button>
)}
{canAssign && (
<Button
disabled={selectedCount === 0}
icon={<UsergroupAddOutlined />}
onClick={() => {
setAssignTargetId(undefined);
setAssignModalOpen(true);
}}
>
批量分配
</Button>
)}
{canDelete && (
<Popconfirm
title={`确认删除${selectedAllMatching ? '当前筛选下' : '选中的'} ${selectedCount} 个账号?`}
@@ -1478,6 +1610,55 @@ export default function AccountsPage() {
/>
</Modal>
<Modal
title="批量分配账号"
open={assignModalOpen}
onCancel={() => setAssignModalOpen(false)}
confirmLoading={assigning}
footer={[
<Button key="cancel" onClick={() => setAssignModalOpen(false)}>
取消
</Button>,
<Button
key="unassign"
danger
loading={assigning}
onClick={() => handleBatchAssign(null)}
>
取消分配
</Button>,
<Button
key="assign"
type="primary"
loading={assigning}
disabled={assignTargetId === undefined}
onClick={() => handleBatchAssign(assignTargetId ?? null)}
>
确定分配
</Button>,
]}
width={420}
>
<p>
为{selectedAllMatching ? '当前筛选下' : '选中的'} {selectedCount} 个账号分配:
</p>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要分配给哪个客服"
value={assignTargetId}
onChange={(val) => setAssignTargetId(val ?? undefined)}
options={assignUsers.map((user) => ({
value: user.id,
label: `${user.username}(现 ${user.assigned_count} 个)`,
}))}
/>
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
只有仍持有 CK 的账号能被分配;取消分配会清空这些账号的归属。
</Text>
</Modal>
<Modal
title={
<Space>