diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 7ce5b6a..c7f4ae0 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -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(getPlatform); - const [edition] = useState(() => { - return parseEdition(import.meta.env.VITE_EDITION); - }); const [isLoggedIn, setIsLoggedIn] = useState(false); const [user, setUser] = useState(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) => { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 02ace83..363440b 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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(); diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 5db318c..97ea0c9 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -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' }, ); } diff --git a/src/pages/home/HomeContent.tsx b/src/pages/home/HomeContent.tsx index 16ab033..063c652 100644 --- a/src/pages/home/HomeContent.tsx +++ b/src/pages/home/HomeContent.tsx @@ -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'; diff --git a/src/pages/home/TaskHistory.tsx b/src/pages/home/TaskHistory.tsx deleted file mode 100644 index 5a32414..0000000 --- a/src/pages/home/TaskHistory.tsx +++ /dev/null @@ -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 = { - pending: { color: 'blue', label: '排队中' }, - submitted: { color: 'cyan', label: '已提交' }, - processing: { color: 'orange', label: '处理中' }, - success: { color: 'green', label: '已完成' }, - failed: { color: 'red', label: '失败' }, -}; - -// ---------- 表格列定义 ---------- - -const columns: ColumnsType = [ - { - title: '任务 ID', - dataIndex: 'id', - key: 'id', - width: 140, - ellipsis: true, - fixed: 'left', - render: (id: string) => ( - - {id.slice(0, 10)}... - - ), - }, - { - 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) => {t}, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - width: 80, - render: (s: TaskStatusString) => { - const cfg = STATUS_CONFIG[s] || { color: 'default', label: s }; - return {cfg.label}; - }, - }, - { - title: '提示词', - dataIndex: 'prompt', - key: 'prompt', - width: 160, - ellipsis: true, - render: (p: string | undefined) => ( - {p || '-'} - ), - }, - { - title: '提交时间', - dataIndex: 'submitted_at', - key: 'submitted_at', - width: 140, - 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: '耗时(分)', - key: 'duration', - width: 80, - render: (_: unknown, record: TaskInfoItemResponseBody) => { - if (!record.submitted_at || !record.finished_at) return -; - const min = Math.round( - (new Date(record.finished_at).getTime() - new Date(record.submitted_at).getTime()) / 60000, - ); - return {min} 分钟; - }, - }, - { - title: '费用', - dataIndex: 'cost_cent', - key: 'cost_cent', - width: 80, - render: (c: number) => ( - ¥{(c / 100).toFixed(2)} - ), - }, - { - title: '用户', - dataIndex: 'user_display_name', - key: 'user_display_name', - width: 100, - ellipsis: true, - render: (n: string | null) => ( - {n || '-'} - ), - }, -]; - -// ---------- 组件 ---------- - -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(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 ( -
- {/* 标题栏 */} -
- - 任务记录 - {total > 0 && ( - 共 {total} 条 - )} - {errorMessage && ( - {errorMessage} - )} -
- - {/* 表格 — 横向 + 纵向滚动 */} -
- - 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: '暂无任务记录' }} - /> -
-
- ); -} diff --git a/src/pages/home/BannerCarousel.tsx b/src/pages/home/components/BannerCarousel.tsx similarity index 100% rename from src/pages/home/BannerCarousel.tsx rename to src/pages/home/components/BannerCarousel.tsx diff --git a/src/pages/home/CenterPanel.tsx b/src/pages/home/components/CenterPanel.tsx similarity index 100% rename from src/pages/home/CenterPanel.tsx rename to src/pages/home/components/CenterPanel.tsx diff --git a/src/pages/home/LeftPanel.tsx b/src/pages/home/components/LeftPanel.tsx similarity index 97% rename from src/pages/home/LeftPanel.tsx rename to src/pages/home/components/LeftPanel.tsx index 0e0a88e..4b011d1 100644 --- a/src/pages/home/LeftPanel.tsx +++ b/src/pages/home/components/LeftPanel.tsx @@ -1,6 +1,8 @@ // ============================================================ -// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务记录) +// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录) // 纵向拖拽分隔线支持鼠标调整上下区域高度比例 +// +// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态 // ============================================================ import { useState, useRef, useEffect, useCallback } from 'react'; diff --git a/src/pages/home/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx similarity index 100% rename from src/pages/home/ModelInputForm.tsx rename to src/pages/home/components/ModelInputForm.tsx diff --git a/src/pages/home/ModelSelector.tsx b/src/pages/home/components/ModelSelector.tsx similarity index 100% rename from src/pages/home/ModelSelector.tsx rename to src/pages/home/components/ModelSelector.tsx diff --git a/src/pages/home/OutputPreview.tsx b/src/pages/home/components/OutputPreview.tsx similarity index 99% rename from src/pages/home/OutputPreview.tsx rename to src/pages/home/components/OutputPreview.tsx index c509104..62db9e6 100644 --- a/src/pages/home/OutputPreview.tsx +++ b/src/pages/home/components/OutputPreview.tsx @@ -162,4 +162,4 @@ export function OutputPreview() { ); -} +} \ No newline at end of file diff --git a/src/pages/home/RightPanel.tsx b/src/pages/home/components/RightPanel.tsx similarity index 100% rename from src/pages/home/RightPanel.tsx rename to src/pages/home/components/RightPanel.tsx diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx new file mode 100644 index 0000000..c864510 --- /dev/null +++ b/src/pages/home/components/TaskHistory.tsx @@ -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; + +/** 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 = { + 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({ + 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(); + 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 } + : {}, + }); + }; + + // -------- 表格列定义(含列头筛选/排序)-------- + + const columns = useMemo>(() => { + const cols: ColumnsType = [ + { + title: '任务 ID', + dataIndex: 'id', + key: 'id', + width: 140, + ellipsis: true, + fixed: 'left', + render: (id: string) => ( + + {id.slice(0, 10)}... + + ), + }, + { + 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) => {t}, + }, + { + 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 {cfg.label}; + }, + }, + { + title: '提示词', + dataIndex: 'prompt', + key: 'prompt', + width: 160, + ellipsis: true, + filterIcon: (filtered: boolean) => ( + + ), + filterDropdown: undefined, // TODO: 后续加自定义搜索框 + render: (p: string | undefined) => ( + {p || '-'} + ), + }, + { + 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) => ( + + {v ? new Date(v).toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) : '-'} + + ), + }, + { + 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 {min !== null ? `${min} 分钟` : '-'}; + }, + }, + { + 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) => ( + ¥{(c / 100).toFixed(2)} + ), + }, + ]; + + // 用户列:仅企业版展示 + 可筛选 + 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; + }, [ + 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(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 ( +
+ {/* 标题栏 */} +
+ + 任务记录 + {total > 0 && ( + 共 {total} 条 + )} + {hasActiveFilters && ( + + 已筛选 + + )} + {errorMessage && ( + {errorMessage} + )} +
+ + {/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */} +
+ + 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: '确定' }} + /> +
+
+ ); +} diff --git a/src/services/modules/models.ts b/src/services/modules/models.ts index bed158f..6b9ab41 100644 --- a/src/services/modules/models.ts +++ b/src/services/modules/models.ts @@ -1,5 +1,5 @@ import {get} from '../request'; - +// 请注意后端没有关于字段category的查询 // ---------- 路由常量 ---------- const ModelsUrlObj = { diff --git a/src/services/modules/task.ts b/src/services/modules/task.ts index 44113cb..85136f4 100644 --- a/src/services/modules/task.ts +++ b/src/services/modules/task.ts @@ -18,8 +18,8 @@ import {get, post} from '../request'; /** 输出类型映射 */ export enum OutputTypeMap { - video = '图片', - image = '视频', + image = '图片', + video = '视频', audio = '音频', }