diff --git a/web/backend/__pycache__/schemas.cpython-312.pyc b/web/backend/__pycache__/schemas.cpython-312.pyc index 9851552..1dcce47 100644 Binary files a/web/backend/__pycache__/schemas.cpython-312.pyc and b/web/backend/__pycache__/schemas.cpython-312.pyc differ diff --git a/web/backend/routers/__pycache__/accounts.cpython-312.pyc b/web/backend/routers/__pycache__/accounts.cpython-312.pyc index 5489a62..7248339 100644 Binary files a/web/backend/routers/__pycache__/accounts.cpython-312.pyc and b/web/backend/routers/__pycache__/accounts.cpython-312.pyc differ diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 8339581..d62ec7a 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ..database import get_db -from ..models import User, Account, AuditLog -from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut +from ..models import User, Account, AuditLog, LoginTask +from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign from ..deps import get_current_user, require_permission from ..permissions import has_permission +from sqlalchemy import func router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) @@ -26,16 +27,30 @@ def _split_account_line(line: str) -> list[str]: 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]) def list_accounts( assigned_only: bool = Query(False), tag: str = Query(None), + has_cookie: bool = Query(False), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """列表:按角色返回不同字段和范围。""" 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 has_permission(current.role, "account:view_assigned"): @@ -135,6 +150,15 @@ def assign_account( if target.role != "support": 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 db.add(AuditLog(user_id=current.id, username=current.username, action="account:assign", target=acc.username)) @@ -142,6 +166,83 @@ def assign_account( 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") def set_account_tag( account_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 17f6dfd..1edaed3 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -57,6 +57,11 @@ class AccountAssign(BaseModel): assigned_to: Optional[int] = None +class BatchAssign(BaseModel): + account_ids: list[int] + assigned_to: Optional[int] = None # None=取消分配 + + class AccountTag(BaseModel): tag: Optional[str] = None account_ids: Optional[list[int]] = None diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 7c59e83..575bfb3 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -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() { > } /> } /> + } /> } /> } /> } /> diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 2512f23..ef72bce 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -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('/accounts', { params }), import: (text: string) => api.post('/accounts/import', { text }), assign: (id: number, assigned_to: number | null) => api.put(`/accounts/${id}/assign`, { assigned_to }), + batchAssign: (account_ids: number[], assigned_to: number | null) => + api.post('/accounts/batch-assign', { account_ids, assigned_to }), + assignmentsSummary: () => + api.get('/accounts/assignments/summary'), setTag: (id: number, tag: string) => api.put(`/accounts/${id}/tag`, { tag }), batchTag: (account_ids: number[], tag: string) => diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index f4fd506..c9f23c1 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -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: }); } + // 分配管理 + if (hasPerm(user, 'account:assign')) { + menuItems.push({ key: '/assignments', label: '分配管理', icon: }); + } + // 登录任务 if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_assigned')) { menuItems.push({ key: '/login-tasks', label: '登录任务', icon: }); diff --git a/web/frontend/src/pages/AssignmentsPage.tsx b/web/frontend/src/pages/AssignmentsPage.tsx new file mode 100644 index 0000000..2e7f672 --- /dev/null +++ b/web/frontend/src/pages/AssignmentsPage.tsx @@ -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([]); + const [supportUsers, setSupportUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [searchText, setSearchText] = useState(''); + const [filterTag, setFilterTag] = useState(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} : -, + }, + ]; + + return ( +
+

分配管理

+ + {/* 统计卡片 */} + + + } /> + + + + } /> + + + + + } /> + + + + + } /> + + + + + {/* 主体区域 */} + + {/* 左栏:客服列表 */} + + +
+ {supportUsers.map((user) => { + const isSelected = selectedUser?.id === user.id; + return ( +
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'; + }} + > + + + {user.username} + + +
+ ); + })} + {supportUsers.length === 0 && ( + + 暂无客服用户 + + )} +
+
+ + + {/* 右栏:账号表格 */} + + + {selectedUser ? ( + <> + 已选中客服: + + {selectedUser.username}(已分配 {selectedUser.assigned_count} 个) + + + ) : ( + 请选择左侧客服进行操作 + )} + + } + style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }} + bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 }} + > + {/* 筛选栏 */} +
+ setSearchText(e.target.value)} + style={{ width: 200 }} + size="small" + /> +