From e24af9ad75f6cf3751726081d1c0fe9aaa6405fc Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 17:10:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E8=B4=A6=E5=8F=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=8C=89=E5=8F=8D=E9=A6=88=E8=B0=83=E6=95=B4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BF=9D=E7=95=99=E6=89=B9=E9=87=8F=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=E5=87=AD=E6=8D=AE=E5=BC=80=E5=85=B3=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E3=80=8C=E5=88=86=E9=85=8D=E7=BB=99=E3=80=8D?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交把四项一起砍了,其中两项理解反了,按反馈改回来: ## 恢复(撤销上一提交的 ① ④) - **「显示凭据」开关 + 密码/邮箱/邮箱密码三列** - **「批量打标签」「批量删除」按钮**,以及随之必需的**整套勾选机制**: `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** --- web/frontend/src/pages/AccountsPage.tsx | 222 +++++++++++++++++++----- 1 file changed, 181 insertions(+), 41 deletions(-) diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 9241bb9..0a492e8 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -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([]); - const [users, setUsers] = useState([]); const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); const [importOpen, setImportOpen] = useState(false); @@ -83,6 +84,11 @@ export default function AccountsPage() { const [ckFilter, setCkFilter] = useState(''); const [bindFilter, setBindFilter] = useState(''); const [searchText, setSearchText] = useState(''); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + 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({ 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['columns'] = [ { // ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。 @@ -288,25 +353,13 @@ export default function AccountsPage() { }, ]; - columns.push({ - title: '分配给', - dataIndex: 'assigned_username', - render: (_: unknown, record: AccountItem) => { - if (canAssign) { - return ( - setBatchTagInput(vals.length > 0 ? vals[vals.length - 1] : '')} + options={tags.map((t) => ({ value: t, label: t }))} + /> + ); }