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
+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>