优化分配功能

This commit is contained in:
yml2213
2026-06-22 16:23:34 +08:00
parent f94b323ad8
commit 48b6a1623b
8 changed files with 530 additions and 4 deletions
+2
View File
@@ -6,6 +6,7 @@ import LoginPage from './pages/LoginPage';
import MainLayout from './layouts/MainLayout';
import DashboardPage from './pages/DashboardPage';
import AccountsPage from './pages/AccountsPage';
import AssignmentsPage from './pages/AssignmentsPage';
import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
@@ -29,6 +30,7 @@ function App() {
>
<Route index element={<DashboardPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="assignments" element={<AssignmentsPage />} />
<Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="cookies" element={<CookiePage />} />
<Route path="proxy" element={<ProxyPage />} />
+5 -1
View File
@@ -36,11 +36,15 @@ export const userApi = {
};
export const accountApi = {
list: (params?: { assigned_only?: boolean; tag?: string }) =>
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
api.get<any, any[]>('/accounts', { params }),
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
assign: (id: number, assigned_to: number | null) =>
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
api.post<any, any>('/accounts/batch-assign', { account_ids, assigned_to }),
assignmentsSummary: () =>
api.get<any, any>('/accounts/assignments/summary'),
setTag: (id: number, tag: string) =>
api.put<any, any>(`/accounts/${id}/tag`, { tag }),
batchTag: (account_ids: number[], tag: string) =>
+6 -1
View File
@@ -3,7 +3,7 @@ import { Layout, Menu, Avatar, Space, Typography, Button, Modal } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
@@ -40,6 +40,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
}
// 分配管理
if (hasPerm(user, 'account:assign')) {
menuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
// 登录任务
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_assigned')) {
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useState, useMemo } from 'react';
import {
Card, Table, Button, Select, Input, Tag, Row, Col, Statistic,
message, Space, Tabs, Typography, Badge,
} from 'antd';
import {
UserOutlined, CheckCircleOutlined, TeamOutlined,
SwapOutlined, ClearOutlined, UsergroupAddOutlined,
} from '@ant-design/icons';
import { accountApi } from '../api/modules';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'assignments_selected_user_id';
interface SupportUser {
id: number;
username: string;
assigned_count: number;
}
interface AccountItem {
id: number;
username: string;
tag: string;
assigned_to: number | null;
assigned_username: string | null;
}
export default function AssignmentsPage() {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [supportUsers, setSupportUsers] = useState<SupportUser[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUser | 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 as SupportUser[];
} catch (e: any) {
message.error(e.message);
return [];
}
};
// 加载账号(仅已成功登录过的)
const loadAccounts = async () => {
try {
const all = await accountApi.list({ has_cookie: true });
setAccounts(all);
} catch (e: any) {
message.error(e.message);
}
};
// 初始化加载 + 自动选中客服
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: SupportUser) => 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: SupportUser) => {
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: SupportUser) => u.id === selectedUser.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: any) {
message.error(e.message);
} 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: SupportUser) => u.id === selectedUser?.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: any) {
message.error(e.message);
} 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: SupportUser) => u.id === selectedUser.id);
if (refreshed) setSelectedUser(refreshed);
} catch (e: any) {
message.error(e.message);
} 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} valueStyle={{ color: '#3f8600' }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} valueStyle={{ color: '#cf1322' }} 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 ? '#1677ff' : '#f0f0f0'}`,
background: isSelected ? '#e6f4ff' : '#fff',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = '#1677ff';
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLElement).style.borderColor = '#f0f0f0';
}}
>
<Space>
<UserOutlined style={{ color: '#1677ff' }} />
<Text strong>{user.username}</Text>
</Space>
<Badge count={user.assigned_count} showZero style={{ backgroundColor: '#1677ff' }} />
</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="#1677ff" 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 #f0f0f0', 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>
);
}