feat :修改文件结构

This commit is contained in:
2026-06-05 19:24:06 +08:00
parent 1da95eaef3
commit af85d31035
15 changed files with 577 additions and 273 deletions

View File

@@ -2,11 +2,10 @@
// AppProvider — 应用状态 Provider 组件
// ============================================================
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/platform';
import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
import { AppContext } from '@/contexts/app-context';
import type { AppContextValue } from '@/contexts/app-context';
@@ -30,12 +29,25 @@ interface AppProviderProps {
export function AppProvider({ children }: AppProviderProps) {
const [platform] = useState<Platform>(getPlatform);
const [edition] = useState<Edition>(() => {
return parseEdition(import.meta.env.VITE_EDITION);
});
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<LoginResponseBody | null>(null);
/**
* edition 从登录用户的 account_type 派生(运行时判断,无需构建变量)
*
* 映射关系:
* "individual" → personal ← 个人用户
* "company" | "employee" → enterprise ← 企业用户
* null未登录 → personal ← 默认最小功能集
*
* 安全性edition 是 useMemo 派生值,不可独立赋值;
* 不写入 localStorage重启/登出后回退默认值,必须重新登录才能确权。
*/
const edition: Edition = useMemo(() => {
if (!user) return 'personal';
return user.account_type === 'individual' ? 'personal' : 'enterprise';
}, [user]);
// ---------- 登录 / 退出 ----------
const login = useCallback(async (auth: LoginResponseBody) => {

View File

@@ -6,7 +6,7 @@
import { useLocation, Outlet } from 'react-router-dom';
import { theme as antTheme } from 'antd';
import { NavBar } from './navbar';
import { BannerCarousel } from '@/pages/home/BannerCarousel';
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
export function Layout() {
const { token } = antTheme.useToken();

View File

@@ -103,23 +103,25 @@ export function useModelList() {
// ============================================================
export interface UseTaskListParams {
page: number;
pageSize: number;
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
requestParams: TaskListRequestParams;
/** 外部传入的刷新信号(任意变化触发重新请求) */
refreshSignal?: number;
}
/** 任务列表 Hook分页 */
/**
* 任务列表 Hook分页 + 筛选 + 排序)
*
* 调用方负责构建 TaskListRequestParams包括 edition-aware 的 user_ids 逻辑),
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
*/
export function useTaskList(params: UseTaskListParams) {
const { isLoggedIn } = useAppContext();
const { page, pageSize, refreshSignal = 0 } = params;
const { requestParams, refreshSignal = 0 } = params;
return useAsyncData(
() => {
const req: TaskListRequestParams = { page, page_size: pageSize };
return TaskAPI.getTaskList(req);
},
[page, pageSize, refreshSignal],
() => TaskAPI.getTaskList(requestParams),
[requestParams, refreshSignal],
{ enabled: isLoggedIn, label: 'task-list' },
);
}

View File

@@ -9,9 +9,9 @@ import { theme as antTheme, Empty } from 'antd';
import { HomeContext } from '@/contexts/home-context';
import { useAppContext } from '@/contexts/app-context';
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
import { LeftPanel } from './LeftPanel';
import { CenterPanel } from './CenterPanel';
import { RightPanel } from './RightPanel';
import { LeftPanel } from './components/LeftPanel';
import { CenterPanel } from './components/CenterPanel';
import { RightPanel } from './components/RightPanel';
import { Typography } from 'antd';

View File

@@ -1,250 +0,0 @@
// ============================================================
// TaskHistory — 任务记录列表(分页,横向+纵向滚动)
// 使用 useTaskList() Hook 获取数据,组件仅负责渲染
// ============================================================
import { useState, useRef, useEffect, useCallback } from 'react';
import { Table, Tag, Typography, theme as antTheme } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { HistoryOutlined } from '@ant-design/icons';
import { useTaskList } from '@/hooks/use-api';
import type {
TaskInfoItemResponseBody,
TaskStatusString,
} from '@/services/modules';
import { useHomeContext } from '@/contexts/home-context';
import { on, off, EVENTS } from '@/utils/event-bus';
const { Text } = Typography;
// ---------- 状态标签配置 ----------
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
// ---------- 表格列定义 ----------
const columns: ColumnsType<TaskInfoItemResponseBody> = [
{
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: 'model_name',
key: 'model_name',
width: 120,
ellipsis: true,
},
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
},
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 80,
render: (t: string) => <Tag>{t}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (s: TaskStatusString) => {
const cfg = STATUS_CONFIG[s] || { color: 'default', label: s };
return <Tag color={cfg.color} style={{ fontSize: 11 }}>{cfg.label}</Tag>;
},
},
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
{
title: '提交时间',
dataIndex: 'submitted_at',
key: 'submitted_at',
width: 140,
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: '耗时(分)',
key: 'duration',
width: 80,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
if (!record.submitted_at || !record.finished_at) return <Text style={{ fontSize: 11 }}>-</Text>;
const min = Math.round(
(new Date(record.finished_at).getTime() - new Date(record.submitted_at).getTime()) / 60000,
);
return <Text style={{ fontSize: 11 }}>{min} </Text>;
},
},
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 80,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
{
title: '用户',
dataIndex: 'user_display_name',
key: 'user_display_name',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
},
];
// ---------- 组件 ----------
export function TaskHistory() {
const { token } = antTheme.useToken();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
// 任务提交事件 → 刷新列表(替代 Context 中的 refreshTaskSignal
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]);
// 数据获取Hook 自动处理 loading / error / 竞态)
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({ page: taskPage, pageSize: taskPageSize, refreshSignal: refreshTick });
const tasks = taskData?.items ?? [];
const total = taskData?.total ?? 0;
// 表格容器高度(用于 scroll.y
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const el = tableWrapperRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
setTableBodyHeight(Math.max(200, el.clientHeight - 80));
});
observer.observe(el);
setTableBodyHeight(Math.max(200, el.clientHeight - 80));
return () => observer.disconnect();
}, []);
// 分页变化
const handleTableChange = (pagination: TablePaginationConfig) => {
if (pagination.current) setTaskPage(pagination.current);
if (pagination.pageSize) setTaskPageSize(pagination.pageSize);
};
// 行样式
const rowClassName = (record: TaskInfoItemResponseBody) =>
selectedTask?.id === record.id ? 'task-row-selected' : '';
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '8px 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>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
</div>
{/* 表格 — 横向 + 纵向滚动 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Table<TaskInfoItemResponseBody>
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: 1200, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={{
current: taskPage,
pageSize: taskPageSize,
total,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
placement: ['bottomCenter'],
style: { marginBottom: 0 },
}}
locale={{ emptyText: '暂无任务记录' }}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
// ============================================================
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务记录)
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
//
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
// ============================================================
import { useState, useRef, useEffect, useCallback } from 'react';

View File

@@ -162,4 +162,4 @@ export function OutputPreview() {
</div>
</div>
);
}
}

View File

@@ -0,0 +1,538 @@
// ============================================================
// 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,
}));
/** 输出类型筛选选项 */
const OUTPUT_TYPE_FILTERS = [
{ text: '图片', value: 'image' },
{ text: '视频', value: 'video' },
{ text: '音频', value: 'audio' },
];
/** 状态标签配置(渲染用) */
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
// ---------- 工具函数 ----------
/** 计算任务耗时(分钟),无 finished_at 时返回 null */
function computeDuration(record: TaskInfoItemResponseBody): number | null {
if (!record.submitted_at || !record.finished_at) return null;
return Math.round(
(new Date(record.finished_at).getTime() - new Date(record.submitted_at).getTime()) / 60000,
);
}
/** 从 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';
// -------- 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 }
: {},
});
};
// -------- 表格列定义(含列头筛选/排序)--------
const columns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
const cols: ColumnsType<TaskInfoItemResponseBody> = [
{
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: 'model_name',
key: 'model_name',
width: 130,
ellipsis: true,
filters: modelFilters,
filteredValue: (tableParams.filters.model_name as string[]) || null,
// 服务端筛选 → onFilter 恒返回 true实际过滤在 API 层)
onFilter: () => true,
filterSearch: true,
filterMode: 'menu' as const,
},
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
filters: providerFilters,
filteredValue: (tableParams.filters.provider_name as string[]) || null,
// 客户端筛选API 不支持 provider 参数,在当前页内过滤)
onFilter: (value, record) => record.provider_name === value,
filterSearch: true,
},
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 100,
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 }}>{t}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
filters: STATUS_FILTERS,
filteredValue: (tableParams.filters.status as string[]) || null,
onFilter: () => true, // 服务端筛选
render: (s: TaskStatusString) => {
const cfg = STATUS_CONFIG[s] || { color: 'default', label: 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 style={{ color: filtered ? token.colorPrimary : undefined, fontSize: 12 }} />
),
filterDropdown: undefined, // TODO: 后续加自定义搜索框
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
{
title: '提交时间',
dataIndex: 'submitted_at',
key: 'submitted_at',
width: 145,
sorter: (a, b) => {
const da = a.submitted_at ? new Date(a.submitted_at).getTime() : 0;
const db = b.submitted_at ? new Date(b.submitted_at).getTime() : 0;
return da - db;
},
sortOrder: tableParams.sorter.field === 'submitted_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: '耗时',
key: 'duration',
width: 80,
sorter: (a, b) => {
const da = computeDuration(a) ?? -1;
const db = computeDuration(b) ?? -1;
return da - db;
},
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
const min = computeDuration(record);
return <Text style={{ fontSize: 11 }}>{min !== null ? `${min} 分钟` : '-'}</Text>;
},
},
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 80,
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>
),
},
];
// 用户列:仅企业版展示 + 可筛选
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;
}, [
token.colorPrimary,
modelFilters,
providerFilters,
userFilters,
tableParams.filters,
tableParams.sorter,
isEnterprise,
]);
// -------- 表格高度 --------
//
// 核心策略:固定预留 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) =>
selectedTask?.id === record.id ? 'task-row-selected' : '';
// -------- 是否有活跃筛选 --------
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>
)}
</div>
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Table<TaskInfoItemResponseBody>
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: isEnterprise ? 1250 : 1150, 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>
);
}

View File

@@ -1,5 +1,5 @@
import {get} from '../request';
// 请注意后端没有关于字段category的查询
// ---------- 路由常量 ----------
const ModelsUrlObj = {

View File

@@ -18,8 +18,8 @@ import {get, post} from '../request';
/** 输出类型映射 */
export enum OutputTypeMap {
video = '图片',
image = '视频',
image = '图片',
video = '视频',
audio = '音频',
}