♻️ refactor: restructure TaskLogsTable into modular component architecture

Refactor the monolithic TaskLogsTable component (802 lines) into a modular,
maintainable architecture following the established pattern from LogsTable
and MjLogsTable refactors.

## What Changed

### 🏗️ Architecture
- Split large single file into focused, single-responsibility components
- Introduced custom hook `useTaskLogsData` for centralized state management
- Created dedicated column definitions file for better organization
- Implemented modal components for user interactions

### 📁 New Structure
```
web/src/components/table/task-logs/
├── index.jsx                       # Main page component orchestrator
├── TaskLogsTable.jsx              # Pure table rendering component
├── TaskLogsActions.jsx            # Actions area (task records + compact mode)
├── TaskLogsFilters.jsx            # Search form component
├── TaskLogsColumnDefs.js          # Column definitions and renderers
└── modals/
    ├── ColumnSelectorModal.jsx    # Column visibility settings
    └── ContentModal.jsx           # Content viewer for JSON data

web/src/hooks/task-logs/
└── useTaskLogsData.js             # Custom hook for state & logic
```

### 🎯 Key Improvements
- **Maintainability**: Clear separation of concerns, easier to understand
- **Reusability**: Modular components can be reused independently
- **Performance**: Optimized with `useMemo` for column rendering
- **Testing**: Single-responsibility components easier to test
- **Developer Experience**: Better code organization and readability

### 🎨 Task-Specific Features Preserved
- All task type rendering with icons (MUSIC, LYRICS, video generation)
- Platform-specific rendering (Suno, Kling, Jimeng) with distinct colors
- Progress indicators for task completion status
- Video preview links for successful video generation tasks
- Admin-only columns for channel information
- Status rendering with appropriate colors and icons

### 🔧 Technical Details
- Centralized all business logic in `useTaskLogsData` custom hook
- Extracted comprehensive column definitions with Lucide React icons
- Split complex UI into focused components (table, actions, filters, modals)
- Maintained responsive design patterns for mobile compatibility
- Preserved admin permission handling for restricted features
- Optimized spacing and layout (reduced gap from 4 to 2 for better density)

### 🎮 Platform Support
- **Suno**: Music and lyrics generation with music icons
- **Kling**: Video generation with video icons
- **Jimeng**: Video generation with distinct purple styling

### 🐛 Fixes
- Improved component prop passing patterns
- Enhanced type safety through better state management
- Optimized rendering performance with proper memoization
- Streamlined export pattern using `export { default }`

## Breaking Changes
None - all existing imports and functionality preserved.
This commit is contained in:
t0ng7u
2025-07-18 22:33:05 +08:00
parent 5407a8345f
commit 3b67759730
10 changed files with 1005 additions and 803 deletions

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Button, Typography } from '@douyinfe/semi-ui';
import { IconEyeOpened } from '@douyinfe/semi-icons';
const { Text } = Typography;
const TaskLogsActions = ({
compactMode,
setCompactMode,
t,
}) => {
return (
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
<Text>{t('任务记录')}</Text>
</div>
<Button
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
);
};
export default TaskLogsActions;

View File

