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