refactor(ui): 账号列表按「只做管理」重做 —— 砍掉凭据/标签/时间列与批量操作
承接上一个提交。按确认的结果,四类老壳子残留一并移除:
## ① 「显示凭据」开关 + 密码 / 邮箱 / 邮箱密码 三列
这是老「卖号」视图的残留。移掉后列表不再有任何凭据字段,
`include_sensitive` 参数不再从列表请求里传(后端能力保留,需要时仍可显式请求)。
⚠️ 副作用:**界面上暂时没有地方能看到账号密码了**。当前重登全自动,
不需要人工读凭据;但如果你偶尔要抄出来核对,告诉我,我在**详情页**加一个
带权限校验 + 写审计的「查看凭据」,而不是把三列塞回列表。
## ② 「标签」列
标签仍可**筛选**(顶部下拉),但不再占一列。设置标签的入口统一到**详情页**
(S6 已在状态卡里做了就地编辑),列表里那个内联编辑框随之删除 ——
所以 `handleSetTag` / `tagColorMap` / `TAG_COLORS` 一并清掉。
## ③ 「导入时间」列
去掉(详情页仍有完整的导入时间与状态变更时间)。
## ④ 「批量打标签」「批量删除」按钮
随之移除**整套勾选机制**:`rowSelection`、`selectedRowKeys`、
`selectedAllMatching`、「已选择 N 个 / 选择当前筛选下全部」提示条、
批量设置标签弹窗、以及两个 handler。**单条删除保留**(操作列,受 `account:delete` 管)。
批量接口在后端保留不动(S4 已给它们接了逐账号事件,别浪费)。
## 结果
- 列表列:**账号 / 状态 / 绑定 / 积分·鱼翅 / 分配给 / 操作**
- 工具栏:筛选(标签 / CK / 绑定 / 搜索)+ 批量导入
- 文件 601 → 478 行;`Alert` / `TagOutlined` / `Eye*` / `App` / `modal` 等
随之失效的导入与变量全部清掉(靠 `tsc --noUnusedLocals` 逐个抓出来)
## 验证
- `TZ=Asia/Shanghai`:500 passed / 6 skipped;ruff 全绿
- 前端 build 通过、`npm run lint` **0 errors**
- 逐个删除后都用 `tsc` 反查死代码,没有留下未使用变量
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
||||
Row, Col, Card, Statistic, App, Tooltip,
|
||||
Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
||||
Row, Col, Card, Statistic, Tooltip,
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import type { TableProps } from 'antd';
|
||||
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import { ImportOutlined, DeleteOutlined, FilterOutlined } 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';
|
||||
@@ -15,9 +15,6 @@ import { getErrorMessage } from '../utils/error';
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const TAG_COLORS = [
|
||||
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
|
||||
];
|
||||
|
||||
/**
|
||||
* 生命周期状态 → 展示色。
|
||||
@@ -73,8 +70,6 @@ function BindChip({ scope, status }: { scope: string; status?: string }) {
|
||||
|
||||
export default function AccountsPage() {
|
||||
const navigate = useNavigate();
|
||||
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
|
||||
const { modal } = App.useApp();
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
@@ -88,11 +83,6 @@ export default function AccountsPage() {
|
||||
const [ckFilter, setCkFilter] = useState<string>('');
|
||||
const [bindFilter, setBindFilter] = 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(() => {
|
||||
@@ -102,12 +92,9 @@ export default function AccountsPage() {
|
||||
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);
|
||||
@@ -122,7 +109,6 @@ export default function AccountsPage() {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
search: searchText.trim() || undefined,
|
||||
include_sensitive: canViewFull && sensitiveVisible,
|
||||
});
|
||||
setAccounts(data.items);
|
||||
setTotal(data.total);
|
||||
@@ -131,7 +117,7 @@ export default function AccountsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter, ckFilter, bindFilter]);
|
||||
}, [currentPage, pageSize, searchText, tagFilter, statusFilter, ckFilter, bindFilter]);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
@@ -170,31 +156,6 @@ export default function AccountsPage() {
|
||||
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('请输入账号数据');
|
||||
@@ -227,42 +188,10 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -271,44 +200,6 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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'] = [
|
||||
{
|
||||
// ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。
|
||||
@@ -395,53 +286,8 @@ export default function AccountsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: 110,
|
||||
// 列表里只关心「哪一批导入的」,时分秒是噪声(需要精确时间看详情页)
|
||||
render: (val: string | null) => val
|
||||
? <Tooltip title={formatTime(val)}>{formatTime(val).slice(0, 10)}</Tooltip>
|
||||
: <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',
|
||||
@@ -522,44 +368,6 @@ export default function AccountsPage() {
|
||||
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); }}>
|
||||
批量导入
|
||||
@@ -611,37 +419,6 @@ export default function AccountsPage() {
|
||||
</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"
|
||||
@@ -696,24 +473,6 @@ export default function AccountsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user