Files
ele-HeiXiu/src/pages/home/components/TaskHistory.tsx
YoungestSongMo a8ace232a4 feat: 任务列表UI重构 + 分页器/登录错误/请求拦截修复
【任务列表 UI 重构】(TaskHistory.tsx)
  - 列重新排序:任务ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 → 时长 → 费用 → 供应商(隐藏) → 帧率/文件大小/标签/错误信息(隐藏) → 用户
  - 斑马纹行样式 + hover 增强 + 选中行高亮 + 暗色主题适配
  - 列可见性选择器(齿轮图标 Popover),持久化到 localStorage
  - 视频时长通过 VideoUiParams.duration 计算,非视频显示 "-"
  - 未知状态降级处理(resolveStatus → 显示原始 key)
  - 分页器固定预留 48px,避免 >20 条时分页器溢出可视区
  - 提示词搜索图标颜色用 CSS class 承载,避免 columns 依赖 token.colorPrimary 导致主题切换时 Table 重建

  【分页器修复】(antd-theme.ts)
  - Pagination 组件 token 添加 itemActiveColor: '#fff'
  - 激活页码数字与 #6366F1 主题背景形成对比,亮色/暗色均可见
  - 替代之前的 CSS !important hack

  【登录错误信息修复】(request.ts)
  - HTTP 401 拦截改为双重校验:status === 401 && data?.code === 401
  - 仅业务码也为 401 时才走 token 刷新流程
  - 其他业务码(如密码错误)透传后端 data.message,不再被 RefreshError 覆盖

  【全局中文 locale】(ThemeProvider.tsx)
  - ConfigProvider 添加 locale={zhCN},分页器显示"条/页"等中文
  - 避免逐个 Table 设置不完整 locale 导致 Pagination Select 退化为 Input

  【上下文稳定性】(HomeContent.tsx)
  - contextValue 用 useMemo 包裹,避免每次 render 新建对象导致无关重渲染
  - 配合 columns 依赖清理,减少主题切换时的级联更新

  【其他】
  - index.html CSP 策略增加 COS 域名白名单
  - OutputTypeMap 枚举值修复为 image='图片', video='视频', audio='音频'
  - TaskStatusMap STATUS_CONFIG 类型放宽为 Record<string,...> 兼容未知状态
  - 移除 computeDuration,替换为 getVideoDuration + formatDuration
  - LoginPage 移除未使用的 authState 依赖
2026-06-05 20:49:23 +08:00

