feat(weibo): 增加账号分配与客服数据隔离
This commit is contained in:
@@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
|
||||
const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage'));
|
||||
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
|
||||
const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage'));
|
||||
const WeiboAssignmentsPage = lazy(() => import('./pages/WeiboAssignmentsPage'));
|
||||
const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage'));
|
||||
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
|
||||
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
|
||||
@@ -108,6 +109,7 @@ function AppContent() {
|
||||
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
|
||||
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
|
||||
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage mode="management" />)} />
|
||||
<Route path="weibo/assignments" element={lazyRoute(<WeiboAssignmentsPage />)} />
|
||||
<Route path="weibo/workbench" element={lazyRoute(<WeiboAccountsPage mode="workbench" />)} />
|
||||
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
|
||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||
|
||||
@@ -754,6 +754,8 @@ export interface WeiboAccountItem {
|
||||
nickname: string;
|
||||
tag: string;
|
||||
remark: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string;
|
||||
is_enabled: boolean;
|
||||
disabled_at: string | null;
|
||||
disabled_reason: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import api from './client';
|
||||
import type {
|
||||
AssignmentsSummary,
|
||||
MessageResponse,
|
||||
PaginatedResponse,
|
||||
WeiboAccountItem,
|
||||
@@ -22,6 +23,8 @@ export const weiboApi = {
|
||||
tag?: string;
|
||||
bind_status?: string;
|
||||
enabled?: boolean;
|
||||
assign_status?: string;
|
||||
assigned_user_id?: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}) =>
|
||||
@@ -29,6 +32,18 @@ export const weiboApi = {
|
||||
'/weibo/accounts',
|
||||
{ params },
|
||||
),
|
||||
listAccountIds: (params: {
|
||||
search?: string;
|
||||
tag?: string;
|
||||
bind_status?: string;
|
||||
enabled?: boolean;
|
||||
assign_status?: string;
|
||||
assigned_user_id?: number;
|
||||
}) =>
|
||||
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>(
|
||||
'/weibo/accounts/ids',
|
||||
{ params },
|
||||
),
|
||||
getAccount: (accountId: number) =>
|
||||
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
|
||||
importCookies: (text: string, tag = '', updateExisting = false) =>
|
||||
@@ -69,6 +84,13 @@ export const weiboApi = {
|
||||
'/weibo/accounts/batch-tag',
|
||||
{ ids, tag },
|
||||
),
|
||||
batchAssign: (accountIds: number[], assignedTo: number | null) =>
|
||||
api.post<MessageResponse, MessageResponse>('/weibo/accounts/batch-assign', {
|
||||
account_ids: accountIds,
|
||||
assigned_to: assignedTo,
|
||||
}),
|
||||
assignmentsSummary: () =>
|
||||
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
|
||||
accountTags: () =>
|
||||
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'),
|
||||
workbenchTags: () =>
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
if (can('weibo:view')) {
|
||||
weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: <UserOutlined /> });
|
||||
}
|
||||
if (can('weibo:assign')) {
|
||||
weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: <SwapOutlined /> });
|
||||
}
|
||||
if (can('weibo:workbench')) {
|
||||
weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: <DesktopOutlined /> });
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export default function WeiboAccountsPage({
|
||||
const canRecharge = can('weibo:recharge');
|
||||
const canExchange = can('weibo:exchange');
|
||||
const canDelete = can('weibo:delete');
|
||||
const canAssign = can('weibo:assign');
|
||||
|
||||
const [items, setItems] = useState<WeiboAccountItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -860,6 +861,23 @@ export default function WeiboAccountsPage({
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
...(canAssign
|
||||
? [
|
||||
{
|
||||
title: '归属客服',
|
||||
dataIndex: 'assigned_username',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
onHeaderCell: centerHeader,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Tag color="cyan">{value}</Tag>
|
||||
) : (
|
||||
<Text type="secondary">未分配</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'CK',
|
||||
key: 'ck',
|
||||
@@ -1237,7 +1255,9 @@ export default function WeiboAccountsPage({
|
||||
<Text type="secondary">
|
||||
{isWorkbench
|
||||
? '当前账号独立使用的微博账号、角色绑定、充值与兑换'
|
||||
: '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'}
|
||||
: canAssign
|
||||
? '全局账号池、归属分配、账号生命周期、批量操作和角色绑定'
|
||||
: '仅显示分配给你的微博账号,可进行绑定、充值与兑换'}
|
||||
</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
@@ -1484,16 +1504,30 @@ export default function WeiboAccountsPage({
|
||||
>
|
||||
{featureAccount ? (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{featureAccount.nickname || featureAccount.uid}
|
||||
</Text>
|
||||
<Space
|
||||
align="start"
|
||||
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {featureAccount.uid}
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{featureAccount.nickname || featureAccount.uid}
|
||||
</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {featureAccount.uid}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshingId === featureAccount.id}
|
||||
disabled={!featureAccount.is_enabled}
|
||||
onClick={() => void handleRefresh(featureAccount)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClearOutlined,
|
||||
ReloadOutlined,
|
||||
SwapOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
UsergroupAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
weiboApi,
|
||||
type AssignmentsSummary,
|
||||
type PaginatedResponse,
|
||||
type SupportUserItem,
|
||||
type WeiboAccountItem,
|
||||
} from '../api/modules';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { formatTime } from '../utils/time';
|
||||
|
||||
const { Text } = Typography;
|
||||
const STORAGE_KEY_SELECTED_USER = 'weibo_assignments_selected_user_id';
|
||||
const STORAGE_KEY_PAGE_SIZE = 'weibo_assignment_page_size';
|
||||
|
||||
const CK_STATUS: Record<string, { label: string; color: string }> = {
|
||||
valid: { label: '有效', color: 'success' },
|
||||
invalid: { label: '失效', color: 'error' },
|
||||
error: { label: '查询失败', color: 'warning' },
|
||||
};
|
||||
|
||||
function renderBindStatus(account: WeiboAccountItem) {
|
||||
if (account.is_bound === true && account.can_rebind === true) {
|
||||
return <Tag color="processing">可换绑</Tag>;
|
||||
}
|
||||
if (account.is_bound === true) {
|
||||
const days = account.rebind_remain_days ?? 0;
|
||||
return <Tag color="success">{days > 0 ? `${days} 天后可换绑` : '已绑定'}</Tag>;
|
||||
}
|
||||
if (account.is_bound === false) return <Tag color="warning">未绑定</Tag>;
|
||||
return <Tag>未查询</Tag>;
|
||||
}
|
||||
|
||||
export default function WeiboAssignmentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
|
||||
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
|
||||
const [accounts, setAccounts] = useState<WeiboAccountItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [knownTags, setKnownTags] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterTag, setFilterTag] = useState<string | undefined>();
|
||||
const [enabledFilter, setEnabledFilter] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
|
||||
return saved ? Number(saved) || 15 : 15;
|
||||
});
|
||||
|
||||
const loadSummary = useCallback(async (silent = false) => {
|
||||
try {
|
||||
const data = await weiboApi.assignmentsSummary();
|
||||
setSummary(data);
|
||||
setSupportUsers(data.support_users);
|
||||
return data.support_users;
|
||||
} catch (error) {
|
||||
if (!silent) message.error(getErrorMessage(error));
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const result = await weiboApi.accountTags();
|
||||
setKnownTags(result.tags);
|
||||
} catch {
|
||||
// 标签字典失败不影响分配列表。
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAccounts = useCallback(
|
||||
async (silent = false) => {
|
||||
if (activeTab === 'assigned' && !selectedUser) return;
|
||||
if (!silent) setTableLoading(true);
|
||||
try {
|
||||
const data: PaginatedResponse<WeiboAccountItem> = await weiboApi.listAccounts({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
search: searchText.trim() || undefined,
|
||||
tag: filterTag,
|
||||
enabled:
|
||||
enabledFilter === 'enabled'
|
||||
? true
|
||||
: enabledFilter === 'disabled'
|
||||
? false
|
||||
: undefined,
|
||||
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
|
||||
assigned_user_id:
|
||||
activeTab === 'assigned' ? selectedUser?.id : undefined,
|
||||
});
|
||||
setAccounts(data.items);
|
||||
setTotalCount(data.total);
|
||||
} catch (error) {
|
||||
if (!silent) message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
if (!silent) setTableLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
currentPage,
|
||||
enabledFilter,
|
||||
filterTag,
|
||||
pageSize,
|
||||
searchText,
|
||||
selectedUser,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
void loadTags();
|
||||
const users = await loadSummary();
|
||||
if (users.length > 0) {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
||||
const savedUser = savedId
|
||||
? users.find((user) => user.id === Number(savedId))
|
||||
: undefined;
|
||||
setSelectedUser(savedUser || users[0]);
|
||||
}
|
||||
};
|
||||
void initialize();
|
||||
}, [loadSummary, loadTags]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const refreshData = async (silent = false) => {
|
||||
await Promise.all([loadSummary(silent), loadAccounts(silent)]);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refreshData();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectUser = (user: SupportUserItem) => {
|
||||
setSelectedUser(user);
|
||||
setActiveTab('unassigned');
|
||||
setSelectedRowKeys([]);
|
||||
setCurrentPage(1);
|
||||
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
localStorage.setItem(STORAGE_KEY_PAGE_SIZE, String(size));
|
||||
setCurrentPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchAssign = async () => {
|
||||
if (!selectedUser || selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择客服和微博账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await weiboApi.batchAssign(selectedRowKeys, selectedUser.id);
|
||||
message.success(
|
||||
result.message || `已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAllFiltered = async () => {
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await weiboApi.listAccountIds({
|
||||
search: searchText.trim() || undefined,
|
||||
tag: filterTag,
|
||||
enabled:
|
||||
enabledFilter === 'enabled'
|
||||
? true
|
||||
: enabledFilter === 'disabled'
|
||||
? false
|
||||
: undefined,
|
||||
assign_status: 'unassigned',
|
||||
});
|
||||
if (result.account_ids.length === 0) {
|
||||
message.info('当前筛选下没有未分配账号');
|
||||
return;
|
||||
}
|
||||
await weiboApi.batchAssign(result.account_ids, selectedUser.id);
|
||||
message.success(
|
||||
`已将 ${result.account_ids.length} 个账号分配给 ${selectedUser.username}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUnassign = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择微博账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await weiboApi.batchAssign(selectedRowKeys, null);
|
||||
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
|
||||
setSelectedRowKeys([]);
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedUserCount =
|
||||
supportUsers.find((user) => user.id === selectedUser?.id)?.assigned_count ??
|
||||
selectedUser?.assigned_count ??
|
||||
0;
|
||||
const unassignedCount = summary?.unassigned_count ?? 0;
|
||||
const assignedCount = summary?.assigned_count ?? 0;
|
||||
const accountCount = summary?.total_count ?? assignedCount + unassignedCount;
|
||||
|
||||
const columns: TableProps<WeiboAccountItem>['columns'] = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 58,
|
||||
align: 'center',
|
||||
render: (_, __, index) => (currentPage - 1) * pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '微博账号',
|
||||
key: 'account',
|
||||
width: 190,
|
||||
align: 'center',
|
||||
render: (_, account) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong>{account.nickname || account.uid}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
UID {account.uid}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (tag: string) =>
|
||||
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'CK 状态',
|
||||
dataIndex: 'ck_check_status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (status: string) => {
|
||||
const state = CK_STATUS[status] || { label: '未检测', color: 'default' };
|
||||
return <Tag color={state.color}>{state.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '角色绑定',
|
||||
key: 'bind',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (_, account) => renderBindStatus(account),
|
||||
},
|
||||
{
|
||||
title: '微豆',
|
||||
dataIndex: 'weidou_balance',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (value: number | null) =>
|
||||
value == null ? <Text type="secondary">-</Text> : value.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '生命周期',
|
||||
dataIndex: 'is_enabled',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (enabled: boolean) => (
|
||||
<Tag color={enabled ? 'success' : 'default'}>
|
||||
{enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 155,
|
||||
align: 'center',
|
||||
render: (value: string | null) =>
|
||||
value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>微博分配管理</h2>
|
||||
<Text type="secondary">管理员和运营查看全部账号;客服只能看到分配给自己的账号。</Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} style={{ flexShrink: 0 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="微博账号" value={accountCount} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="已分配"
|
||||
value={assignedCount}
|
||||
styles={{ content: { color: token.colorSuccess } }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="未分配"
|
||||
value={unassignedCount}
|
||||
styles={{ content: { color: token.colorError } }}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="客服人数"
|
||||
value={supportUsers.length}
|
||||
prefix={<UsergroupAddOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col xs={24} lg={8} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
|
||||
<Card
|
||||
title="客服列表"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{supportUsers.map((user) => {
|
||||
const isSelected = selectedUser?.id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||
<Text strong>{user.username}</Text>
|
||||
</Space>
|
||||
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
|
||||
<Badge
|
||||
count={user.assigned_count}
|
||||
overflowCount={999999}
|
||||
showZero
|
||||
style={{ backgroundColor: token.colorPrimary }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{supportUsers.length === 0 && (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', padding: 20, display: 'block' }}
|
||||
>
|
||||
暂无客服用户
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={16} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
selectedUser ? (
|
||||
<Space wrap>
|
||||
<span>已选中客服:</span>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
|
||||
{selectedUser.username}(已分配 {selectedUserCount} 个)
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">请选择左侧客服进行操作</Text>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索 UID、昵称、角色或备注"
|
||||
value={searchText}
|
||||
onChange={(event) => {
|
||||
setSearchText(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 230 }}
|
||||
size="small"
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按标签筛选"
|
||||
value={filterTag}
|
||||
onChange={(value) => {
|
||||
setFilterTag(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
size="small"
|
||||
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onChange={(value) => {
|
||||
setEnabledFilter(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'enabled', label: '已启用' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key as 'unassigned' | 'assigned');
|
||||
setSelectedRowKeys([]);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
|
||||
items={[
|
||||
{ key: 'unassigned', label: `未分配 (${unassignedCount})` },
|
||||
{
|
||||
key: 'assigned',
|
||||
label: selectedUser
|
||||
? `已分配给 ${selectedUser.username} (${selectedUserCount})`
|
||||
: '已分配账号',
|
||||
disabled: !selectedUser,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '0 16px 8px',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activeTab === 'unassigned' && selectedUser && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchAssign}
|
||||
>
|
||||
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`把当前筛选下 ${totalCount} 个未分配账号全部分给 ${selectedUser.username}?`}
|
||||
okText="确认分配"
|
||||
cancelText="取消"
|
||||
disabled={totalCount === 0}
|
||||
onConfirm={handleAssignAllFiltered}
|
||||
>
|
||||
<Button size="small" loading={assigning} disabled={totalCount === 0}>
|
||||
分配当前筛选全部
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'assigned' && selectedUser && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<ClearOutlined />}
|
||||
loading={assigning}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
onClick={handleBatchUnassign}
|
||||
>
|
||||
取消分配选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table<WeiboAccountItem>
|
||||
columns={columns}
|
||||
dataSource={accounts}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={tableLoading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: totalCount,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: handlePageChange,
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||
}}
|
||||
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user