From 0d3d4fcd6a9d5245f7a14b36ffb4447f83a145b1 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)
- 列重新排序、斑马纹/hover/选中样式、暗色主题适配
- 列可见性选择器(齿轮图标),localStorage 持久化
- 未知状态降级、视频时长从 ui_params.duration 计算
- 分页器固定预留 48px
【分页器修复】(antd-theme.ts)
- Pagination 添加 itemActiveColor: '#fff',激活页码始终可见
【登录错误信息修复】(request.ts)
- HTTP 401 双重校验:status === 401 && data?.code === 401
- 仅业务码 401 才走 token 刷新;其他业务码透传后端 message
【全局中文】(ThemeProvider.tsx)
- ConfigProvider locale={zhCN},分页器"条/页"等全局生效
【上下文稳定性】(HomeContent.tsx)
- contextValue useMemo 包裹
【其他】
- CSP 策略 COS 域名白名单
- OutputTypeMap 枚举值修正
- PROJECT.md 补充 401 判断策略和主题配置说明
---
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}
+
+ ))}
+
+ }
+ >
+ }
+ style={{ marginLeft: 'auto' }}
+ title="列设置"
+ />
+
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
+
+ className="task-table"
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
- scroll={{ x: isEnterprise ? 1250 : 1150, y: tableBodyHeight }}
+ scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
index 13dc464..72b3183 100644
--- a/src/pages/login/index.tsx
+++ b/src/pages/login/index.tsx
@@ -111,7 +111,6 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
const loginTab = (