优化分配功能

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
Binary file not shown.
+103 -2
View File
@@ -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,
+5
View File
@@ -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
+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>
);
}