AppProvider useEffect 增加 aborted 标志,防止 React StrictMode 挂载→卸载→重新挂载时 refreshToken/getUserInfo 各调两次。 useModelList 提升到 LeftPanel 单例调用,通过 props 分发给 ModelSelector 和 TaskHistory,消除 /api/v1/models 重复请求。 DevTools 中显示「已停止」的请求即 StrictMode 第一轮挂载被丢弃的请求。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
750 lines
28 KiB
TypeScript
750 lines
28 KiB
TypeScript
// ============================================================
|
||
// 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<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;
|
||
}
|
||
|
||
// ---------- 组件 ----------
|
||
|
||
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<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: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<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>
|
||
);
|
||
}
|