// ============================================================ // TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动) // // 筛选策略(混合模式): // - 服务端筛选(传 API):status、model_id、user_id(企业版) // - 客户端筛选(antd onFilter):output_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 } from '@/hooks/use-api'; import type { ModelsListResponseBody } from '@/services/modules/models'; import { useAppContext } from '@/components/AppProvider'; import { useHomeContext } from '@/pages/home/HomeContent'; import { useTheme } from '@/components/ThemeProvider'; import { on, off, EVENTS } from '@/utils/event-bus'; import type { TaskInfoItemResponseBody, TaskListRequestParams, TaskStatusString, } from '@/services/modules/task'; import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task'; const { Text } = Typography; // ---------- 辅助类型 ---------- /** antd Table onChange 中 filters 的类型 */ type TableFilters = Record; /** 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 = { 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 | 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): 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; } // ---------- 组件 ---------- interface TaskHistoryProps { /** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */ allModels: ModelsListResponseBody[] | null; } export function TaskHistory({ allModels }: TaskHistoryProps) { const { token } = antTheme.useToken(); const { edition } = useAppContext(); const { selectedTask, setSelectedTask, taskPage, taskPageSize, setTaskPage, setTaskPageSize, } = useHomeContext(); const isEnterprise = edition === 'enterprise'; // -------- 列可见性 -------- const [visibleColumns, setVisibleColumns] = useState>(() => { 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({ pagination: { current: taskPage, pageSize: taskPageSize }, filters: {}, sorter: {}, }); // 同步 context 分页 ↔ tableParams useEffect(() => { setTableParams((prev:TableParams) => ({ ...prev, pagination: { current: taskPage, pageSize: taskPageSize }, })); }, [taskPage, taskPageSize]); // -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)-------- const modelFilters:{text:string, value:string}[] = 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(); 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>([]); // -------- 构建 API 请求参数 -------- const requestParams = useMemo(() => { 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(); 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['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; 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>(() => { const cols: ColumnsType = [ // ---- 任务 ID(固定列) ---- { title: '任务 ID', dataIndex: 'id', key: 'id', width: 140, ellipsis: true, fixed: 'left', render: (id: string) => ( {id.slice(0, 10)}... ), }, // ---- 创建时间 ---- { 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) => ( {v ? new Date(v).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }) : '-'} ), }, // ---- 模型 ---- { 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) => ( {OutputTypeMap[t as keyof typeof OutputTypeMap] || t} ), }, // ---- 状态 ---- { 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 {cfg.label}; }, }, // ---- 提示词 ---- { title: '提示词', dataIndex: 'prompt', key: 'prompt', width: 160, ellipsis: true, filterIcon: (filtered: boolean) => ( ), filterDropdown: undefined, // TODO: 后续加自定义搜索框 render: (p: string | undefined) => ( {p || '-'} ), }, // ---- 时长(仅视频有数据,否则 "-") ---- { 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 {sec !== null ? formatDuration(sec) : '-'}; }, }, // ---- 费用 ---- { 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) => ( ¥{(c / 100).toFixed(2)} ), }, // ---- 供应商(默认隐藏) ---- { 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: () => -, }, // ---- 文件大小(占位列,待后端支持) ---- { title: '文件大小', key: 'file_size', width: 85, render: () => -, }, // ---- 标签 ---- { title: '标签', dataIndex: 'tags', key: 'tags', width: 100, ellipsis: true, render: (tags: Array) => { const filtered = tags?.filter((t): t is string => t !== null) ?? []; if (filtered.length === 0) return -; return ( {filtered.slice(0, 2).map((t) => ( {t} ))} {filtered.length > 2 && ( +{filtered.length - 2} )} ); }, }, // ---- 错误信息(失败任务时展示) ---- { title: '错误信息', dataIndex: 'error_message', key: 'error_message', width: 140, ellipsis: true, render: (msg: string) => msg ? {msg} : -, }, ]; // 用户列:仅企业版可筛选 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) => ( {n || '-'} ), }); } else { cols.push({ title: '用户', dataIndex: 'user_display_name', key: 'user_id', width: 100, ellipsis: true, render: (n: string | null) => ( {n || '-'} ), }); } 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(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 (
{/* 标题栏 */}
任务记录 {total > 0 && ( 共 {total} 条 )} {hasActiveFilters && ( 已筛选 )} {errorMessage && ( {errorMessage} )} {/* 列可见性控制 — 齿轮图标 */} {ALL_COLUMNS.map((col) => ( handleColumnToggle(col.key, e.target.checked)} > {col.title} ))} } >
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
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: '确定' }} />
); }