746 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
//
// 筛选策略(混合模式):
// - 服务端筛选(传 APIstatus、model_id、user_id企业版
// - 客户端筛选antd onFilteroutput_type、provider_name
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
// - 企业版才会在"用户"列显示筛选菜单
//
// 使用 antd Table onChange 统一管理 pagination + filters + sorter
// 参考 table-test.tsx 示例 4 的模式。
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList, useModelList } from '@/hooks/use-api';
import { useAppContext } from '@/contexts/app-context';
import { useHomeContext } from '@/contexts/home-context';
import { useTheme } from '@/hooks/use-theme';
import { on, off, EVENTS } from '@/utils/event-bus';
import type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
} from '@/services/modules';
import { TaskStatusMap, OutputTypeMap } from '@/services/modules';
const { Text } = Typography;
// ---------- 辅助类型 ----------
/** antd Table onChange 中 filters 的类型 */
type TableFilters = Record<string, FilterValue | null>;
/** antd Table onChange 中 sorter 的类型(单列排序) */
interface TableSorter {
field?: React.Key | readonly React.Key[];
order?: 'ascend' | 'descend';
}
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
interface TableParams {
pagination: { current: number; pageSize: number };
filters: TableFilters;
sorter: TableSorter;
}
// ---------- 常量 ----------
/** 状态筛选选项(来自 API 枚举) */
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
text: label,
value,
}));
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
text,
value,
}));
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
function resolveStatus(s: string): { color: string; label: string } {
return STATUS_CONFIG[s] || { color: 'default', label: s };
}
// ---------- 列可见性持久化 ----------
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
/** 列元数据(定义所有可用列 + 默认可见性) */
interface ColumnMeta { key: string; title: string; defaultVisible: boolean }
const ALL_COLUMNS: ColumnMeta[] = [
{ key: 'id', title: '任务 ID', defaultVisible: true },
{ key: 'created_at', title: '创建时间', defaultVisible: true },
{ key: 'model_name', title: '模型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'status', title: '状态', defaultVisible: true },
{ key: 'prompt', title: '提示词', defaultVisible: true },
{ key: 'duration', title: '时长', defaultVisible: true },
{ key: 'cost_cent', title: '费用', defaultVisible: true },
{ key: 'user_display_name', title: '用户', defaultVisible: true },
{ key: 'provider_name', title: '供应商', defaultVisible: false },
{ key: 'fps', title: '帧率', defaultVisible: false },
{ key: 'file_size', title: '文件大小', defaultVisible: false },
{ key: 'tags', title: '标签', defaultVisible: false },
{ key: 'error_message', title: '错误信息', defaultVisible: false },
];
function readStoredColumns(): Set<string> | null {
try {
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
if (raw) {
const arr: unknown = JSON.parse(raw);
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
}
} catch { /* ignore */ }
return null;
}
function writeStoredColumns(visible: Set<string>): void {
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
}
// ---------- 工具函数 ----------
/** 从 filters 中提取单值筛选(如 status 单选) */
function pickOne(filters: TableFilters, key: string): string | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v[0] as string;
return undefined;
}
/** 从 filters 中提取多值筛选(如 model_ids、user_ids */
function pickMany(filters: TableFilters, key: string): string[] | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v as string[];
return undefined;
}
// ---------- 组件 ----------
export function TaskHistory() {
const { token } = antTheme.useToken();
const { edition } = useAppContext();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
const isEnterprise = edition === 'enterprise';
// -------- 列可见性 --------
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
const stored = readStoredColumns();
if (stored) return stored;
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
});
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
setVisibleColumns((prev) => {
const next = new Set(prev);
if (checked) next.add(key); else next.delete(key);
writeStoredColumns(next);
return next;
});
}, []);
// -------- 主题感知斑马纹 / hover / 选中行 CSS --------
const { isDark } = useTheme();
const tableCss = useMemo(() => {
const searchIconColor = token.colorPrimary;
if (isDark) {
return `
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
.task-table .task-row-even > td { background: transparent !important; }
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
`;
}
return `
.task-table .task-row-odd > td { background: #fafafa !important; }
.task-table .task-row-even > td { background: #ffffff !important; }
.task-table .task-row-selected > td { background: #e6f4ff !important; }
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
`;
}, [isDark, token.blue, token.colorPrimary]);
// -------- antd Table onChange 参数缓存 --------
const [tableParams, setTableParams] = useState<TableParams>({
pagination: { current: taskPage, pageSize: taskPageSize },
filters: {},
sorter: {},
});
// 同步 context 分页 ↔ tableParams
useEffect(() => {
setTableParams((prev) => ({
...prev,
pagination: { current: taskPage, pageSize: taskPageSize },
}));
}, [taskPage, taskPageSize]);
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
// TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层
const { data: allModels } = useModelList();
const modelFilters = useMemo(() => {
if (!allModels) return [];
return allModels.map((m) => ({ text: m.name, value: m.id }));
}, [allModels]);
// -------- 供应商列表(从模型数据中提取)--------
const providerFilters = useMemo(() => {
if (!allModels) return [];
const seen = new Set<string>();
allModels.forEach((m) => {
if (m.provider_name) seen.add(m.provider_name);
});
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
}, [allModels]);
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
// TODO: 替换为独立的用户列表 API
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
// -------- 构建 API 请求参数 --------
const requestParams = useMemo<TaskListRequestParams>(() => {
const req: TaskListRequestParams = {
page: tableParams.pagination.current,
page_size: tableParams.pagination.pageSize,
};
const { filters } = tableParams;
// 服务端筛选字段
const status = pickOne(filters, 'status');
if (status) req.status = status as TaskStatusString;
const modelIds = pickMany(filters, 'model_name');
if (modelIds) req.model_ids = modelIds;
// user_ids仅企业版传入
if (isEnterprise) {
const userIds = pickMany(filters, 'user_id');
if (userIds) req.user_ids = userIds;
}
return req;
}, [tableParams, isEnterprise]);
// -------- 数据获取 --------
const [refreshTick, setRefreshTick] = useState(0);
const handleTaskSubmitted = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
}, [handleTaskSubmitted]);
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({
requestParams,
refreshSignal: refreshTick,
});
const tasks = taskData?.items ?? [];
const total = taskData?.total ?? 0;
// 从任务数据中提取用户列表(企业版筛选用)
useEffect(() => {
if (!isEnterprise || !taskData?.items) return;
const seen = new Set<string>();
taskData.items.forEach((t) => {
if (t.user_id && t.user_display_name) {
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
}
});
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
setUserFilters((prev) => {
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
return newList;
});
}, [isEnterprise, taskData]);
// -------- antd Table onChange --------
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
pagination,
filters,
sorter,
) => {
// 同步分页到 Context
if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current);
if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize);
// 缓存筛选和排序参数
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
setTableParams({
pagination: {
current: pagination.current || taskPage,
pageSize: pagination.pageSize || taskPageSize,
},
filters: filters as TableFilters,
sorter: !Array.isArray(s) && s.field
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
: {},
});
};
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
//
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
//
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
//
// ⚠️ 重要columns 依赖中不出 token.colorPrimary否则主题切换时 columns 重新生成
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
/** 提示词搜索图标颜色(通过 CSS class 承载,避免 columns 依赖 token 导致主题切换重置状态) */
/** 提取视频时长(秒),非视频输出类型返回 null → 渲染 "-" */
function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
if (record.output_type === 'video') {
return record.ui_params.duration ?? null;
}
return null;
}
/** 格式化时长:<60s 显示秒≥60s 显示分秒 */
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}${s}` : `${m} 分钟`;
}
const allColumns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
const cols: ColumnsType<TaskInfoItemResponseBody> = [
// ---- 任务 ID固定列 ----
{
title: '任务 ID',
dataIndex: 'id',
key: 'id',
width: 140,
ellipsis: true,
fixed: 'left',
render: (id: string) => (
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
{id.slice(0, 10)}...
</Text>
),
},
// ---- 创建时间 ----
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 155,
sorter: (a, b) => {
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
return da - db;
},
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
render: (v: Date) => (
<Text style={{ fontSize: 11 }}>
{v ? new Date(v).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}) : '-'}
</Text>
),
},
// ---- 模型 ----
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 130,
ellipsis: true,
filters: modelFilters,
filteredValue: (tableParams.filters.model_name as string[]) || null,
onFilter: () => true, // 服务端筛选
filterSearch: true,
filterMode: 'menu' as const,
},
// ---- 输出类型 ----
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 90,
filters: OUTPUT_TYPE_FILTERS,
filteredValue: (tableParams.filters.output_type as string[]) || null,
onFilter: (value, record) => record.output_type === value, // 客户端筛选
render: (t: string) => (
<Tag style={{ fontSize: 10 }}>
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
</Tag>
),
},
// ---- 状态 ----
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
filters: STATUS_FILTERS,
filteredValue: (tableParams.filters.status as string[]) || null,
onFilter: () => true, // 服务端筛选
render: (s: string) => {
const cfg = resolveStatus(s);
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
},
},
// ---- 提示词 ----
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
filterIcon: (filtered: boolean) => (
<SearchOutlined className={`prompt-search-icon${filtered ? ' filtered' : ''}`} style={{ fontSize: 12 }} />
),
filterDropdown: undefined, // TODO: 后续加自定义搜索框
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
// ---- 时长(仅视频有数据,否则 "-" ----
{
title: '时长',
key: 'duration',
width: 75,
sorter: (a, b) => {
const da = getVideoDuration(a) ?? -1;
const db = getVideoDuration(b) ?? -1;
return da - db;
},
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
const sec = getVideoDuration(record);
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
},
},
// ---- 费用 ----
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 75,
sorter: (a, b) => a.cost_cent - b.cost_cent,
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
// ---- 供应商(默认隐藏) ----
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
filters: providerFilters,
filteredValue: (tableParams.filters.provider_name as string[]) || null,
onFilter: (value, record) => record.provider_name === value, // 客户端筛选
filterSearch: true,
},
// ---- 帧率(占位列,待后端支持) ----
{
title: '帧率',
key: 'fps',
width: 70,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 文件大小(占位列,待后端支持) ----
{
title: '文件大小',
key: 'file_size',
width: 85,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 标签 ----
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 100,
ellipsis: true,
render: (tags: Array<string | null>) => {
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
return (
<Space size={2} wrap>
{filtered.slice(0, 2).map((t) => (
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
))}
{filtered.length > 2 && (
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
)}
</Space>
);
},
},
// ---- 错误信息(失败任务时展示) ----
{
title: '错误信息',
dataIndex: 'error_message',
key: 'error_message',
width: 140,
ellipsis: true,
render: (msg: string) => msg
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
];
// 用户列:仅企业版可筛选
if (isEnterprise) {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
filters: userFilters,
filteredValue: (tableParams.filters.user_id as string[]) || null,
onFilter: () => true, // 服务端筛选(传 user_ids
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
} else {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
}
return cols;
}, [
modelFilters,
providerFilters,
userFilters,
tableParams.filters,
tableParams.sorter,
isEnterprise,
]);
// 按 visibleColumns 过滤实际展示的列
const columns = useMemo(
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
[allColumns, visibleColumns],
);
// -------- 表格高度 --------
//
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
// 分页器始终有空间渲染,不会溢出可视区。
//
// scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度
//
// 为什么固定预留 pagination 而不是动态测量?
// - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大
// - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控
// - 固定预留值48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题
/** 预留 pagination 高度small-size: ~32px + 上下 padding/margin 8px × 2 */
const PAGINATION_RESERVE = 48;
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const calcHeight = () => {
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
const theadH = thead?.offsetHeight ?? 0;
// 水平滚动条占用高度antd Table scroll.x 触发时出现)
let hScrollH = 0;
if (body && body.scrollWidth > body.clientWidth + 1) {
hScrollH = body.offsetHeight - body.clientHeight;
}
const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH;
setTableBodyHeight(Math.max(120, available));
};
// ResizeObserver容器尺寸变化窗口拉伸、面板拖拽
const resizeObserver = new ResizeObserver(calcHeight);
resizeObserver.observe(wrapper);
// MutationObserver内部 DOM 变化thead 高度变化如筛选菜单弹出、
// 数据加载后水平滚动条出现等)
const mutationObserver = new MutationObserver(calcHeight);
mutationObserver.observe(wrapper, { childList: true, subtree: true });
calcHeight();
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [taskPageSize]);
// -------- 行样式:斑马纹 + 选中行 --------
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
};
// -------- 是否有活跃筛选 --------
const hasActiveFilters = Object.values(tableParams.filters).some(
(v) => Array.isArray(v) && v.length > 0,
);
// ======== 渲染 ========
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '6px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
)}
{hasActiveFilters && (
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
</Tag>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
{/* 列可见性控制 — 齿轮图标 */}
<Popover
trigger="click"
placement="bottomRight"
title="显示/隐藏列"
content={
<Checkbox.Group
value={[...visibleColumns]}
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ALL_COLUMNS.map((col) => (
<Checkbox
key={col.key}
value={col.key}
disabled={col.key === 'id'}
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
>
{col.title}
</Checkbox>
))}
</Checkbox.Group>
}
>
<Button
type="text"
size="small"
icon={<SettingOutlined style={{ fontSize: 13 }} />}
style={{ marginLeft: 'auto' }}
title="列设置"
/>
</Popover>
</div>
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
className="task-table"
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={{
current: tableParams.pagination.current,
pageSize: tableParams.pagination.pageSize,
total,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
placement: ['bottomCenter'],
style: { marginBottom: 0 },
}}
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
/>
</div>
</div>
);
}