537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import {
|
|
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
|
Row, Col, Card, Statistic,
|
|
} from 'antd';
|
|
import { message } from '../utils/antdMessage';
|
|
import type { TableProps } from 'antd';
|
|
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
|
import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { formatTime } from '../utils/time';
|
|
import { getErrorMessage } from '../utils/error';
|
|
|
|
const { TextArea } = Input;
|
|
const { Text } = Typography;
|
|
|
|
const TAG_COLORS = [
|
|
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
|
|
];
|
|
|
|
export default function AccountsPage() {
|
|
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
|
const [users, setUsers] = useState<UserInfo[]>([]);
|
|
const [tags, setTags] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [importText, setImportText] = useState('');
|
|
const [importTag, setImportTag] = useState('');
|
|
const [importing, setImporting] = useState(false);
|
|
const [tagFilter, setTagFilter] = useState<string>('');
|
|
const [searchText, setSearchText] = useState('');
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
|
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
|
|
const [batchTagInput, setBatchTagInput] = useState('');
|
|
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
|
const [sensitiveVisible, setSensitiveVisible] = useState(false);
|
|
const [total, setTotal] = useState(0);
|
|
const [summary, setSummary] = useState<BasicSummary>({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 });
|
|
const [pageSize, setPageSize] = useState(() => {
|
|
const v = localStorage.getItem('account_page_size');
|
|
return v ? Number(v) || 20 : 20;
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const { can } = usePermissions();
|
|
|
|
const canViewFull = can('account:view_full');
|
|
const canImport = can('account:import');
|
|
const canTag = can('account:tag') || canImport;
|
|
const canAssign = can('account:assign');
|
|
const canDelete = can('account:delete');
|
|
const canSelectRows = canTag || canDelete || canAssign;
|
|
|
|
const loadAccounts = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: { tag?: string } = {};
|
|
if (tagFilter) params.tag = tagFilter;
|
|
const data = await accountApi.listPaged({
|
|
...params,
|
|
page: currentPage,
|
|
page_size: pageSize,
|
|
search: searchText.trim() || undefined,
|
|
include_sensitive: canViewFull && sensitiveVisible,
|
|
});
|
|
setAccounts(data.items);
|
|
setTotal(data.total);
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter]);
|
|
|
|
const loadSummary = useCallback(async () => {
|
|
try {
|
|
const data = await accountApi.summary();
|
|
setSummary(data);
|
|
} catch {
|
|
// 统计加载失败时不影响主列表操作。
|
|
}
|
|
}, []);
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
try {
|
|
const data = await userApi.list();
|
|
setUsers(data.filter((u) => u.role === 'support'));
|
|
} catch {
|
|
// 忽略客服列表加载失败,账号列表仍可继续使用。
|
|
}
|
|
}, []);
|
|
|
|
const loadTags = useCallback(async () => {
|
|
try {
|
|
const data = await accountApi.listTags();
|
|
setTags(data);
|
|
} catch {
|
|
// 忽略标签加载失败,页面会退化为无标签筛选。
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadAccounts();
|
|
}, [loadAccounts]);
|
|
|
|
useEffect(() => {
|
|
if (canAssign) loadUsers();
|
|
loadTags();
|
|
loadSummary();
|
|
}, [canAssign, loadUsers, loadTags, loadSummary]);
|
|
|
|
const tagColorMap = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
tags.forEach((t, i) => {
|
|
map[t] = TAG_COLORS[i % TAG_COLORS.length];
|
|
});
|
|
return map;
|
|
}, [tags]);
|
|
|
|
const selectedCount = selectedAllMatching ? total : selectedRowKeys.length;
|
|
const selectionPayload = useMemo(() => ({
|
|
account_ids: selectedAllMatching ? [] : selectedRowKeys.map((key) => Number(key)),
|
|
all_matching: selectedAllMatching,
|
|
search: searchText.trim(),
|
|
tag: tagFilter,
|
|
}), [searchText, selectedAllMatching, selectedRowKeys, tagFilter]);
|
|
|
|
const clearSelection = () => {
|
|
setSelectedRowKeys([]);
|
|
setSelectedAllMatching(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
clearSelection();
|
|
}, [searchText, tagFilter]);
|
|
|
|
const handleImport = async () => {
|
|
if (!importText.trim()) {
|
|
message.warning('请输入账号数据');
|
|
return;
|
|
}
|
|
setImporting(true);
|
|
try {
|
|
const result = await accountApi.import(importText, importTag);
|
|
message.success(result.message);
|
|
setImportOpen(false);
|
|
setImportText('');
|
|
setImportTag('');
|
|
loadAccounts();
|
|
loadTags();
|
|
loadSummary();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleAssign = async (accountId: number, assignedTo: number | null) => {
|
|
try {
|
|
await accountApi.assign(accountId, assignedTo);
|
|
message.success('已分配');
|
|
loadAccounts();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
const handleSetTag = async (accountId: number, tag: string) => {
|
|
try {
|
|
await accountApi.setTag(accountId, tag);
|
|
message.success('标签已更新');
|
|
loadAccounts();
|
|
loadTags();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
const handleBatchTag = async () => {
|
|
if (selectedCount === 0) {
|
|
message.warning('请先选择账号');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await accountApi.batchTagSelection({ ...selectionPayload, tag_value: batchTagInput });
|
|
message.success(result.message);
|
|
setBatchTagVisible(false);
|
|
setBatchTagInput('');
|
|
clearSelection();
|
|
loadAccounts();
|
|
loadTags();
|
|
loadSummary();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
try {
|
|
await accountApi.delete(id);
|
|
message.success('已删除');
|
|
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
|
|
setSelectedAllMatching(false);
|
|
loadAccounts();
|
|
loadTags();
|
|
loadSummary();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
const handleBatchDelete = async () => {
|
|
if (selectedCount === 0) {
|
|
message.warning('请先选择账号');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await accountApi.batchDeleteSelection(selectionPayload);
|
|
message.success(result.message);
|
|
clearSelection();
|
|
loadAccounts();
|
|
loadTags();
|
|
loadSummary();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
}
|
|
};
|
|
|
|
const handleSensitiveVisibility = () => {
|
|
if (sensitiveVisible) {
|
|
// 立即清除已加载的敏感内容,再由 effect 重新获取脱敏列表。
|
|
setAccounts((previous) => previous.map((account) => ({
|
|
...account,
|
|
password: undefined,
|
|
email: undefined,
|
|
email_password: undefined,
|
|
})));
|
|
setSensitiveVisible(false);
|
|
return;
|
|
}
|
|
Modal.confirm({
|
|
title: '显示账号凭据?',
|
|
content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。',
|
|
okText: '显示',
|
|
cancelText: '取消',
|
|
onOk: () => setSensitiveVisible(true),
|
|
});
|
|
};
|
|
|
|
const columns: TableProps<AccountItem>['columns'] = [
|
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
|
{ title: '用户名', dataIndex: 'username' },
|
|
{
|
|
title: '标签',
|
|
dataIndex: 'tag',
|
|
width: 170,
|
|
render: (tag: string, record: AccountItem) => {
|
|
if (canTag) {
|
|
return (
|
|
<Select
|
|
mode="tags"
|
|
maxCount={1}
|
|
allowClear
|
|
showSearch
|
|
optionFilterProp="label"
|
|
size="small"
|
|
placeholder="选择或输入标签"
|
|
style={{ width: 150 }}
|
|
value={tag ? [tag] : []}
|
|
onChange={(values) => {
|
|
const value = values.at(-1)?.trim() || '';
|
|
if (value !== tag) handleSetTag(record.id, value);
|
|
}}
|
|
options={tags.map((value) => ({ value, label: value }))}
|
|
/>
|
|
);
|
|
}
|
|
return tag ? <Tag color={tagColorMap[tag]}>{tag}</Tag> : <Text type="secondary">-</Text>;
|
|
},
|
|
},
|
|
{
|
|
title: '上传时间',
|
|
dataIndex: 'created_at',
|
|
width: 180,
|
|
render: (val: string | null) => val ? formatTime(val) : <Text type="secondary">-</Text>,
|
|
},
|
|
];
|
|
|
|
if (canViewFull && sensitiveVisible) {
|
|
columns.push(
|
|
{ title: '密码', dataIndex: 'password', width: 120 },
|
|
{ title: '邮箱', dataIndex: 'email' },
|
|
{ title: '邮箱密码', dataIndex: 'email_password', width: 120 },
|
|
);
|
|
}
|
|
|
|
columns.push({
|
|
title: '分配给',
|
|
dataIndex: 'assigned_username',
|
|
render: (_: unknown, record: AccountItem) => {
|
|
if (canAssign) {
|
|
return (
|
|
<Select
|
|
style={{ width: 140 }}
|
|
allowClear
|
|
placeholder="未分配"
|
|
value={record.assigned_to}
|
|
onChange={(val) => handleAssign(record.id, val ?? null)}
|
|
options={users.map((u) => ({ value: u.id, label: u.username }))}
|
|
/>
|
|
);
|
|
}
|
|
return record.assigned_username || <Text type="secondary">未分配</Text>;
|
|
},
|
|
});
|
|
|
|
if (canDelete) {
|
|
columns.push({
|
|
title: '操作',
|
|
width: 80,
|
|
render: (_: unknown, record: AccountItem) => (
|
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
|
<Button danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
<h2>账号管理</h2>
|
|
<Space>
|
|
<Select
|
|
allowClear
|
|
showSearch
|
|
optionFilterProp="label"
|
|
placeholder="按标签筛选"
|
|
style={{ width: 150 }}
|
|
value={tagFilter || undefined}
|
|
onChange={(val) => {
|
|
setTagFilter(val || '');
|
|
setCurrentPage(1);
|
|
}}
|
|
options={tags.map((t) => ({ value: t, label: t }))}
|
|
prefix={<FilterOutlined />}
|
|
/>
|
|
<Input.Search
|
|
allowClear
|
|
size="small"
|
|
placeholder="搜索账号/标签/备注"
|
|
style={{ width: 220 }}
|
|
value={searchText}
|
|
onChange={(e) => {
|
|
setSearchText(e.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
{canViewFull && (
|
|
<Button
|
|
icon={sensitiveVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
|
onClick={handleSensitiveVisibility}
|
|
>
|
|
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
|
|
</Button>
|
|
)}
|
|
{canTag && (
|
|
<Button
|
|
disabled={selectedCount === 0}
|
|
icon={<TagOutlined />}
|
|
onClick={() => {
|
|
setBatchTagInput('');
|
|
setBatchTagVisible(true);
|
|
}}
|
|
>
|
|
批量打标签
|
|
</Button>
|
|
)}
|
|
{canDelete && (
|
|
<Popconfirm
|
|
title={`确认删除${selectedAllMatching ? '当前筛选下' : '选中的'} ${selectedCount} 个账号?`}
|
|
description="将同时删除关联的登录任务"
|
|
onConfirm={handleBatchDelete}
|
|
okText="删除"
|
|
okButtonProps={{ danger: true }}
|
|
cancelText="取消"
|
|
>
|
|
<Button
|
|
danger
|
|
disabled={selectedCount === 0}
|
|
icon={<DeleteOutlined />}
|
|
>
|
|
批量删除
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
{canImport && (
|
|
<Button type="primary" icon={<ImportOutlined />} onClick={() => { setImportTag(''); setImportOpen(true); }}>
|
|
批量导入
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
</div>
|
|
|
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
|
<Col span={6}>
|
|
<Card size="small"><Statistic title="账号总数" value={summary.total} /></Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small"><Statistic title="标签数" value={summary.tag_count || tags.length} /></Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="已分配"
|
|
value={summary.assigned_count}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="未分配"
|
|
value={summary.unassigned_count}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Table
|
|
title={() => selectedRowKeys.length > 0 ? (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={(
|
|
<Space wrap>
|
|
<span>
|
|
{selectedAllMatching
|
|
? `已选择当前筛选下全部 ${total} 个账号`
|
|
: `已选择 ${selectedRowKeys.length} 个账号`}
|
|
</span>
|
|
{!selectedAllMatching && total > selectedRowKeys.length ? (
|
|
<Button size="small" type="link" onClick={() => setSelectedAllMatching(true)}>
|
|
选择当前筛选下全部 {total} 个
|
|
</Button>
|
|
) : null}
|
|
<Button size="small" type="link" onClick={clearSelection}>
|
|
取消选择
|
|
</Button>
|
|
</Space>
|
|
)}
|
|
/>
|
|
) : undefined}
|
|
rowSelection={canSelectRows ? {
|
|
selectedRowKeys,
|
|
onChange: (keys) => {
|
|
setSelectedRowKeys(keys);
|
|
setSelectedAllMatching(false);
|
|
},
|
|
preserveSelectedRowKeys: true,
|
|
} : undefined}
|
|
columns={columns}
|
|
dataSource={accounts}
|
|
rowKey="id"
|
|
loading={loading}
|
|
size="small"
|
|
pagination={{
|
|
current: currentPage,
|
|
pageSize,
|
|
total,
|
|
showSizeChanger: true,
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
onChange: (page, size) => {
|
|
setCurrentPage(page);
|
|
if (size !== pageSize) {
|
|
setPageSize(size);
|
|
localStorage.setItem('account_page_size', String(size));
|
|
setCurrentPage(1); // 切换 pageSize 时重置到第1页
|
|
}
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<Modal
|
|
title="批量导入账号"
|
|
open={importOpen}
|
|
onCancel={() => setImportOpen(false)}
|
|
onOk={handleImport}
|
|
confirmLoading={importing}
|
|
okText="导入"
|
|
width={600}
|
|
>
|
|
<Text type="secondary">
|
|
格式:用户名|密码|邮箱|邮箱密码|标签(可选,每行一个)
|
|
</Text>
|
|
<TextArea
|
|
rows={10}
|
|
value={importText}
|
|
onChange={(e) => setImportText(e.target.value)}
|
|
placeholder={`用户名|密码|邮箱|邮箱密码|标签\n用户名|密码|邮箱|邮箱密码|标签`}
|
|
style={{ marginTop: 8 }}
|
|
/>
|
|
<div style={{ marginTop: 12 }}>
|
|
<Text type="secondary">统一标签(可选):未在行内写标签的账号将使用此标签</Text>
|
|
<Select
|
|
mode="tags"
|
|
style={{ width: '100%', marginTop: 4 }}
|
|
placeholder="输入或选择标签"
|
|
value={importTag ? [importTag] : []}
|
|
onChange={(vals) => setImportTag(vals.length > 0 ? vals[vals.length - 1] : '')}
|
|
options={tags.map((t) => ({ value: t, label: t }))}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="批量设置标签"
|
|
open={batchTagVisible}
|
|
onCancel={() => setBatchTagVisible(false)}
|
|
onOk={handleBatchTag}
|
|
okText="确定"
|
|
width={400}
|
|
>
|
|
<p>为{selectedAllMatching ? '当前筛选下' : '选中的'} {selectedCount} 个账号设置标签:</p>
|
|
<Select
|
|
mode="tags"
|
|
style={{ width: '100%' }}
|
|
placeholder="输入或选择标签"
|
|
value={batchTagInput ? [batchTagInput] : []}
|
|
onChange={(vals) => setBatchTagInput(vals.length > 0 ? vals[vals.length - 1] : '')}
|
|
options={tags.map((t) => ({ value: t, label: t }))}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|