优化分配功能
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, Account, AuditLog
|
from ..models import User, Account, AuditLog, LoginTask
|
||||||
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut
|
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
||||||
from ..deps import get_current_user, require_permission
|
from ..deps import get_current_user, require_permission
|
||||||
from ..permissions import has_permission
|
from ..permissions import has_permission
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||||
|
|
||||||
@@ -26,16 +27,30 @@ def _split_account_line(line: str) -> list[str]:
|
|||||||
return line.split()
|
return line.split()
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_account_ids_query(db: Session):
|
||||||
|
"""返回有成功登录记录(cookie非空)的账号ID子查询。"""
|
||||||
|
return db.query(LoginTask.account_id).filter(
|
||||||
|
LoginTask.status == 'success',
|
||||||
|
LoginTask.cookie != '',
|
||||||
|
LoginTask.cookie.isnot(None),
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AccountOut])
|
@router.get("", response_model=list[AccountOut])
|
||||||
def list_accounts(
|
def list_accounts(
|
||||||
assigned_only: bool = Query(False),
|
assigned_only: bool = Query(False),
|
||||||
tag: str = Query(None),
|
tag: str = Query(None),
|
||||||
|
has_cookie: bool = Query(False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""列表:按角色返回不同字段和范围。"""
|
"""列表:按角色返回不同字段和范围。"""
|
||||||
query = db.query(Account)
|
query = db.query(Account)
|
||||||
|
|
||||||
|
# 只展示已成功登录过的账号
|
||||||
|
if has_cookie:
|
||||||
|
query = query.filter(Account.id.in_(_cookie_account_ids_query(db)))
|
||||||
|
|
||||||
# 权限控制:客服只能看分配给自己的
|
# 权限控制:客服只能看分配给自己的
|
||||||
if not has_permission(current.role, "account:view_all"):
|
if not has_permission(current.role, "account:view_all"):
|
||||||
if has_permission(current.role, "account:view_assigned"):
|
if has_permission(current.role, "account:view_assigned"):
|
||||||
@@ -135,6 +150,15 @@ def assign_account(
|
|||||||
if target.role != "support":
|
if target.role != "support":
|
||||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||||
|
|
||||||
|
# 只能分配已成功登录过的账号(有cookie)
|
||||||
|
has_success = db.query(LoginTask).filter(
|
||||||
|
LoginTask.account_id == account_id,
|
||||||
|
LoginTask.status == 'success',
|
||||||
|
LoginTask.cookie != '',
|
||||||
|
).first()
|
||||||
|
if not has_success:
|
||||||
|
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
|
||||||
|
|
||||||
acc.assigned_to = req.assigned_to
|
acc.assigned_to = req.assigned_to
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||||
action="account:assign", target=acc.username))
|
action="account:assign", target=acc.username))
|
||||||
@@ -142,6 +166,83 @@ def assign_account(
|
|||||||
return {"message": "已分配", "success": True}
|
return {"message": "已分配", "success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-assign")
|
||||||
|
def batch_assign_accounts(
|
||||||
|
req: BatchAssign,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("account:assign")),
|
||||||
|
):
|
||||||
|
"""批量分配/取消分配账号给客服。"""
|
||||||
|
if not req.account_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
|
|
||||||
|
# 验证目标用户
|
||||||
|
if req.assigned_to is not None:
|
||||||
|
target = db.query(User).filter(User.id == req.assigned_to).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="目标用户不存在")
|
||||||
|
if target.role != "support":
|
||||||
|
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||||
|
|
||||||
|
# 只能分配已成功登录过的账号(有cookie)
|
||||||
|
cookie_ids_query = _cookie_account_ids_query(db).subquery()
|
||||||
|
invalid_ids = db.query(Account.id).filter(
|
||||||
|
Account.id.in_(req.account_ids),
|
||||||
|
Account.id.notin_(cookie_ids_query),
|
||||||
|
).all()
|
||||||
|
if invalid_ids:
|
||||||
|
names = db.query(Account.username).filter(Account.id.in_([i[0] for i in invalid_ids])).all()
|
||||||
|
name_list = ', '.join([n[0] for n in names[:5]])
|
||||||
|
suffix = '...' if len(invalid_ids) > 5 else ''
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
|
||||||
|
)
|
||||||
|
|
||||||
|
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
|
||||||
|
{Account.assigned_to: req.assigned_to}, synchronize_session=False
|
||||||
|
)
|
||||||
|
db.add(AuditLog(
|
||||||
|
user_id=current.id, username=current.username,
|
||||||
|
action="account:assign",
|
||||||
|
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号"
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
action = "分配" if req.assigned_to else "取消分配"
|
||||||
|
return {"message": f"已批量{action} {count} 个账号", "success": True, "count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assignments/summary")
|
||||||
|
def assignments_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("account:assign")),
|
||||||
|
):
|
||||||
|
"""分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。"""
|
||||||
|
cookie_subq = _cookie_account_ids_query(db).subquery()
|
||||||
|
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||||
|
|
||||||
|
results = (
|
||||||
|
db.query(User.id, User.username, func.count(cookie_accounts.c.id).label("count"))
|
||||||
|
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||||
|
.filter(User.role == "support")
|
||||||
|
.group_by(User.id, User.username)
|
||||||
|
.order_by(func.count(cookie_accounts.c.id).desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_unassigned = (
|
||||||
|
db.query(func.count(cookie_accounts.c.id))
|
||||||
|
.filter(cookie_accounts.c.assigned_to.is_(None))
|
||||||
|
.scalar()
|
||||||
|
) or 0
|
||||||
|
return {
|
||||||
|
"support_users": [
|
||||||
|
{"id": uid, "username": uname, "assigned_count": cnt}
|
||||||
|
for uid, uname, cnt in results
|
||||||
|
],
|
||||||
|
"unassigned_count": total_unassigned,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{account_id}/tag")
|
@router.put("/{account_id}/tag")
|
||||||
def set_account_tag(
|
def set_account_tag(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ class AccountAssign(BaseModel):
|
|||||||
assigned_to: Optional[int] = None
|
assigned_to: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAssign(BaseModel):
|
||||||
|
account_ids: list[int]
|
||||||
|
assigned_to: Optional[int] = None # None=取消分配
|
||||||
|
|
||||||
|
|
||||||
class AccountTag(BaseModel):
|
class AccountTag(BaseModel):
|
||||||
tag: Optional[str] = None
|
tag: Optional[str] = None
|
||||||
account_ids: Optional[list[int]] = None
|
account_ids: Optional[list[int]] = None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import AccountsPage from './pages/AccountsPage';
|
import AccountsPage from './pages/AccountsPage';
|
||||||
|
import AssignmentsPage from './pages/AssignmentsPage';
|
||||||
import LoginTasksPage from './pages/LoginTasksPage';
|
import LoginTasksPage from './pages/LoginTasksPage';
|
||||||
import ProxyPage from './pages/ProxyPage';
|
import ProxyPage from './pages/ProxyPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
@@ -29,6 +30,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="accounts" element={<AccountsPage />} />
|
<Route path="accounts" element={<AccountsPage />} />
|
||||||
|
<Route path="assignments" element={<AssignmentsPage />} />
|
||||||
<Route path="login-tasks" element={<LoginTasksPage />} />
|
<Route path="login-tasks" element={<LoginTasksPage />} />
|
||||||
<Route path="cookies" element={<CookiePage />} />
|
<Route path="cookies" element={<CookiePage />} />
|
||||||
<Route path="proxy" element={<ProxyPage />} />
|
<Route path="proxy" element={<ProxyPage />} />
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export const userApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const accountApi = {
|
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 }),
|
api.get<any, any[]>('/accounts', { params }),
|
||||||
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
|
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
|
||||||
assign: (id: number, assigned_to: number | null) =>
|
assign: (id: number, assigned_to: number | null) =>
|
||||||
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
|
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) =>
|
setTag: (id: number, tag: string) =>
|
||||||
api.put<any, any>(`/accounts/${id}/tag`, { tag }),
|
api.put<any, any>(`/accounts/${id}/tag`, { tag }),
|
||||||
batchTag: (account_ids: number[], tag: string) =>
|
batchTag: (account_ids: number[], tag: string) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Layout, Menu, Avatar, Space, Typography, Button, Modal } from 'antd';
|
|||||||
import {
|
import {
|
||||||
DashboardOutlined, UserOutlined, LogoutOutlined,
|
DashboardOutlined, UserOutlined, LogoutOutlined,
|
||||||
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
|
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
|
||||||
MenuFoldOutlined, MenuUnfoldOutlined,
|
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
|
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 /> });
|
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')) {
|
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_assigned')) {
|
||||||
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
|
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user