feat: 任务列表UI重构 + 分页器/登录错误/请求拦截修复
【任务列表 UI 重构】(TaskHistory.tsx)
- 列重新排序:任务ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 → 时长 → 费用 → 供应商(隐藏) → 帧率/文件大小/标签/错误信息(隐藏) → 用户
- 斑马纹行样式 + hover 增强 + 选中行高亮 + 暗色主题适配
- 列可见性选择器(齿轮图标 Popover),持久化到 localStorage
- 视频时长通过 VideoUiParams.duration 计算,非视频显示 "-"
- 未知状态降级处理(resolveStatus → 显示原始 key)
- 分页器固定预留 48px,避免 >20 条时分页器溢出可视区
- 提示词搜索图标颜色用 CSS class 承载,避免 columns 依赖 token.colorPrimary 导致主题切换时 Table 重建
【分页器修复】(antd-theme.ts)
- Pagination 组件 token 添加 itemActiveColor: '#fff'
- 激活页码数字与 #6366F1 主题背景形成对比,亮色/暗色均可见
- 替代之前的 CSS !important hack
【登录错误信息修复】(request.ts)
- HTTP 401 拦截改为双重校验:status === 401 && data?.code === 401
- 仅业务码也为 401 时才走 token 刷新流程
- 其他业务码(如密码错误)透传后端 data.message,不再被 RefreshError 覆盖
【全局中文 locale】(ThemeProvider.tsx)
- ConfigProvider 添加 locale={zhCN},分页器显示"条/页"等中文
- 避免逐个 Table 设置不完整 locale 导致 Pagination Select 退化为 Input
【上下文稳定性】(HomeContent.tsx)
- contextValue 用 useMemo 包裹,避免每次 render 新建对象导致无关重渲染
- 配合 columns 依赖清理,减少主题切换时的级联更新
【其他】
- index.html CSP 策略增加 COS 域名白名单
- OutputTypeMap 枚举值修复为 image='图片', video='视频', audio='音频'
- TaskStatusMap STATUS_CONFIG 类型放宽为 Record<string,...> 兼容未知状态
- 移除 computeDuration,替换为 getVideoDuration + formatDuration
- LoginPage 移除未使用的 authState 依赖
This commit is contained in:
11
PROJECT.md
11
PROJECT.md
@@ -169,6 +169,10 @@
|
|||||||
|
|
||||||
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
||||||
|
|
||||||
|
- 401 判断策略:HTTP 401 拦截处双重校验——同时检查 HTTP 状态码和响应体中的业务码 `data.code`。
|
||||||
|
仅 `status === 401 && data?.code === 401` 才走 token 刷新流程;其他业务码(如密码错误)穿透到正常错误处理,
|
||||||
|
透传后端的 `data.message`。这确保登录接口返回的"账号或密码错误"不被 RefreshError("登录已过期") 覆盖。
|
||||||
|
|
||||||
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
||||||
|
|
||||||
# 样式与主题
|
# 样式与主题
|
||||||
@@ -176,8 +180,11 @@
|
|||||||
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
||||||
theme.extend)。
|
theme.extend)。
|
||||||
|
|
||||||
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建
|
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。
|
||||||
antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
`ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。
|
||||||
|
在 src/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
||||||
|
组件级 token(如 Pagination.itemActiveColor / itemActiveBg)在 sharedComponents 中统一管理,
|
||||||
|
亮色/暗色主题共享,避免 CSS !important hack。
|
||||||
|
|
||||||
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
|
||||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
||||||
">
|
">
|
||||||
<title>船长·HeiXiu</title>
|
<title>船长·HeiXiu</title>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
import { getThemeConfig } from '../theme/antd-theme';
|
import { getThemeConfig } from '../theme/antd-theme';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +67,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={ctx}>
|
<ThemeContext.Provider value={ctx}>
|
||||||
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider>
|
<ConfigProvider theme={getThemeConfig(isDark)} locale={zhCN}>{children}</ConfigProvider>
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { HomeContext } from '@/contexts/home-context';
|
import { HomeContext } from '@/contexts/home-context';
|
||||||
@@ -61,7 +61,7 @@ export function HomeContent() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = useMemo(() => ({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
setSelectedModel: handleSetSelectedModel,
|
setSelectedModel: handleSetSelectedModel,
|
||||||
taskPage,
|
taskPage,
|
||||||
@@ -72,7 +72,16 @@ export function HomeContent() {
|
|||||||
setSelectedTask: handleSetSelectedTask,
|
setSelectedTask: handleSetSelectedTask,
|
||||||
isLeftCollapsed,
|
isLeftCollapsed,
|
||||||
toggleLeftCollapsed,
|
toggleLeftCollapsed,
|
||||||
};
|
}), [
|
||||||
|
selectedModel,
|
||||||
|
handleSetSelectedModel,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
selectedTask,
|
||||||
|
handleSetSelectedTask,
|
||||||
|
isLeftCollapsed,
|
||||||
|
toggleLeftCollapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
// ======== 拖拽调整列宽 ========
|
// ======== 拖拽调整列宽 ========
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,14 @@ const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
|
|||||||
value,
|
value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** 输出类型筛选选项 */
|
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
|
||||||
const OUTPUT_TYPE_FILTERS = [
|
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
|
||||||
{ text: '图片', value: 'image' },
|
text,
|
||||||
{ text: '视频', value: 'video' },
|
value,
|
||||||
{ text: '音频', value: 'audio' },
|
}));
|
||||||
];
|
|
||||||
|
|
||||||
/** 状态标签配置(渲染用) */
|
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
|
||||||
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||||
pending: { color: 'blue', label: '排队中' },
|
pending: { color: 'blue', label: '排队中' },
|
||||||
submitted: { color: 'cyan', label: '已提交' },
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
processing: { color: 'orange', label: '处理中' },
|
processing: { color: 'orange', label: '处理中' },
|
||||||
@@ -73,16 +72,53 @@ const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }>
|
|||||||
failed: { color: 'red', label: '失败' },
|
failed: { color: 'red', label: '失败' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 工具函数 ----------
|
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
|
||||||
|
function resolveStatus(s: string): { color: string; label: string } {
|
||||||
/** 计算任务耗时(分钟),无 finished_at 时返回 null */
|
return STATUS_CONFIG[s] || { color: 'default', label: s };
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 列可见性持久化 ----------
|
||||||
|
|
||||||
|
/** 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 单选) */
|
/** 从 filters 中提取单值筛选(如 status 单选) */
|
||||||
function pickOne(filters: TableFilters, key: string): string | undefined {
|
function pickOne(filters: TableFilters, key: string): string | undefined {
|
||||||
const v = filters[key];
|
const v = filters[key];
|
||||||
@@ -113,6 +149,51 @@ export function TaskHistory() {
|
|||||||
|
|
||||||
const isEnterprise = edition === 'enterprise';
|
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 参数缓存 --------
|
// -------- antd Table onChange 参数缓存 --------
|
||||||
|
|
||||||
const [tableParams, setTableParams] = useState<TableParams>({
|
const [tableParams, setTableParams] = useState<TableParams>({
|
||||||
@@ -244,10 +325,38 @@ export function TaskHistory() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------- 表格列定义(含列头筛选/排序)--------
|
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
|
||||||
|
//
|
||||||
|
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
|
||||||
|
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
|
||||||
|
//
|
||||||
|
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
|
||||||
|
//
|
||||||
|
// ⚠️ 重要:columns 依赖中不出 token.colorPrimary,否则主题切换时 columns 重新生成
|
||||||
|
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
|
||||||
|
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
|
/** 提示词搜索图标颜色(通过 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> = [
|
const cols: ColumnsType<TaskInfoItemResponseBody> = [
|
||||||
|
// ---- 任务 ID(固定列) ----
|
||||||
{
|
{
|
||||||
title: '任务 ID',
|
title: '任务 ID',
|
||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
@@ -261,79 +370,18 @@ export function TaskHistory() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// ---- 创建时间 ----
|
||||||
{
|
{
|
||||||
title: '模型',
|
title: '创建时间',
|
||||||
dataIndex: 'model_name',
|
dataIndex: 'created_at',
|
||||||
key: 'model_name',
|
key: 'created_at',
|
||||||
width: 130,
|
width: 155,
|
||||||
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) => {
|
sorter: (a, b) => {
|
||||||
const da = a.submitted_at ? new Date(a.submitted_at).getTime() : 0;
|
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
const db = b.submitted_at ? new Date(b.submitted_at).getTime() : 0;
|
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
return da - db;
|
return da - db;
|
||||||
},
|
},
|
||||||
sortOrder: tableParams.sorter.field === 'submitted_at' ? tableParams.sorter.order : null,
|
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
|
||||||
render: (v: Date) => (
|
render: (v: Date) => (
|
||||||
<Text style={{ fontSize: 11 }}>
|
<Text style={{ fontSize: 11 }}>
|
||||||
{v ? new Date(v).toLocaleString('zh-CN', {
|
{v ? new Date(v).toLocaleString('zh-CN', {
|
||||||
@@ -343,35 +391,153 @@ export function TaskHistory() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// ---- 模型 ----
|
||||||
{
|
{
|
||||||
title: '耗时',
|
title: '模型',
|
||||||
key: 'duration',
|
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,
|
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) => {
|
sorter: (a, b) => {
|
||||||
const da = computeDuration(a) ?? -1;
|
const da = getVideoDuration(a) ?? -1;
|
||||||
const db = computeDuration(b) ?? -1;
|
const db = getVideoDuration(b) ?? -1;
|
||||||
return da - db;
|
return da - db;
|
||||||
},
|
},
|
||||||
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
|
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
|
||||||
render: (_: unknown, record: TaskInfoItemResponseBody) => {
|
render: (_: unknown, record: TaskInfoItemResponseBody) => {
|
||||||
const min = computeDuration(record);
|
const sec = getVideoDuration(record);
|
||||||
return <Text style={{ fontSize: 11 }}>{min !== null ? `${min} 分钟` : '-'}</Text>;
|
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ---- 费用 ----
|
||||||
{
|
{
|
||||||
title: '费用',
|
title: '费用',
|
||||||
dataIndex: 'cost_cent',
|
dataIndex: 'cost_cent',
|
||||||
key: 'cost_cent',
|
key: 'cost_cent',
|
||||||
width: 80,
|
width: 75,
|
||||||
sorter: (a, b) => a.cost_cent - b.cost_cent,
|
sorter: (a, b) => a.cost_cent - b.cost_cent,
|
||||||
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
|
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
|
||||||
render: (c: number) => (
|
render: (c: number) => (
|
||||||
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
|
<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) {
|
if (isEnterprise) {
|
||||||
cols.push({
|
cols.push({
|
||||||
title: '用户',
|
title: '用户',
|
||||||
@@ -401,7 +567,6 @@ export function TaskHistory() {
|
|||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [
|
}, [
|
||||||
token.colorPrimary,
|
|
||||||
modelFilters,
|
modelFilters,
|
||||||
providerFilters,
|
providerFilters,
|
||||||
userFilters,
|
userFilters,
|
||||||
@@ -410,6 +575,12 @@ export function TaskHistory() {
|
|||||||
isEnterprise,
|
isEnterprise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 按 visibleColumns 过滤实际展示的列
|
||||||
|
const columns = useMemo(
|
||||||
|
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
|
||||||
|
[allColumns, visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
// -------- 表格高度 --------
|
// -------- 表格高度 --------
|
||||||
//
|
//
|
||||||
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
|
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
|
||||||
@@ -463,10 +634,12 @@ export function TaskHistory() {
|
|||||||
};
|
};
|
||||||
}, [taskPageSize]);
|
}, [taskPageSize]);
|
||||||
|
|
||||||
// -------- 行样式 --------
|
// -------- 行样式:斑马纹 + 选中行 --------
|
||||||
|
|
||||||
const rowClassName = (record: TaskInfoItemResponseBody) =>
|
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
|
||||||
selectedTask?.id === record.id ? 'task-row-selected' : '';
|
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
|
||||||
|
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
|
||||||
|
};
|
||||||
|
|
||||||
// -------- 是否有活跃筛选 --------
|
// -------- 是否有活跃筛选 --------
|
||||||
|
|
||||||
@@ -502,18 +675,52 @@ export function TaskHistory() {
|
|||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
|
<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>
|
||||||
|
|
||||||
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
||||||
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<style>{tableCss}</style>
|
||||||
<Table<TaskInfoItemResponseBody>
|
<Table<TaskInfoItemResponseBody>
|
||||||
|
className="task-table"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={tasks}
|
dataSource={tasks}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
showHeader={true}
|
showHeader={true}
|
||||||
scroll={{ x: isEnterprise ? 1250 : 1150, y: tableBodyHeight }}
|
scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
onClick: () => setSelectedTask(record),
|
onClick: () => setSelectedTask(record),
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
const loginTab = (
|
const loginTab = (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
initialUsername={authState.username}
|
initialUsername={authState.username}
|
||||||
|
|
||||||
initialRemember={authState.remember}
|
initialRemember={authState.remember}
|
||||||
initialAutoLogin={authState.autoLogin}
|
initialAutoLogin={authState.autoLogin}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ instance.interceptors.response.use(
|
|||||||
|
|
||||||
// HTTP 200 但业务码非 0 → 业务错误
|
// HTTP 200 但业务码非 0 → 业务错误
|
||||||
if (data && data.code !== 0) {
|
if (data && data.code !== 0) {
|
||||||
// 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
|
// 业务码 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝)
|
||||||
if (data.code === 401 && response.config) {
|
if (data.code === 401 && response.config) {
|
||||||
if (isRefreshRequest(response.config)) {
|
if (isRefreshRequest(response.config)) {
|
||||||
return Promise.reject(new RefreshError());
|
return Promise.reject(new RefreshError());
|
||||||
@@ -246,8 +246,11 @@ instance.interceptors.response.use(
|
|||||||
// HTTP 状态码错误
|
// HTTP 状态码错误
|
||||||
const { status, data } = error.response;
|
const { status, data } = error.response;
|
||||||
|
|
||||||
// 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝,防死循环)
|
// 401 → 尝试刷新 token 并重试
|
||||||
if (status === 401 && error.config) {
|
// ⚠️ 同时检查响应体中的业务码 data.code:
|
||||||
|
// data.code === 401 → token 过期 → 走刷新流程
|
||||||
|
// data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message
|
||||||
|
if (status === 401 && error.config && data?.code === 401) {
|
||||||
if (isRefreshRequest(error.config)) {
|
if (isRefreshRequest(error.config)) {
|
||||||
const refreshErr = new RefreshError();
|
const refreshErr = new RefreshError();
|
||||||
logger.debug('request', 'Refresh endpoint returned 401', {
|
logger.debug('request', 'Refresh endpoint returned 401', {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const sharedComponents: ThemeConfig['components'] = {
|
|||||||
},
|
},
|
||||||
Pagination: {
|
Pagination: {
|
||||||
itemActiveBg: colorPrimary,
|
itemActiveBg: colorPrimary,
|
||||||
|
itemActiveColor: '#fff',
|
||||||
colorPrimary,
|
colorPrimary,
|
||||||
colorPrimaryHover: colorPrimaryLight,
|
colorPrimaryHover: colorPrimaryLight,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user