feat: 账号管理支持按账号名批量选号打标签,并支持复制账号名

This commit is contained in:
yml2213
2026-10-20 17:31:39 +08:00
parent c299f63537
commit c778ae9a09
8 changed files with 465 additions and 10 deletions
@@ -0,0 +1,94 @@
"""账号管理「按账号名批量选号」相关测试。"""
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, User
from web.backend.routers.accounts import (
_parse_account_names,
batch_tag_selection,
list_account_ids,
list_accounts,
)
from web.backend.schemas import AccountBulkTag
class TestAccountNameBatchSelection:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(
username="name-filter-admin",
password_hash="hash",
role="super_admin",
is_active=True,
)
self.session.add(self.admin)
self.session.commit()
self.accounts = {}
for name in ("name-filter-a", "name-filter-b", "name-filter-c"):
account = Account(
username=name,
password="password",
email=f"{name}@example.com",
email_password="mail-password",
cookie="acf_uid=1",
)
self.session.add(account)
self.session.flush()
self.accounts[name] = account
self.session.commit()
def teardown_method(self):
self.session.close()
def test_parse_account_names_trims_and_dedupes(self):
assert _parse_account_names(
"name-filter-c\n\nname-filter-a\r\nname-filter-c\n name-filter-b "
) == ["name-filter-c", "name-filter-a", "name-filter-b"]
def test_list_accounts_filters_by_exact_names(self):
result = list_accounts(
account_names="name-filter-c\nname-filter-missing\nname-filter-a",
page=1,
page_size=20,
db=self.session,
current=self.admin,
)
assert result["total"] == 2
assert {item.username for item in result["items"]} == {
"name-filter-a",
"name-filter-c",
}
def test_list_account_ids_follows_paste_order(self):
result = list_account_ids(
account_names="name-filter-c\nname-filter-missing\nname-filter-a",
db=self.session,
current=self.admin,
)
assert result["account_ids"] == [
self.accounts["name-filter-c"].id,
self.accounts["name-filter-a"].id,
]
assert result["usernames"] == ["name-filter-c", "name-filter-a"]
assert result["total"] == 2
def test_batch_tag_selection_supports_account_names(self):
result = batch_tag_selection(
req=AccountBulkTag(
all_matching=True,
account_names="name-filter-a\nname-filter-b",
tag_value="按名单标签",
),
db=self.session,
current=self.admin,
)
assert result["count"] == 2
for name in ("name-filter-a", "name-filter-b"):
self.session.refresh(self.accounts[name])
assert self.accounts[name].tag == "按名单标签"
self.session.refresh(self.accounts["name-filter-c"])
assert self.accounts["name-filter-c"].tag == ""
+96
View File
@@ -98,6 +98,20 @@ def _query_bool(value) -> bool:
return value is True
def _parse_account_names(raw_names: str) -> list[str]:
"""解析粘贴的账号名:每行一个,去空白并按输入顺序去重。"""
names: list[str] = []
seen: set[str] = set()
for value in (
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
):
name = value.strip()
if name and name not in seen:
names.append(name)
seen.add(name)
return names
def _filter_accounts_query(
db: Session,
query,
@@ -109,6 +123,7 @@ def _filter_accounts_query(
tag: str | None = None,
has_cookie: bool = False,
search: str = "",
account_names: str = "",
status: str = "",
ck_status: str = "",
failure_kind: str = "",
@@ -140,6 +155,9 @@ def _filter_accounts_query(
tag_value = _query_str(tag)
if tag_value:
query = query.filter(Account.tag == tag_value)
selected_names = _parse_account_names(_query_str(account_names))
if selected_names:
query = query.filter(Account.username.in_(selected_names))
status_value = _query_str(status)
if status_value:
query = query.filter(Account.status == status_value)
@@ -248,6 +266,7 @@ def _selected_account_ids(
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
account_names=req.account_names,
status=req.status,
ck_status=req.ck_status,
failure_kind=req.failure_kind,
@@ -398,6 +417,9 @@ def list_accounts(
),
assigned_user_id: int | None = Query(None, description="只看某个客服名下的账号"),
tag: str = Query(None),
account_names: str = Query(
"", description="多行斗鱼账号名(每行一个),精确匹配并自动去重"
),
has_cookie: bool = Query(False),
search: str = Query(""),
status: str = Query("", description="按生命周期状态筛选;空=全部(不含归档)"),
@@ -446,6 +468,7 @@ def list_accounts(
tag=tag,
has_cookie=has_cookie,
search=search,
account_names=account_names,
status=status,
ck_status=ck_status,
failure_kind=failure_kind,
@@ -623,6 +646,79 @@ def list_accounts(
return result
@router.get("/ids")
def list_account_ids(
assigned_only: bool = Query(False),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
tag: str = Query(None),
has_cookie: bool = Query(False),
search: str = Query(""),
account_names: str = Query(
"", description="多行斗鱼账号名(每行一个),精确匹配并自动去重"
),
status: str = Query(""),
ck_status: str = Query(""),
failure_kind: str = Query(""),
bind_status: str = Query(""),
high_value_filter: str = Query(""),
min_points: int | None = Query(None),
max_points: int | None = Query(None),
min_gold: float | None = Query(None),
max_gold: float | None = Query(None),
elite_opened: bool | None = Query(None),
esports_opened: bool | None = Query(None),
role_bind_status: str = Query(""),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前筛选条件下的全部账号 ID,并按粘贴的账号名顺序排序。
前端「按账号名批量选号」用它一次性拿到所有匹配 ID,再写入表格的选择集,
这样跨分页勾选和后续批量标签都不受当前页限制。
"""
query = _filter_accounts_query(
db,
_visible_accounts_query(db, current),
current,
assigned_only=assigned_only,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
tag=tag,
has_cookie=has_cookie,
search=search,
account_names=account_names,
status=status,
ck_status=ck_status,
failure_kind=failure_kind,
bind_status=bind_status,
high_value_filter=high_value_filter,
min_points=min_points,
max_points=max_points,
min_gold=min_gold,
max_gold=max_gold,
elite_opened=elite_opened,
esports_opened=esports_opened,
role_bind_status=role_bind_status,
)
selected_names = _parse_account_names(_query_str(account_names))
if selected_names:
input_order = case(
{name: index for index, name in enumerate(selected_names)},
value=Account.username,
else_=len(selected_names),
)
query = query.order_by(input_order, Account.id.desc())
else:
query = query.order_by(Account.id.desc())
rows = query.with_entities(Account.id, Account.username).all()
return {
"account_ids": [account_id for account_id, _ in rows],
"usernames": [username for _, username in rows],
"total": len(rows),
}
@router.get("/summary")
def accounts_summary(
db: Session = Depends(get_db),
+2
View File
@@ -130,6 +130,8 @@ class AccountBulkSelection(BaseModel):
all_matching: bool = False
search: str = ""
tag: str = ""
#: 多行斗鱼账号名(每行一个),与其它筛选条件叠加
account_names: str = ""
assigned_only: bool = False
#: 归属筛选:""=全部 / unassigned=未分配 / assigned=已分配
assign_status: str = ""
+28
View File
@@ -45,6 +45,34 @@ export const accountApi = {
},
) =>
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
// 按当前筛选条件返回全部账号 ID(不受分页限制),供「按账号名批量选号」使用
listIds: (
params?: {
search?: string;
assigned_only?: boolean;
assign_status?: string;
assigned_user_id?: number;
tag?: string;
has_cookie?: boolean;
status?: string;
ck_status?: string;
failure_kind?: string;
bind_status?: string;
high_value_filter?: string;
min_points?: number;
max_points?: number;
min_gold?: number;
max_gold?: number;
elite_opened?: boolean;
esports_opened?: boolean;
role_bind_status?: string;
account_names?: string;
},
) =>
api.get<
{ account_ids: number[]; usernames: string[]; total: number },
{ account_ids: number[]; usernames: string[]; total: number }
>('/accounts/ids', { params }),
export: (data: AccountExportRequest) =>
api.post('/accounts/export', data, { responseType: 'blob' }),
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
+2
View File
@@ -29,6 +29,8 @@ export interface AccountBulkSelection {
all_matching?: boolean;
search?: string;
tag?: string;
/** 多行斗鱼账号名(每行一个),与其它筛选条件叠加 */
account_names?: string;
assigned_only?: boolean;
/** 归属筛选:unassigned=未分配 / assigned=已分配;空=全部 */
assign_status?: string;
+105 -1
View File
@@ -25,6 +25,8 @@ import {
clearStoredFilters,
} from './accounts/storage';
import { AccountToolbar } from './accounts/components/AccountToolbar';
import { AccountNameBatchSelector } from './accounts/components/AccountNameBatchSelector';
import type { AccountNameMatchInfo } from './accounts/components/AccountNameBatchSelector';
import { StatusCards } from './accounts/components/StatusCards';
import { FilterPanel } from './accounts/components/FilterPanel';
import { buildAccountColumns } from './accounts/components/AccountColumns';
@@ -35,6 +37,23 @@ import {
ExchangeRecordsModal,
} from './accounts/components/AccountModals';
function parseAccountNames(raw: string): string[] {
const names: string[] = [];
const seen = new Set<string>();
raw
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.forEach((line) => {
const name = line.trim();
if (name && !seen.has(name)) {
names.push(name);
seen.add(name);
}
});
return names;
}
export default function AccountsPage() {
const navigate = useNavigate();
const { modal } = App.useApp();
@@ -64,6 +83,12 @@ export default function AccountsPage() {
const [searchText, setSearchText] = useState(() => initialFilters.searchText || '');
const [exporting, setExporting] = useState(false);
// 按账号名批量选号:粘贴名单 -> 匹配 ID -> 自动勾选 -> 批量改标签
const [accountNameText, setAccountNameText] = useState('');
const [accountNamesFilter, setAccountNamesFilter] = useState('');
const [accountNameMatchInfo, setAccountNameMatchInfo] = useState<AccountNameMatchInfo | null>(null);
const [accountNameMatching, setAccountNameMatching] = useState(false);
// 高价值资产与高级自定义筛选状态
const [highValueFilter, setHighValueFilter] = useState<string>(() => initialFilters.highValueFilter || '');
const [minPoints, setMinPoints] = useState<number | undefined>(() => initialFilters.minPoints);
@@ -99,8 +124,12 @@ export default function AccountsPage() {
setBindFilter('');
setAssignFilter('');
setSearchText('');
setAccountNameText('');
setAccountNamesFilter('');
setAccountNameMatchInfo(null);
resetAdvancedFiltersState();
setCurrentPage(1);
clearSelection();
clearStoredFilters();
message.success('已重置所有筛选条件');
};
@@ -172,6 +201,7 @@ export default function AccountsPage() {
include_sensitive: canViewFull && sensitiveVisible,
};
if (tagFilter) params.tag = tagFilter;
if (accountNamesFilter) params.account_names = accountNamesFilter;
if (statusFilter) params.status = statusFilter;
if (ckFilter) params.ck_status = ckFilter;
if (failureKindFilter) params.failure_kind = failureKindFilter;
@@ -206,6 +236,7 @@ export default function AccountsPage() {
searchText,
sensitiveVisible,
tagFilter,
accountNamesFilter,
statusFilter,
ckFilter,
failureKindFilter,
@@ -271,6 +302,7 @@ export default function AccountsPage() {
all_matching: selectedAllMatching,
search: searchText.trim(),
tag: tagFilter,
account_names: accountNamesFilter || undefined,
status: statusFilter || undefined,
ck_status: ckFilter || undefined,
failure_kind: failureKindFilter || undefined,
@@ -291,6 +323,7 @@ export default function AccountsPage() {
selectedAllMatching,
selectedRowKeys,
tagFilter,
accountNamesFilter,
statusFilter,
ckFilter,
failureKindFilter,
@@ -468,6 +501,61 @@ export default function AccountsPage() {
roleBindStatus,
]);
const handleApplyAccountNames = async () => {
const names = parseAccountNames(accountNameText);
if (names.length === 0) {
message.warning('请先粘贴斗鱼账号名,每行一个');
return;
}
setAccountNameMatching(true);
try {
const result = await accountApi.listIds({
search: searchText.trim() || undefined,
tag: tagFilter || undefined,
status: statusFilter || undefined,
ck_status: ckFilter || undefined,
failure_kind: failureKindFilter || undefined,
bind_status: bindFilter || undefined,
assign_status: assignStatusParam || undefined,
assigned_user_id: assignedUserIdParam,
high_value_filter: highValueFilter || undefined,
min_points: minPoints,
max_points: maxPoints,
min_gold: minGold,
max_gold: maxGold,
elite_opened: eliteOpened,
esports_opened: esportsOpened,
role_bind_status: roleBindStatus || undefined,
account_names: names.join('\n'),
});
if (result.total === 0) {
message.warning('没有匹配到账号,请检查账号名或当前筛选条件');
return;
}
setAccountNamesFilter(names.join('\n'));
setAccountNameMatchInfo({ submitted: names.length, matched: result.total });
setSelectedRowKeys(result.account_ids);
setSelectedAllMatching(false);
setCurrentPage(1);
const unmatched = Math.max(0, names.length - result.total);
message.success(
`已匹配并勾选 ${result.total} 个账号${unmatched > 0 ? `,${unmatched} 个账号名未匹配` : ''}`,
);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAccountNameMatching(false);
}
};
const handleClearAccountNames = () => {
setAccountNameText('');
setAccountNamesFilter('');
setAccountNameMatchInfo(null);
clearSelection();
setCurrentPage(1);
};
const handleImport = async () => {
if (!importText.trim()) {
message.warning('请输入账号数据');
@@ -642,7 +730,8 @@ export default function AccountsPage() {
);
const hasAnyFilter = Boolean(
tagFilter ||
accountNamesFilter ||
tagFilter ||
statusFilter ||
ckFilter ||
failureKindFilter ||
@@ -750,6 +839,21 @@ export default function AccountsPage() {
setCurrentPage={setCurrentPage}
/>
<AccountNameBatchSelector
value={accountNameText}
onChange={setAccountNameText}
onApply={handleApplyAccountNames}
onClear={handleClearAccountNames}
onOpenBatchTag={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
loading={accountNameMatching}
canTag={canTag}
selectedCount={selectedCount}
matchInfo={accountNameMatchInfo}
/>
<StatusCards
summary={summary}
statusFilter={statusFilter}
@@ -1,8 +1,10 @@
import type { TableProps } from 'antd';
import { Space, Button, Typography, Tag, Tooltip, Select, Popconfirm } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import { CopyOutlined, DeleteOutlined } from '@ant-design/icons';
import type { NavigateFunction } from 'react-router-dom';
import type { AccountItem } from '../../../api/modules';
import { message } from '../../../utils/antdMessage';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { formatTimeShort } from '../../../utils/time';
import {
type AccountSortKey,
@@ -86,15 +88,49 @@ export function buildAccountColumns({
ellipsis: true,
render: (username: string, record: AccountItem) => (
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Button
type="link"
size="small"
style={{ padding: 0, maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
title={username}
onClick={() => navigate(`/accounts/${record.id}`)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 2,
maxWidth: '100%',
minWidth: 0,
}}
>
{username}
</Button>
<Button
type="link"
size="small"
style={{
padding: 0,
minWidth: 0,
flex: '0 1 auto',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={username}
onClick={() => navigate(`/accounts/${record.id}`)}
>
{username}
</Button>
<Tooltip title="复制账号名">
<Button
type="text"
size="small"
aria-label="复制账号名"
icon={<CopyOutlined style={{ fontSize: 12, color: '#8c8c8c' }} />}
style={{ padding: 0, width: 18, height: 18, minWidth: 18, flex: 'none' }}
onClick={async () => {
try {
await copyTextToClipboard(username);
message.success('账号名已复制');
} catch {
message.error('复制失败');
}
}}
/>
</Tooltip>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
#{record.id}
</Text>
@@ -0,0 +1,93 @@
import { Button, Card, Input, Space, Typography } from 'antd';
import {
CheckSquareOutlined,
ClearOutlined,
TagsOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
export interface AccountNameMatchInfo {
submitted: number;
matched: number;
}
export interface AccountNameBatchSelectorProps {
value: string;
onChange: (value: string) => void;
onApply: () => void;
onClear: () => void;
onOpenBatchTag: () => void;
loading: boolean;
canTag: boolean;
selectedCount: number;
matchInfo: AccountNameMatchInfo | null;
}
export function AccountNameBatchSelector({
value,
onChange,
onApply,
onClear,
onOpenBatchTag,
loading,
canTag,
selectedCount,
matchInfo,
}: AccountNameBatchSelectorProps) {
const unmatched = matchInfo ? matchInfo.submitted - matchInfo.matched : 0;
return (
<Card
size="small"
title={
<Space size={6}>
<TagsOutlined />
<span>按账号名批量选号</span>
</Space>
}
style={{ marginBottom: 16 }}
>
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<Input.TextArea
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="从 Excel 复制斗鱼账号名后粘贴到这里,每行一个;匹配后会自动勾选,可直接批量改标签"
autoSize={{ minRows: 3, maxRows: 7 }}
/>
<Space wrap>
<Button
type="primary"
icon={<CheckSquareOutlined />}
loading={loading}
onClick={onApply}
>
匹配并选中
</Button>
<Button
icon={<TagsOutlined />}
disabled={!canTag || selectedCount === 0}
onClick={onOpenBatchTag}
>
批量改标签{selectedCount > 0 ? `(${selectedCount})` : ''}
</Button>
<Button
icon={<ClearOutlined />}
disabled={!value.trim() && selectedCount === 0 && !matchInfo}
onClick={onClear}
>
清空
</Button>
{matchInfo && (
<Text type={unmatched > 0 ? 'warning' : 'secondary'}>
已粘贴 {matchInfo.submitted} 个账号名,匹配到 {matchInfo.matched} 个账号
{unmatched > 0
? `;${unmatched} 个未匹配或不在当前筛选范围内`
: ''}
</Text>
)}
</Space>
</Space>
</Card>
);
}