@@ -0,0 +1,351 @@
import React from 'react';
import {
Progress,
Tag,
Typography
} from '@douyinfe/semi-ui';
import {
Music,
FileText,
HelpCircle,
CheckCircle,
Pause,
Clock,
Play,
XCircle,
Loader,
List,
Hash,
Video,
Sparkles
} from 'lucide-react';
import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../../constants/common.constant';
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// Render functions
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份从0开始需要+1并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const durationSec = finishTime - submit_time;
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} prefixIcon={<Clock size={14} />}>
{durationSec}
</Tag>
);
}
const renderType = (type, t) => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
case TASK_ACTION_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('图生视频')}
</Tag>
);
case TASK_ACTION_TEXT_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('文生视频')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
};
const renderPlatform = (platform, t) => {
switch (platform) {
case 'suno':
return (
<Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
Suno
</Tag>
);
case 'kling':
return (
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
Kling
</Tag>
);
case 'jimeng':
return (
<Tag color='purple' shape='circle' prefixIcon={<Video size={14} />}>
Jimeng
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
};
const renderStatus = (type, t) => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
}
};
export const getTaskLogsColumns = ({
t,
COLUMN_KEYS,
copyText,
openContentModal,
isAdminUser,
}) => {
return [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.FINISH_TIME,
title: t('结束时间'),
dataIndex: 'finish_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
render: (finish, record) => {
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),
dataIndex: 'platform',
render: (text, record, index) => {
return <div>{renderPlatform(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'task_id',
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
onClick={() => {
openContentModal(JSON.stringify(record, null, 2));
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text, t)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
isNaN(text?.replace('%', '')) ? (
text || '-'
) : (
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '160px' }}
/>
)
}
</div>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('详情'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
const isVideoTask = record.action === TASK_ACTION_GENERATE || record.action === TASK_ACTION_TEXT_GENERATE;
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {
return (
<a href={text} target="_blank" rel="noopener noreferrer">
{t('点击预览视频')}
</a>
);
}
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
openContentModal(text);
}}
>
{text}
</Typography.Text>
);
},
},
];
};

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const TaskLogsFilters = ({
formInitValues,
setFormApi,
refresh,
setShowColumnSelector,
formApi,
loading,
isAdminUser,
t,
}) => {
return (
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
{/* 任务 ID */}
<Form.Input
field='task_id'
prefix={<IconSearch />}
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center">
<div></div>
<div className="flex gap-2">
<Button
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</Form>
);
};
export default TaskLogsFilters;

View File

@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import { Table, Empty } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getTaskLogsColumns } from './TaskLogsColumnDefs.js';
const TaskLogsTable = (taskLogsData) => {
const {
logs,
loading,
activePage,
pageSize,
logCount,
compactMode,
visibleColumns,
handlePageChange,
handlePageSizeChange,
copyText,
openContentModal,
isAdminUser,
t,
COLUMN_KEYS,
} = taskLogsData;
// Get all columns
const allColumns = useMemo(() => {
return getTaskLogsColumns({
t,
COLUMN_KEYS,
copyText,
openContentModal,
isAdminUser,
});
}, [
t,
COLUMN_KEYS,
copyText,
openContentModal,
isAdminUser,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
return compactMode
? visibleColumnsList.map(({ fixed, ...rest }) => rest)
: visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<Table
columns={tableColumns}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={compactMode ? undefined : { x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={
<Empty
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
/>
);
};
export default TaskLogsTable;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Layout } from '@douyinfe/semi-ui';
import CardPro from '../../common/ui/CardPro.js';
import TaskLogsTable from './TaskLogsTable.jsx';
import TaskLogsActions from './TaskLogsActions.jsx';
import TaskLogsFilters from './TaskLogsFilters.jsx';
import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
import ContentModal from './modals/ContentModal.jsx';
import { useTaskLogsData } from '../../../hooks/task-logs/useTaskLogsData.js';
const TaskLogsPage = () => {
const taskLogsData = useTaskLogsData();
return (
<>
{/* Modals */}
<ColumnSelectorModal {...taskLogsData} />
<ContentModal {...taskLogsData} />
<Layout>
<CardPro
type="type2"
statsArea={<TaskLogsActions {...taskLogsData} />}
searchArea={<TaskLogsFilters {...taskLogsData} />}
>
<TaskLogsTable {...taskLogsData} />
</CardPro>
</Layout>
</>
);
};
export default TaskLogsPage;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
import { getTaskLogsColumns } from '../TaskLogsColumnDefs.js';
const ColumnSelectorModal = ({
showColumnSelector,
setShowColumnSelector,
visibleColumns,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
COLUMN_KEYS,
isAdminUser,
copyText,
openContentModal,
t,
}) => {
// Get all columns for display in selector
const allColumns = getTaskLogsColumns({
t,
COLUMN_KEYS,
copyText,
openContentModal,
isAdminUser,
});
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip admin-only columns for non-admin users
if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
return null;
}
return (
<div key={column.key} className="w-1/2 mb-4 pr-2">
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
export default ColumnSelectorModal;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ContentModal = ({
isModalOpen,
setIsModalOpen,
modalContent,
}) => {
return (
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }}
width={800}
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
);
};
export default ContentModal;