From a8ace232a4aad9993317c7eb70aeb64cf715665a Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Fri, 5 Jun 2026 20:48:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8UI?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20+=20=E5=88=86=E9=A1=B5=E5=99=A8/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=94=99=E8=AF=AF/=E8=AF=B7=E6=B1=82=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【任务列表 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 兼容未知状态 - 移除 computeDuration,替换为 getVideoDuration + formatDuration - LoginPage 移除未使用的 authState 依赖 --- PROJECT.md | 11 +- index.html | 18 +- src/components/ThemeProvider.tsx | 3 +- src/pages/home/HomeContent.tsx | 15 +- src/pages/home/components/TaskHistory.tsx | 407 ++++++++++++++++------ src/pages/login/index.tsx | 1 - src/services/request.ts | 9 +- src/theme/antd-theme.ts | 1 + 8 files changed, 346 insertions(+), 119 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index dbcd7b9..1f3742c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -169,6 +169,10 @@ - 错误日志: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 等字段。 # 样式与主题 @@ -176,8 +180,11 @@ - 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置 theme.extend)。 -- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建 - antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。 +- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。 + `ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。 + 在 src/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。 + 组件级 token(如 Pagination.itemActiveColor / itemActiveBg)在 sharedComponents 中统一管理, + 亮色/暗色主题共享,避免 CSS !important hack。 - 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。 diff --git a/index.html b/index.html index e76a48c..f8e6c45 100644 --- a/index.html +++ b/index.html @@ -1,21 +1,21 @@ - + - + 船长·HeiXiu - - -
- - + + +
+ + diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index a03a6f6..1f1d4a4 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; import { getThemeConfig } from '../theme/antd-theme'; import { @@ -66,7 +67,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { return ( - {children} + {children} ); } diff --git a/src/pages/home/HomeContent.tsx b/src/pages/home/HomeContent.tsx index 063c652..fb84a50 100644 --- a/src/pages/home/HomeContent.tsx +++ b/src/pages/home/HomeContent.tsx @@ -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 { HomeContext } from '@/contexts/home-context'; @@ -61,7 +61,7 @@ export function HomeContent() { [], ); - const contextValue = { + const contextValue = useMemo(() => ({ selectedModel, setSelectedModel: handleSetSelectedModel, taskPage, @@ -72,7 +72,16 @@ export function HomeContent() { setSelectedTask: handleSetSelectedTask, isLeftCollapsed, toggleLeftCollapsed, - }; + }), [ + selectedModel, + handleSetSelectedModel, + taskPage, + taskPageSize, + selectedTask, + handleSetSelectedTask, + isLeftCollapsed, + toggleLeftCollapsed, + ]); // ======== 拖拽调整列宽 ======== diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx index c864510..4d31a50 100644 --- a/src/pages/home/components/TaskHistory.tsx +++ b/src/pages/home/components/TaskHistory.tsx @@ -57,15 +57,14 @@ const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({ value, })); -/** 输出类型筛选选项 */ -const OUTPUT_TYPE_FILTERS = [ - { text: '图片', value: 'image' }, - { text: '视频', value: 'video' }, - { text: '音频', value: 'audio' }, -]; +/** 输出类型筛选选项(从 OutputTypeMap 派生) */ +const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({ + text, + value, +})); -/** 状态标签配置(渲染用) */ -const STATUS_CONFIG: Record = { +/** 状态标签配置(渲染用,含未来未知状态的降级处理) */ +const STATUS_CONFIG: Record = { pending: { color: 'blue', label: '排队中' }, submitted: { color: 'cyan', label: '已提交' }, processing: { color: 'orange', label: '处理中' }, @@ -73,16 +72,53 @@ const STATUS_CONFIG: Record 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, - ); +/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */ +function resolveStatus(s: string): { color: string; label: string } { + return STATUS_CONFIG[s] || { color: 'default', label: s }; } +// ---------- 列可见性持久化 ---------- + +/** localStorage 键名(遵循 ele-heixiu-* 规范) */ +const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns'; + +/** 列元数据(定义所有可用列 + 默认可见性) */ +interface ColumnMeta { key: string; title: string; defaultVisible: boolean } + +const ALL_COLUMNS: ColumnMeta[] = [ + { key: 'id', title: '任务 ID', defaultVisible: true }, + { key: 'created_at', title: '创建时间', defaultVisible: true }, + { key: 'model_name', title: '模型', defaultVisible: true }, + { key: 'output_type', title: '输出类型', defaultVisible: true }, + { key: 'status', title: '状态', defaultVisible: true }, + { key: 'prompt', title: '提示词', defaultVisible: true }, + { key: 'duration', title: '时长', defaultVisible: true }, + { key: 'cost_cent', title: '费用', defaultVisible: true }, + { key: 'user_display_name', title: '用户', defaultVisible: true }, + { key: 'provider_name', title: '供应商', defaultVisible: false }, + { key: 'fps', title: '帧率', defaultVisible: false }, + { key: 'file_size', title: '文件大小', defaultVisible: false }, + { key: 'tags', title: '标签', defaultVisible: false }, + { key: 'error_message', title: '错误信息', defaultVisible: false }, +]; + +function readStoredColumns(): Set | 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): void { + try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ } +} + +// ---------- 工具函数 ---------- + /** 从 filters 中提取单值筛选(如 status 单选) */ function pickOne(filters: TableFilters, key: string): string | undefined { const v = filters[key]; @@ -113,6 +149,51 @@ export function TaskHistory() { const isEnterprise = edition === 'enterprise'; + // -------- 列可见性 -------- + + const [visibleColumns, setVisibleColumns] = useState>(() => { + const stored = readStoredColumns(); + if (stored) return stored; + return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key)); + }); + + const handleColumnToggle = useCallback((key: string, checked: boolean) => { + setVisibleColumns((prev) => { + const next = new Set(prev); + if (checked) next.add(key); else next.delete(key); + writeStoredColumns(next); + return next; + }); + }, []); + + // -------- 主题感知斑马纹 / hover / 选中行 CSS -------- + + const { isDark } = useTheme(); + + const tableCss = useMemo(() => { + const searchIconColor = token.colorPrimary; + if (isDark) { + return ` + .task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; } + .task-table .task-row-even > td { background: transparent !important; } + .task-table .task-row-selected > td { background: ${token.blue}1A !important; } + .task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; } + .task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; } + .task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; } + .task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; } + `; + } + return ` + .task-table .task-row-odd > td { background: #fafafa !important; } + .task-table .task-row-even > td { background: #ffffff !important; } + .task-table .task-row-selected > td { background: #e6f4ff !important; } + .task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; } + .task-table .ant-table-row:hover > td { background: #f0f5ff !important; } + .task-table .task-row-selected:hover > td { background: #d6ebff !important; } + .task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; } + `; + }, [isDark, token.blue, token.colorPrimary]); + // -------- antd Table onChange 参数缓存 -------- const [tableParams, setTableParams] = useState({ @@ -244,10 +325,38 @@ export function TaskHistory() { }); }; - // -------- 表格列定义(含列头筛选/排序)-------- + // -------- 表格列定义(含列头筛选/排序 + 可见性过滤)-------- + // + // 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 → + // 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版) + // + // 可见性:通过标题栏齿轮图标控制,持久化到 localStorage + // + // ⚠️ 重要:columns 依赖中不出 token.colorPrimary,否则主题切换时 columns 重新生成 + // 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。 + // 主题色通过 searchIconColorRef 传递,不触发 columns 重建。 - const columns = useMemo>(() => { + /** 提示词搜索图标颜色(通过 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>(() => { const cols: ColumnsType = [ + // ---- 任务 ID(固定列) ---- { title: '任务 ID', dataIndex: 'id', @@ -261,79 +370,18 @@ export function TaskHistory() { ), }, + // ---- 创建时间 ---- { - 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, + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 155, 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; + const da = a.created_at ? new Date(a.created_at).getTime() : 0; + const db = b.created_at ? new Date(b.created_at).getTime() : 0; return da - db; }, - sortOrder: tableParams.sorter.field === 'submitted_at' ? tableParams.sorter.order : null, + sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null, render: (v: Date) => ( {v ? new Date(v).toLocaleString('zh-CN', { @@ -343,35 +391,153 @@ export function TaskHistory() { ), }, + // ---- 模型 ---- { - title: '耗时', - key: 'duration', + title: '模型', + dataIndex: 'model_name', + key: 'model_name', + width: 130, + ellipsis: true, + filters: modelFilters, + filteredValue: (tableParams.filters.model_name as string[]) || null, + onFilter: () => true, // 服务端筛选 + filterSearch: true, + filterMode: 'menu' as const, + }, + // ---- 输出类型 ---- + { + title: '输出类型', + dataIndex: 'output_type', + key: 'output_type', + width: 90, + filters: OUTPUT_TYPE_FILTERS, + filteredValue: (tableParams.filters.output_type as string[]) || null, + onFilter: (value, record) => record.output_type === value, // 客户端筛选 + render: (t: string) => ( + + {OutputTypeMap[t as keyof typeof OutputTypeMap] || t} + + ), + }, + // ---- 状态 ---- + { + title: '状态', + dataIndex: 'status', + key: 'status', width: 80, + filters: STATUS_FILTERS, + filteredValue: (tableParams.filters.status as string[]) || null, + onFilter: () => true, // 服务端筛选 + render: (s: string) => { + const cfg = resolveStatus(s); + return {cfg.label}; + }, + }, + // ---- 提示词 ---- + { + title: '提示词', + dataIndex: 'prompt', + key: 'prompt', + width: 160, + ellipsis: true, + filterIcon: (filtered: boolean) => ( + + ), + filterDropdown: undefined, // TODO: 后续加自定义搜索框 + render: (p: string | undefined) => ( + {p || '-'} + ), + }, + // ---- 时长(仅视频有数据,否则 "-") ---- + { + title: '时长', + key: 'duration', + width: 75, sorter: (a, b) => { - const da = computeDuration(a) ?? -1; - const db = computeDuration(b) ?? -1; + const da = getVideoDuration(a) ?? -1; + const db = getVideoDuration(b) ?? -1; return da - db; }, sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null, render: (_: unknown, record: TaskInfoItemResponseBody) => { - const min = computeDuration(record); - return {min !== null ? `${min} 分钟` : '-'}; + const sec = getVideoDuration(record); + return {sec !== null ? formatDuration(sec) : '-'}; }, }, + // ---- 费用 ---- { title: '费用', dataIndex: 'cost_cent', key: 'cost_cent', - width: 80, + width: 75, 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)} ), }, + // ---- 供应商(默认隐藏) ---- + { + 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: () => -, + }, + // ---- 文件大小(占位列,待后端支持) ---- + { + title: '文件大小', + key: 'file_size', + width: 85, + render: () => -, + }, + // ---- 标签 ---- + { + title: '标签', + dataIndex: 'tags', + key: 'tags', + width: 100, + ellipsis: true, + render: (tags: Array) => { + const filtered = tags?.filter((t): t is string => t !== null) ?? []; + if (filtered.length === 0) return -; + return ( + + {filtered.slice(0, 2).map((t) => ( + {t} + ))} + {filtered.length > 2 && ( + +{filtered.length - 2} + )} + + ); + }, + }, + // ---- 错误信息(失败任务时展示) ---- + { + title: '错误信息', + dataIndex: 'error_message', + key: 'error_message', + width: 140, + ellipsis: true, + render: (msg: string) => msg + ? {msg} + : -, + }, ]; - // 用户列:仅企业版展示 + 可筛选 + // 用户列:仅企业版可筛选 if (isEnterprise) { cols.push({ title: '用户', @@ -401,7 +567,6 @@ export function TaskHistory() { return cols; }, [ - token.colorPrimary, modelFilters, providerFilters, userFilters, @@ -410,6 +575,12 @@ export function TaskHistory() { isEnterprise, ]); + // 按 visibleColumns 过滤实际展示的列 + const columns = useMemo( + () => allColumns.filter((c) => visibleColumns.has(c.key as string)), + [allColumns, visibleColumns], + ); + // -------- 表格高度 -------- // // 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何, @@ -463,10 +634,12 @@ export function TaskHistory() { }; }, [taskPageSize]); - // -------- 行样式 -------- + // -------- 行样式:斑马纹 + 选中行 -------- - const rowClassName = (record: TaskInfoItemResponseBody) => - selectedTask?.id === record.id ? 'task-row-selected' : ''; + const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => { + const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even'; + return selectedTask?.id === _record.id ? `${base} task-row-selected` : base; + }; // -------- 是否有活跃筛选 -------- @@ -502,18 +675,52 @@ export function TaskHistory() { {errorMessage && ( {errorMessage} )} + + {/* 列可见性控制 — 齿轮图标 */} + + {ALL_COLUMNS.map((col) => ( + handleColumnToggle(col.key, e.target.checked)} + > + {col.title} + + ))} + + } + > +