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
+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>
);
}