fix(ui): 账号列表按反馈调整 —— 保留批量操作与凭据开关,去掉「分配给」列

上一个提交把四项一起砍了,其中两项理解反了,按反馈改回来:

## 恢复(撤销上一提交的 ① ④)

- **「显示凭据」开关 + 密码/邮箱/邮箱密码三列**
- **「批量打标签」「批量删除」按钮**,以及随之必需的**整套勾选机制**:
  `rowSelection`、`selectedRowKeys`、`selectedAllMatching`、
  「已选择 N 个 / 选择当前筛选下全部」提示条、批量设置标签弹窗、
  `handleBatchTag` / `handleBatchDelete`

## 新增去掉:**「分配给」列**

分配统一走「分配管理」页(`/accounts/assignments`,页面一直在),
列表里的内联分配下拉是重复入口。随之清掉只服务于它的
`handleAssign` / `loadUsers` / `users` 状态 / `userApi` 与 `UserInfo` 导入。

## 保持删除(反馈里未提)

**「标签」列**与**「导入时间」列**仍不恢复。注意因此产生的一处连带:
标签列里的**内联编辑框**也没了 —— 现在改标签的入口是
**批量打标签**(列表)+ **详情页状态卡**(S6 已做),够用。

## 踩到并修掉

用「区间删除」砍 `handleAssign`→`handleDelete` 时,把夹在中间的
**`handleBatchTag` 一起删了**(后来 `handleBatchTag` 引用未定义才暴露)。
已补回。教训:按区间删代码前要先确认区间里没有别的东西 ——
这类错误 `tsc` 只在**引用处**报,很容易看漏。

## 最终形态

- 列:账号 / 状态 / 绑定 / 积分·鱼翅 /(凭据三列,开关控制)/ 操作
- 工具栏:筛选(标签 · CK · 绑定 · 搜索)+ 显示凭据 + 批量打标签 + 批量删除 + 批量导入
- 618 行;`TAG_COLORS` / `tagColorMap` / `handleSetTag`(单条改标签)
  等确认无用的死代码已清

## 验证

- `TZ=Asia/Shanghai`:500 passed / 6 skipped;ruff 全绿
- 前端 build 通过、`npm run lint` **0 errors**
This commit is contained in:
yml2213
2026-10-04 17:10:17 +08:00
parent cb87a844b3
commit e24af9ad75
+181 -41
View File
@@ -1,13 +1,13 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
Row, Col, Card, Statistic, Tooltip,
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
Row, Col, Card, Statistic, App, Tooltip,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { ImportOutlined, DeleteOutlined, FilterOutlined } from '@ant-design/icons';
import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules';
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import { accountApi, type AccountItem, type BasicSummary } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
@@ -70,8 +70,9 @@ 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[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
@@ -83,6 +84,11 @@ 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(() => {
@@ -92,9 +98,11 @@ export default function AccountsPage() {
const [currentPage, setCurrentPage] = useState(1);
const { can } = usePermissions();
const canViewFull = can('account:view_full');
const canImport = can('account:import');
const canAssign = can('account:assign');
const canTag = can('account:tag') || canImport;
const canDelete = can('account:delete');
const canSelectRows = canTag || canDelete;
const loadAccounts = useCallback(async () => {
setLoading(true);
@@ -109,6 +117,7 @@ export default function AccountsPage() {
page: currentPage,
page_size: pageSize,
search: searchText.trim() || undefined,
include_sensitive: canViewFull && sensitiveVisible,
});
setAccounts(data.items);
setTotal(data.total);
@@ -117,7 +126,7 @@ export default function AccountsPage() {
} finally {
setLoading(false);
}
}, [currentPage, pageSize, searchText, tagFilter, statusFilter, ckFilter, bindFilter]);
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter, ckFilter, bindFilter]);
const loadSummary = useCallback(async () => {
try {
@@ -128,15 +137,6 @@ export default function AccountsPage() {
}
}, []);
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();
@@ -151,10 +151,26 @@ export default function AccountsPage() {
}, [loadAccounts]);
useEffect(() => {
if (canAssign) loadUsers();
loadTags();
loadSummary();
}, [canAssign, loadUsers, loadTags, loadSummary]);
}, [loadTags, loadSummary]);
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()) {
@@ -178,11 +194,20 @@ export default function AccountsPage() {
}
};
const handleAssign = async (accountId: number, assignedTo: number | null) => {
const handleBatchTag = async () => {
if (selectedCount === 0) {
message.warning('请先选择账号');
return;
}
try {
await accountApi.assign(accountId, assignedTo);
message.success('已分配');
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));
}
@@ -192,6 +217,8 @@ export default function AccountsPage() {
try {
await accountApi.delete(id);
message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
setSelectedAllMatching(false);
loadAccounts();
loadTags();
loadSummary();
@@ -200,6 +227,44 @@ 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 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。
@@ -288,25 +353,13 @@ export default function AccountsPage() {
},
];
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 (canViewFull && sensitiveVisible) {
columns.push(
{ title: '密码', dataIndex: 'password', width: 120 },
{ title: '邮箱', dataIndex: 'email' },
{ title: '邮箱密码', dataIndex: 'email_password', width: 120 },
);
}
if (canDelete) {
columns.push({
@@ -368,6 +421,44 @@ 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); }}>
批量导入
@@ -419,6 +510,37 @@ 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"
@@ -473,6 +595,24 @@ 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>
);
}