feat: 为CK页面增加标签筛选

This commit is contained in:
yml2213
2026-08-13 16:24:35 +08:00
parent 47ded2bc3e
commit a223cb726d
6 changed files with 157 additions and 23 deletions
+43
View File
@@ -14,6 +14,7 @@ from web.backend.models import Account, LoginTask, User
from web.backend.routers.cookies import ( from web.backend.routers.cookies import (
check_cookie_operations, check_cookie_operations,
get_cookie, get_cookie,
list_cookie_operation_tags,
list_cookie_operations, list_cookie_operations,
list_cookies, list_cookies,
) )
@@ -62,6 +63,7 @@ class CookieOperationTests(unittest.TestCase):
def test_support_operation_list_is_scoped_and_never_returns_credentials(self): def test_support_operation_list_is_scoped_and_never_returns_credentials(self):
result = list_cookie_operations( result = list_cookie_operations(
search="", search="",
tag="",
page=1, page=1,
page_size=20, page_size=20,
db=self.session, db=self.session,
@@ -72,6 +74,7 @@ class CookieOperationTests(unittest.TestCase):
self.assertEqual(result["items"], [{ self.assertEqual(result["items"], [{
"id": self.owned_task.id, "id": self.owned_task.id,
"account_username": "owned-account", "account_username": "owned-account",
"tag": "",
"ck_check_status": "", "ck_check_status": "",
"ck_checked_at": None, "ck_checked_at": None,
"created_at": None, "created_at": None,
@@ -85,6 +88,45 @@ class CookieOperationTests(unittest.TestCase):
with self.assertRaisesRegex(Exception, "cookie:view"): with self.assertRaisesRegex(Exception, "cookie:view"):
get_cookie(self.owned_task.id, db=self.session, current=self.support) get_cookie(self.owned_task.id, db=self.session, current=self.support)
def test_cookie_list_filters_by_account_tag(self):
admin = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(admin)
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "A组"})
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "B组"})
self.session.commit()
result = list_cookies(
search="",
tag="A组",
account_names="",
page=1,
page_size=20,
include_cookie=False,
db=self.session,
current=admin,
)
self.assertEqual(result["total"], 1)
self.assertEqual([item["id"] for item in result["items"]], [self.owned_task.id])
def test_support_operation_tag_filter_and_list_stay_scoped(self):
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "我的标签"})
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "别人的标签"})
self.session.commit()
tags = list_cookie_operation_tags(db=self.session, current=self.support)
result = list_cookie_operations(
search="",
tag="别人的标签",
page=1,
page_size=20,
db=self.session,
current=self.support,
)
self.assertEqual(tags, ["我的标签"])
self.assertEqual(result["total"], 0)
@patch("web.backend.routers.cookies._check_one_cookie") @patch("web.backend.routers.cookies._check_one_cookie")
def test_support_can_check_only_assigned_cookie(self, check_one_cookie): def test_support_can_check_only_assigned_cookie(self, check_one_cookie):
check_one_cookie.return_value = { check_one_cookie.return_value = {
@@ -119,6 +161,7 @@ class CookieOperationTests(unittest.TestCase):
with self.assertRaisesRegex(Exception, "cookie:operate"): with self.assertRaisesRegex(Exception, "cookie:operate"):
list_cookie_operations( list_cookie_operations(
search="", search="",
tag="",
page=1, page=1,
page_size=20, page_size=20,
db=self.session, db=self.session,
+37 -1
View File
@@ -163,6 +163,7 @@ def _visible_cookie_tasks_query(db: Session, current: User):
@router.get("") @router.get("")
def list_cookies( def list_cookies(
search: str = Query(""), search: str = Query(""),
tag: str = Query(""),
account_names: str = Query(""), account_names: str = Query(""),
page: int | None = Query(None, ge=1), page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200), page_size: int = Query(20, ge=1, le=200),
@@ -180,6 +181,7 @@ def list_cookies(
joinedload(LoginTask.account).joinedload(Account.assigned_user), joinedload(LoginTask.account).joinedload(Account.assigned_user),
) )
search_text = (search or "").strip() search_text = (search or "").strip()
tag_value = (tag or "").strip()
selected_names = _parse_account_names(account_names) selected_names = _parse_account_names(account_names)
can_view_all = user_has_permission(current, "login:view_all") can_view_all = user_has_permission(current, "login:view_all")
account_joined = not can_view_all account_joined = not can_view_all
@@ -188,6 +190,11 @@ def list_cookies(
query = query.join(Account, LoginTask.account_id == Account.id) query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True account_joined = True
query = query.filter(Account.username.in_(selected_names)) query = query.filter(Account.username.in_(selected_names))
if tag_value:
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.filter(Account.tag == tag_value)
if search_text: if search_text:
pattern = f"%{search_text}%" pattern = f"%{search_text}%"
if not account_joined: if not account_joined:
@@ -195,6 +202,7 @@ def list_cookies(
account_joined = True account_joined = True
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_( query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_(
Account.username.ilike(pattern), Account.username.ilike(pattern),
Account.tag.ilike(pattern),
User.username.ilike(pattern), User.username.ilike(pattern),
)) ))
@@ -276,6 +284,7 @@ def cookies_summary(
@router.get("/operations") @router.get("/operations")
def list_cookie_operations( def list_cookie_operations(
search: str = Query(""), search: str = Query(""),
tag: str = Query(""),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200), page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -287,8 +296,14 @@ def list_cookie_operations(
if user_has_permission(current, "login:view_all"): if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id) query = query.join(Account, LoginTask.account_id == Account.id)
search_text = (search or "").strip() search_text = (search or "").strip()
tag_value = (tag or "").strip()
if tag_value:
query = query.filter(Account.tag == tag_value)
if search_text: if search_text:
query = query.filter(Account.username.ilike(f"%{search_text}%")) query = query.filter(or_(
Account.username.ilike(f"%{search_text}%"),
Account.tag.ilike(f"%{search_text}%"),
))
total = query.order_by(None).count() total = query.order_by(None).count()
tasks = ( tasks = (
@@ -302,6 +317,7 @@ def list_cookie_operations(
{ {
"id": task.id, "id": task.id,
"account_username": task.account.username if task.account else "", "account_username": task.account.username if task.account else "",
"tag": task.account.tag if task.account else "",
"ck_check_status": task.ck_check_status or "", "ck_check_status": task.ck_check_status or "",
"ck_checked_at": _fmt_dt(task.ck_checked_at), "ck_checked_at": _fmt_dt(task.ck_checked_at),
"created_at": _fmt_dt(task.finished_at), "created_at": _fmt_dt(task.finished_at),
@@ -314,6 +330,26 @@ def list_cookie_operations(
} }
@router.get("/operations/tags")
def list_cookie_operation_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。"""
_require_cookie_operation_perm(current)
query = _visible_cookie_tasks_query(db, current)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
rows = (
query.filter(Account.tag != "", Account.tag.isnot(None))
.with_entities(Account.tag)
.distinct()
.order_by(Account.tag.asc())
.all()
)
return [tag for tag, in rows if tag]
@router.get("/duplicates") @router.get("/duplicates")
def find_duplicate_cookies( def find_duplicate_cookies(
db: Session = Depends(get_db), db: Session = Depends(get_db),
+3 -2
View File
@@ -3,11 +3,12 @@ import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieIt
export const cookieApi = { export const cookieApi = {
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'), list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
listPaged: (params: PageParams & { include_cookie?: boolean; account_names?: string }) => listPaged: (params: PageParams & { include_cookie?: boolean; account_names?: string; tag?: string }) =>
api.get<PaginatedResponse<CookieItem>, PaginatedResponse<CookieItem>>('/cookies', { params }), api.get<PaginatedResponse<CookieItem>, PaginatedResponse<CookieItem>>('/cookies', { params }),
summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'), summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'),
listOperations: (params: PageParams) => listOperations: (params: PageParams & { tag?: string }) =>
api.get<PaginatedResponse<CookieOperationItem>, PaginatedResponse<CookieOperationItem>>('/cookies/operations', { params }), api.get<PaginatedResponse<CookieOperationItem>, PaginatedResponse<CookieOperationItem>>('/cookies/operations', { params }),
listOperationTags: () => api.get<string[], string[]>('/cookies/operations/tags'),
duplicates: () => api.get<CookieDuplicateResponse, CookieDuplicateResponse>('/cookies/duplicates'), duplicates: () => api.get<CookieDuplicateResponse, CookieDuplicateResponse>('/cookies/duplicates'),
get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`), get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`),
exportCsv: (format?: string, accountNames?: string) => api.get<Blob, Blob>('/cookies/export', { exportCsv: (format?: string, accountNames?: string) => api.get<Blob, Blob>('/cookies/export', {
+1
View File
@@ -256,6 +256,7 @@ export interface CookieItem {
export interface CookieOperationItem { export interface CookieOperationItem {
id: number; id: number;
account_username: string; account_username: string;
tag: string;
ck_check_status: string; ck_check_status: string;
ck_checked_at: string | null; ck_checked_at: string | null;
created_at: string | null; created_at: string | null;
+45 -14
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button, Input, Popconfirm, Space, Table, Tag, Typography } from 'antd'; import { Button, Input, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import { LoadingOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { LoadingOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules'; import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules';
@@ -12,6 +12,8 @@ export default function CookieOperationsPage() {
const [items, setItems] = useState<CookieOperationItem[]>([]); const [items, setItems] = useState<CookieOperationItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set()); const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set());
const [reloginIds, setReloginIds] = useState<Set<number>>(new Set()); const [reloginIds, setReloginIds] = useState<Set<number>>(new Set());
@@ -30,6 +32,7 @@ export default function CookieOperationsPage() {
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
search: search.trim() || undefined, search: search.trim() || undefined,
tag: tagFilter || undefined,
}); });
setItems(data.items); setItems(data.items);
setTotal(data.total); setTotal(data.total);
@@ -50,12 +53,18 @@ export default function CookieOperationsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, pageSize, search]); }, [currentPage, pageSize, search, tagFilter]);
useEffect(() => { useEffect(() => {
void loadItems(); void loadItems();
}, [loadItems]); }, [loadItems]);
useEffect(() => {
cookieApi.listOperationTags().then(setTags).catch(() => {
// 标签加载失败不影响 CK 检测与重登。
});
}, []);
const handleCheck = async (ids: number[]) => { const handleCheck = async (ids: number[]) => {
if (ids.length === 0) return; if (ids.length === 0) return;
setCheckingIds((previous) => new Set([...previous, ...ids])); setCheckingIds((previous) => new Set([...previous, ...ids]));
@@ -103,6 +112,13 @@ export default function CookieOperationsPage() {
const selectedIds = selectedRowKeys.map((key) => Number(key)); const selectedIds = selectedRowKeys.map((key) => Number(key));
const columns = [ const columns = [
{ title: '账号', dataIndex: 'account_username', ellipsis: true }, { title: '账号', dataIndex: 'account_username', ellipsis: true },
{
title: '标签',
dataIndex: 'tag',
width: 140,
ellipsis: true,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{ {
title: '有效性', title: '有效性',
width: 110, width: 110,
@@ -168,18 +184,33 @@ export default function CookieOperationsPage() {
</Popconfirm> </Popconfirm>
</Space> </Space>
</div> </div>
<Input.Search <Space wrap style={{ marginBottom: 12 }}>
allowClear <Select
prefix={<SearchOutlined />} allowClear
placeholder="搜索账号" showSearch
style={{ width: 280, marginBottom: 12 }} placeholder="按标签筛选"
value={search} style={{ width: 160 }}
onChange={(event) => { value={tagFilter || undefined}
setSearch(event.target.value); onChange={(value) => {
setCurrentPage(1); setTagFilter(value || '');
setSelectedRowKeys([]); setCurrentPage(1);
}} setSelectedRowKeys([]);
/> }}
options={tags.map((tag) => ({ value: tag, label: tag }))}
/>
<Input.Search
allowClear
prefix={<SearchOutlined />}
placeholder="搜索账号或标签"
style={{ width: 280 }}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
setSelectedRowKeys([]);
}}
/>
</Space>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
+28 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Table, Button, Card, Row, Col, Statistic, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown, Tooltip, Modal, Alert, Empty } from 'antd'; import { Table, Button, Card, Row, Col, Statistic, Tag, Popconfirm, Space, Typography, Input, Select, theme, Dropdown, Tooltip, Modal, Alert, Empty } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined, ReloadOutlined, FilterOutlined, ScanOutlined } from '@ant-design/icons'; import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined, ReloadOutlined, FilterOutlined, ScanOutlined } from '@ant-design/icons';
import { cookieApi, type BasicSummary, type CookieCheckResult, type CookieDuplicateGroup, type CookieDuplicateResponse, type CookieItem } from '../api/modules'; import { accountApi, cookieApi, type BasicSummary, type CookieCheckResult, type CookieDuplicateGroup, type CookieDuplicateResponse, type CookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
@@ -53,6 +53,8 @@ export default function CookiePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [customNamesText, setCustomNamesText] = useState(''); const [customNamesText, setCustomNamesText] = useState('');
const [customAccountNames, setCustomAccountNames] = useState<string[]>([]); const [customAccountNames, setCustomAccountNames] = useState<string[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -181,6 +183,7 @@ export default function CookiePage() {
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
search: searchText.trim() || undefined, search: searchText.trim() || undefined,
tag: tagFilter || undefined,
account_names: customAccountNames.length > 0 ? customAccountNames.join('\n') : undefined, account_names: customAccountNames.length > 0 ? customAccountNames.join('\n') : undefined,
include_cookie: false, include_cookie: false,
}); });
@@ -208,7 +211,7 @@ export default function CookiePage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, pageSize, searchText, customAccountNames]); }, [currentPage, pageSize, searchText, tagFilter, customAccountNames]);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
try { try {
@@ -227,6 +230,12 @@ export default function CookiePage() {
loadSummary(); loadSummary();
}, [loadSummary]); }, [loadSummary]);
useEffect(() => {
accountApi.listTags().then(setTags).catch(() => {
// 标签加载失败不影响 Cookie 管理。
});
}, []);
const handleExport = async (format: string = 'csv') => { const handleExport = async (format: string = 'csv') => {
try { try {
const blob = await cookieApi.exportCsv( const blob = await cookieApi.exportCsv(
@@ -626,9 +635,22 @@ export default function CookiePage() {
</Space> </Space>
</Space> </Space>
</Card> </Card>
<div style={{ marginBottom: 12 }}> <Space wrap style={{ marginBottom: 12 }}>
<Select
allowClear
showSearch
placeholder="按标签筛选"
style={{ width: 160 }}
value={tagFilter || undefined}
onChange={(value) => {
setTagFilter(value || '');
setCurrentPage(1);
setSelectedRowKeys([]);
}}
options={tags.map((tag) => ({ value: tag, label: tag }))}
/>
<Input.Search <Input.Search
placeholder="搜索账号或分配客服" placeholder="搜索账号、标签或分配客服"
allowClear allowClear
value={searchText} value={searchText}
onChange={(e) => { onChange={(e) => {
@@ -639,7 +661,7 @@ export default function CookiePage() {
size="small" size="small"
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
/> />
</div> </Space>
<Table <Table
rowSelection={{ rowSelection={{
selectedRowKeys, selectedRowKeys,