From 48b6a1623bb78f014ec67a0b1647a6e93fac23c0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 16:23:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=88=86=E9=85=8D=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/schemas.cpython-312.pyc | Bin 5751 -> 6004 bytes .../__pycache__/accounts.cpython-312.pyc | Bin 10881 -> 17312 bytes web/backend/routers/accounts.py | 105 ++++- web/backend/schemas.py | 5 + web/frontend/src/App.tsx | 2 + web/frontend/src/api/modules.ts | 6 +- web/frontend/src/layouts/MainLayout.tsx | 7 +- web/frontend/src/pages/AssignmentsPage.tsx | 409 ++++++++++++++++++ 8 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 web/frontend/src/pages/AssignmentsPage.tsx diff --git a/web/backend/__pycache__/schemas.cpython-312.pyc b/web/backend/__pycache__/schemas.cpython-312.pyc index 9851552e56c01c1816a8eb0fd23f89290ce442e9..1dcce471e44a0f27700a45da27d3fcb4b11fbf86 100644 GIT binary patch delta 736 zcmZ9}U2D@&7zgm2F4<~gnsrHr8EBVvbc^_c4n$BlQtHYmZWQb43wxtD|m@r!5gpW|577NZJth4{`Z>Aq%+xRvHB5q zp5DeLXND@G#V4sq-oRykZ$=z(UrCp?%T+fBYWM2_H5C zky?s9JjRDlg>KwzmG`VK;uw9F4L18SYSC}GaLk|MWk>cEyl@n#AWiM4K8>1>_x))w z4B}t}xQDp(S~K8k+} zq(I-)d1PRf-pB8=Ja&{F`JP)v?f?y}ff!f^C2$v%fd~2~UO@&L^hCeGR;a75pV&YM zn*0hR&aTiEqkm6$18X;oSyrSs#>r$2z2Oe}X0D?1b4yOOoSqP2sh&*Zqs2d;ppdfw delta 540 zcmX|-O-n*S6o&6qT3x+YQ?FrBX7qKVk3|G6%E}CpNVAkEE3pXyqS>AZQ;%NYu7@XVJhsyz`zj=ggV!q2B?eqA0S&*ZKQc_T*CWFypZ| z$hsZ(wQwbA)mbBjB*S|q867*Of7f;F(>u#IEZ0a?OWglzNpEi-9Oa6sJ)@w@c?-Xp zw*m?<3Df~9U<0Or7NCz}E+^esRJyhfGJm;Z?PP3{K9!Ow#N!bvs1w=@GJ}99HV!pQ z&uXt6fCQ%LN4;PHT4?mpwJpx3sA`Lu=MbJp3por4EK$Zj%)(T$ddd&>Q)XJ><_?WI zM!FV}69*E24-hZMf8>{e9#Jd`39Qha8|dAm&_$5@T;brGQN!4Qz>8 zNk|||Iah)u$*y|n%@t}l5CDpN7fc*y(o0=KzDf9^Wm5CA1YK$U=3SUNHEBNj&|2#= o{20+SQrz(t@ke5O@odpg)Xq@J-6m&wD&xEGM7QpJ@-`>_06INz>;M1& diff --git a/web/backend/routers/__pycache__/accounts.cpython-312.pyc b/web/backend/routers/__pycache__/accounts.cpython-312.pyc index 5489a62d5a6cab01f549b2983685702920bd4408..7248339788ba6125fe9a7f1cebe95b4c3266526b 100644 GIT binary patch delta 7652 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 PvP({{@ZO6P=zje#wV>Ta delta 3070 zcmaJ@eQX?85#P7ByLadN^nLp5JNx_*+h?3OklL}^#Ii#^N?l6iI;yzRTrRh3XRr5_ zw|g{J&NvY*5ET?fFH%729|0k%C_zPaArh*T@(+K21XMw&U5FfmQYGpiT!_S7^JeDm>UYWcW5d7qc-lA^>vtyecRD%lAGl}^a|afWKC)dJPR|og z7G+1lUu;je7X#@)F_;b(L+OwycNW6MNIFuCrlaP%tE^N=4VlJC;&Sl+_@2KS^_(E zIn=aZu6Q;rbd5!tGO!X>dgTaMjw(*ML-8y+Y{0SeqahxqUT;XC5sQiEp_CsVB_c}zOtSsngvaqj7|wb< za9SVR@PxdX_9hqr1C}VzA{w+r!A+5rPoc=)e0IWp|1SbplUUeyh4izZ`{IyPzw_;0 z4IAR=szR5k3$PRo7y>O96hm563%Ww-Zj|ptf-yHlRV$TsI*7cZOl5^;PA(g^Y@tB? zsI&{o818zqnx-z56ggAXz|NOULstz8Ms3nTrY zXGq|{Hx+6Kl`_@U4amxr-SYHV*rv}R*~fy>VafF2%WO8y27w;jz+Psj70?50cH3b zPpIRD^LnFIY$a%o&&SxTr_&1s)rLP%@9-~kDG$qbcZnllsmjiG50NUn)}35E1#9{> z6k>kdyZQdT?|tXNTR&L1#?zN^YXL~gL-!zWCiXaT7;r;eRH>$$$!z5|_9^-dk`qYW zNX#tG0+*7gih}L$GC^lhI1faVfLI}=Nnj1HmG;eG($gS31E2Oe$3E@(30qITDD=U4 z?f#xc;(r}DT0nwVr2Xvpcr0B8HsySp_q2jqScq1&d>uK{Pw{Dfnr>>SZh8pi^YM6C z)L?yyeLh}=8p%%lmJF0YQ1t=1|K809=PrEq_O<&T{OsPvn+=~}<8X@oVJO08CR3}X zb1uWRl@{9L4m~aGHno&7q-sUZLQ`pYygjJajgG--tscYDaGu1f$>`Z7x{R0kF~|Ns zc@@0>_Ee3mVuim%@sA7?_14j5C7aJafi@hns_AMer<-+|5?YE3Gu0rvh8c-*vfj~I z005q;(G}QuA3hE54*kZ>xxE)BFHBxOa%Jw)+VXQze)X|;{1zxamY-K2{OG3()QVn< zo12N*^0X%qy_tuGBr64_88yLr=dCt51IpUicHg}gVlxNNgU*Mj_cjo`09)_b&(2QI zg)tLTI}Lj#BbRfTjKNcdcC)`uzq$$?CwB@uhekc1L%m2aN6a>F2(nVqDE=x@{2ek} z6@?a6X!zxlX8nptv|I(J;lDw-SWycK{RZmUp!5s@Lu+IUSEZq^XRw57{P@IB}`BzUoF9K~B$ujbfSEX&n`GEFaojKHV;l4m0`-Tu*! zxzTm*h1=W<>)hTu+%S7}W{O>%88{e-{cw8NV7}{&)J2#ZIOHrGaEKeI P^C;WBmOC&-%y#fU>Ycgu 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" + /> +