feat: 账号管理支持按账号名批量选号打标签,并支持复制账号名
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user