feat(accounts): 账号列表按归属筛选,批量分配支持按筛选全选
原来只有 assigned_only(已分配),筛不出「未分配」,也看不到账号归谁。 新增 assign_status/assigned_user_id 可与标签、CK、搜索等叠加; BatchAssign 支持 all_matching,「筛出未分配 → 一键分给某人」不必先勾选。
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user