feat :修改文件结构
This commit is contained in:
@@ -2,11 +2,10 @@
|
|||||||
// AppProvider — 应用状态 Provider 组件
|
// 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 type { Platform, Edition } from '@shared/types';
|
||||||
|
|
||||||
import { getPlatform, isDev } from '@/utils/platform';
|
import { getPlatform, isDev } from '@/utils/platform';
|
||||||
import { parseEdition } from '@shared/constants/app';
|
|
||||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||||
import { AppContext } from '@/contexts/app-context';
|
import { AppContext } from '@/contexts/app-context';
|
||||||
import type { AppContextValue } from '@/contexts/app-context';
|
import type { AppContextValue } from '@/contexts/app-context';
|
||||||
@@ -30,12 +29,25 @@ interface AppProviderProps {
|
|||||||
|
|
||||||
export function AppProvider({ children }: AppProviderProps) {
|
export function AppProvider({ children }: AppProviderProps) {
|
||||||
const [platform] = useState<Platform>(getPlatform);
|
const [platform] = useState<Platform>(getPlatform);
|
||||||
const [edition] = useState<Edition>(() => {
|
|
||||||
return parseEdition(import.meta.env.VITE_EDITION);
|
|
||||||
});
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
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) => {
|
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { useLocation, Outlet } from 'react-router-dom';
|
import { useLocation, Outlet } from 'react-router-dom';
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { NavBar } from './navbar';
|
import { NavBar } from './navbar';
|
||||||
import { BannerCarousel } from '@/pages/home/BannerCarousel';
|
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
|
|||||||
@@ -103,23 +103,25 @@ export function useModelList() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export interface UseTaskListParams {
|
export interface UseTaskListParams {
|
||||||
page: number;
|
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
|
||||||
pageSize: number;
|
requestParams: TaskListRequestParams;
|
||||||
/** 外部传入的刷新信号(任意变化触发重新请求) */
|
/** 外部传入的刷新信号(任意变化触发重新请求) */
|
||||||
refreshSignal?: number;
|
refreshSignal?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 任务列表 Hook(分页) */
|
/**
|
||||||
|
* 任务列表 Hook(分页 + 筛选 + 排序)
|
||||||
|
*
|
||||||
|
* 调用方负责构建 TaskListRequestParams(包括 edition-aware 的 user_ids 逻辑),
|
||||||
|
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
|
||||||
|
*/
|
||||||
export function useTaskList(params: UseTaskListParams) {
|
export function useTaskList(params: UseTaskListParams) {
|
||||||
const { isLoggedIn } = useAppContext();
|
const { isLoggedIn } = useAppContext();
|
||||||
const { page, pageSize, refreshSignal = 0 } = params;
|
const { requestParams, refreshSignal = 0 } = params;
|
||||||
|
|
||||||
return useAsyncData(
|
return useAsyncData(
|
||||||
() => {
|
() => TaskAPI.getTaskList(requestParams),
|
||||||
const req: TaskListRequestParams = { page, page_size: pageSize };
|
[requestParams, refreshSignal],
|
||||||
return TaskAPI.getTaskList(req);
|
|
||||||
},
|
|
||||||
[page, pageSize, refreshSignal],
|
|
||||||
{ enabled: isLoggedIn, label: 'task-list' },
|
{ enabled: isLoggedIn, label: 'task-list' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { theme as antTheme, Empty } from 'antd';
|
|||||||
import { HomeContext } from '@/contexts/home-context';
|
import { HomeContext } from '@/contexts/home-context';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/contexts/app-context';
|
||||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
||||||
import { LeftPanel } from './LeftPanel';
|
import { LeftPanel } from './components/LeftPanel';
|
||||||
import { CenterPanel } from './CenterPanel';
|
import { CenterPanel } from './components/CenterPanel';
|
||||||
import { RightPanel } from './RightPanel';
|
import { RightPanel } from './components/RightPanel';
|
||||||
|
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务记录)
|
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
|
||||||
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
|
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
|
||||||
|
//
|
||||||
|
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
@@ -162,4 +162,4 @@ export function OutputPreview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
538
src/pages/home/components/TaskHistory.tsx
Normal file
538
src/pages/home/components/TaskHistory.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {get} from '../request';
|
import {get} from '../request';
|
||||||
|
// 请注意后端没有关于字段category的查询
|
||||||
// ---------- 路由常量 ----------
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
const ModelsUrlObj = {
|
const ModelsUrlObj = {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {get, post} from '../request';
|
|||||||
|
|
||||||
/** 输出类型映射 */
|
/** 输出类型映射 */
|
||||||
export enum OutputTypeMap {
|
export enum OutputTypeMap {
|
||||||
video = '图片',
|
image = '图片',
|
||||||
image = '视频',
|
video = '视频',
|
||||||
audio = '音频',
|
audio = '音频',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user