Files
live-hub-py/web/frontend/src/pages/AssignmentsPage.tsx
T

398 lines
14 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import {
Card, Table, Button, Select, Input, Tag, Row, Col, Statistic,
message, Space, Tabs, Typography, Badge, theme,
} from 'antd';
import {
UserOutlined, CheckCircleOutlined, TeamOutlined,
SwapOutlined, ClearOutlined, UsergroupAddOutlined,
} from '@ant-design/icons';
import { accountApi, type AccountItem, type SupportUserItem } from '../api/modules';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'assignments_selected_user_id';
export default function AssignmentsPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState('unassigned');
const [assigning, setAssigning] = useState(false);
// 加载分配概览
const loadSummary = async () => {
try {
const data = await accountApi.assignmentsSummary();
setSupportUsers(data.support_users);
return data.support_users;
} catch (e: unknown) {
message.error(getErrorMessage(e));
return [];
}
};
// 加载账号(仅已成功登录过的)
const loadAccounts = async () => {
try {
const all = await accountApi.list({ has_cookie: true });
setAccounts(all);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
// 初始化加载 + 自动选中客服
useEffect(() => {
const init = async () => {
loadAccounts();
const users = await loadSummary();
if (users.length > 0) {
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null;
setSelectedUser(savedUser || users[0]);
}
};
init();
}, []);
// 标签列表
const allTags = useMemo(() => {
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
return tags.sort();
}, [accounts]);
// 未分配账号
const unassignedAccounts = useMemo(() => {
return accounts.filter((a) => !a.assigned_to);
}, [accounts]);
// 当前选中客服的已分配账号
const assignedAccounts = useMemo(() => {
if (!selectedUser) return [];
return accounts.filter((a) => a.assigned_to === selectedUser.id);
}, [accounts, selectedUser]);
// 当前显示的账号集合
const displayAccounts = useMemo(() => {
let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts;
if (searchText) {
const s = searchText.toLowerCase();
list = list.filter((a) => a.username.toLowerCase().includes(s));
}
if (filterTag) {
list = list.filter((a) => (a.tag || '').trim() === filterTag);
}
return list;
}, [activeTab, unassignedAccounts, assignedAccounts, searchText, filterTag]);
// 切换客服
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setSelectedRowKeys([]);
setActiveTab('unassigned');
setSearchText('');
setFilterTag(undefined);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
// 批量分配
const handleBatchAssign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择账号');
return;
}
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
await accountApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(`已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`);
setSelectedRowKeys([]);
await loadAccounts();
const users = await loadSummary();
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
// 批量取消分配
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择账号');
return;
}
setAssigning(true);
try {
await accountApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
setSelectedRowKeys([]);
await loadAccounts();
const users = await loadSummary();
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser?.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
// 一键将未分配账号全部分配给选中客服
const handleAssignAllUnassigned = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
const ids = unassignedAccounts
.filter((a) => {
if (searchText && !a.username.toLowerCase().includes(searchText.toLowerCase())) return false;
if (filterTag && (a.tag || '').trim() !== filterTag) return false;
return true;
})
.map((a) => a.id);
if (ids.length === 0) {
message.info('没有未分配的账号');
return;
}
setAssigning(true);
try {
await accountApi.batchAssign(ids, selectedUser.id);
message.success(`已将 ${ids.length} 个账号分配给 ${selectedUser.username}`);
await loadAccounts();
const users = await loadSummary();
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
// 统计
const assignedCount = accounts.filter((a) => !!a.assigned_to).length;
const unassignedCount = accounts.length - assignedCount;
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
{
title: '标签',
dataIndex: 'tag',
width: 100,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<h2 style={{ margin: 0, flexShrink: 0 }}>分配管理</h2>
{/* 统计卡片 */}
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col span={6}>
<Card size="small"><Statistic title="总账号" value={accounts.length} prefix={<TeamOutlined />} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} prefix={<SwapOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="客服人数" value={supportUsers.length} prefix={<UsergroupAddOutlined />} />
</Card>
</Col>
</Row>
{/* 主体区域 */}
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
{/* 左栏:客服列表 */}
<Col span={8} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
title="客服列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
bodyStyle={{ flex: 1, overflow: 'auto', padding: '8px' }}
>
<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,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = token.colorPrimary;
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = token.colorBorderSecondary;
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
</div>
);
})}
{supportUsers.length === 0 && (
<Text type="secondary" style={{ textAlign: 'center', padding: 20, display: 'block' }}>
暂无客服用户
</Text>
)}
</div>
</Card>
</Col>
{/* 右栏:账号表格 */}
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
size="small"
title={
<Space>
{selectedUser ? (
<>
<span>已选中客服:</span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username}(已分配 {selectedUser.assigned_count} 个)
</Tag>
</>
) : (
<Text type="secondary">请选择左侧客服进行操作</Text>
)}
</Space>
}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 }}
>
{/* 筛选栏 */}
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<Input.Search
placeholder="搜索用户名"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 200 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(v) => setFilterTag(v || undefined)}
style={{ width: 150 }}
size="small"
options={allTags.map((t) => ({ value: t, label: t }))}
/>
</div>
{/* Tab 切换 */}
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => { setActiveTab(key); setSelectedRowKeys([]); }}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{
key: 'unassigned',
label: `未分配 (${unassignedAccounts.length})`,
},
{
key: 'assigned',
label: selectedUser
? `已分配给 ${selectedUser.username} (${assignedAccounts.length})`
: '已分配账号',
disabled: !selectedUser,
},
]}
/>
{/* 操作按钮 */}
<div style={{ padding: '0 16px 8px', flexShrink: 0 }}>
<Space>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
分配选中 ({selectedRowKeys.length}) {selectedUser.username}
</Button>
<Button
size="small"
loading={assigning}
onClick={handleAssignAllUnassigned}
>
全部分配给 {selectedUser.username}
</Button>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
取消分配选中 ({selectedRowKeys.length})
</Button>
)}
</Space>
</div>
{/* 表格 */}
<Table
columns={columns}
dataSource={displayAccounts}
rowKey="id"
size="small"
pagination={{ pageSize: 15, size: 'small', showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
}}
scroll={{ y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}