From 3b6775973049744643a0e8bf54088b964c0aea23 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:33:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restructure=20?= =?UTF-8?q?TaskLogsTable=20into=20modular=20component=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/components/table/TaskLogsTable.js | 803 +----------------- .../table/task-logs/TaskLogsActions.jsx | 30 + .../table/task-logs/TaskLogsColumnDefs.js | 351 ++++++++ .../table/task-logs/TaskLogsFilters.jsx | 105 +++ .../table/task-logs/TaskLogsTable.jsx | 93 ++ web/src/components/table/task-logs/index.jsx | 33 + .../task-logs/modals/ColumnSelectorModal.jsx | 86 ++ .../table/task-logs/modals/ContentModal.jsx | 23 + web/src/hooks/task-logs/useTaskLogsData.js | 280 ++++++ web/src/pages/Log/index.js | 4 +- 10 files changed, 1005 insertions(+), 803 deletions(-) create mode 100644 web/src/components/table/task-logs/TaskLogsActions.jsx create mode 100644 web/src/components/table/task-logs/TaskLogsColumnDefs.js create mode 100644 web/src/components/table/task-logs/TaskLogsFilters.jsx create mode 100644 web/src/components/table/task-logs/TaskLogsTable.jsx create mode 100644 web/src/components/table/task-logs/index.jsx create mode 100644 web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/task-logs/modals/ContentModal.jsx create mode 100644 web/src/hooks/task-logs/useTaskLogsData.js diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index 0e3abbb7..a6996611 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -1,801 +1,2 @@ -import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Music, - FileText, - HelpCircle, - CheckCircle, - Pause, - Clock, - Play, - XCircle, - Loader, - List, - Hash, - Video, - Sparkles -} from 'lucide-react'; -import { - API, - copy, - isAdmin, - showError, - showSuccess, - timestamp2string -} from '../../helpers'; - -import { - Button, - Checkbox, - Empty, - Form, - Layout, - Modal, - Progress, - Table, - Tag, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { ITEMS_PER_PAGE } from '../../constants'; -import { - IconEyeOpened, - IconSearch, -} from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; -import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant'; - -const { Text } = Typography; - -const colors = [ - 'amber', - 'blue', - 'cyan', - 'green', - 'grey', - 'indigo', - 'light-blue', - 'lime', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'violet', - 'yellow', -]; - -// 定义列键值常量 -const COLUMN_KEYS = { - SUBMIT_TIME: 'submit_time', - FINISH_TIME: 'finish_time', - DURATION: 'duration', - CHANNEL: 'channel', - PLATFORM: 'platform', - TYPE: 'type', - TASK_ID: 'task_id', - TASK_STATUS: 'task_status', - PROGRESS: 'progress', - FAIL_REASON: 'fail_reason', - RESULT_URL: 'result_url', -}; - -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 ( - }> - {durationSec} 秒 - - ); -} - -const LogsTable = () => { - const { t } = useTranslation(); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContent, setModalContent] = useState(''); - - // 列可见性状态 - const [visibleColumns, setVisibleColumns] = useState({}); - const [showColumnSelector, setShowColumnSelector] = useState(false); - const isAdminUser = isAdmin(); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - - // 加载保存的列偏好设置 - useEffect(() => { - const savedColumns = localStorage.getItem('task-logs-table-columns'); - if (savedColumns) { - try { - const parsed = JSON.parse(savedColumns); - const defaults = getDefaultColumnVisibility(); - const merged = { ...defaults, ...parsed }; - setVisibleColumns(merged); - } catch (e) { - console.error('Failed to parse saved column preferences', e); - initDefaultColumns(); - } - } else { - initDefaultColumns(); - } - }, []); - - // 获取默认列可见性 - const getDefaultColumnVisibility = () => { - return { - [COLUMN_KEYS.SUBMIT_TIME]: true, - [COLUMN_KEYS.FINISH_TIME]: true, - [COLUMN_KEYS.DURATION]: true, - [COLUMN_KEYS.CHANNEL]: isAdminUser, - [COLUMN_KEYS.PLATFORM]: true, - [COLUMN_KEYS.TYPE]: true, - [COLUMN_KEYS.TASK_ID]: true, - [COLUMN_KEYS.TASK_STATUS]: true, - [COLUMN_KEYS.PROGRESS]: true, - [COLUMN_KEYS.FAIL_REASON]: true, - [COLUMN_KEYS.RESULT_URL]: true, - }; - }; - - // 初始化默认列可见性 - const initDefaultColumns = () => { - const defaults = getDefaultColumnVisibility(); - setVisibleColumns(defaults); - localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults)); - }; - - // 处理列可见性变化 - const handleColumnVisibilityChange = (columnKey, checked) => { - const updatedColumns = { ...visibleColumns, [columnKey]: checked }; - setVisibleColumns(updatedColumns); - }; - - // 处理全选 - const handleSelectAll = (checked) => { - const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); - const updatedColumns = {}; - - allKeys.forEach((key) => { - if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) { - updatedColumns[key] = false; - } else { - updatedColumns[key] = checked; - } - }); - - setVisibleColumns(updatedColumns); - }; - - // 更新表格时保存列可见性 - useEffect(() => { - if (Object.keys(visibleColumns).length > 0) { - localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns)); - } - }, [visibleColumns]); - - const renderType = (type) => { - switch (type) { - case 'MUSIC': - return ( - }> - {t('生成音乐')} - - ); - case 'LYRICS': - return ( - }> - {t('生成歌词')} - - ); - case TASK_ACTION_GENERATE: - return ( - }> - {t('图生视频')} - - ); - case TASK_ACTION_TEXT_GENERATE: - return ( - }> - {t('文生视频')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - const renderPlatform = (platform) => { - switch (platform) { - case 'suno': - return ( - }> - Suno - - ); - case 'kling': - return ( - }> - Kling - - ); - case 'jimeng': - return ( - }> - Jimeng - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - const renderStatus = (type) => { - switch (type) { - case 'SUCCESS': - return ( - }> - {t('成功')} - - ); - case 'NOT_START': - return ( - }> - {t('未启动')} - - ); - case 'SUBMITTED': - return ( - }> - {t('队列中')} - - ); - case 'IN_PROGRESS': - return ( - }> - {t('执行中')} - - ); - case 'FAILURE': - return ( - }> - {t('失败')} - - ); - case 'QUEUED': - return ( - }> - {t('排队中')} - - ); - case 'UNKNOWN': - return ( - }> - {t('未知')} - - ); - case '': - return ( - }> - {t('正在提交')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - // 定义所有列 - const allColumns = [ - { - key: COLUMN_KEYS.SUBMIT_TIME, - title: t('提交时间'), - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - key: COLUMN_KEYS.FINISH_TIME, - title: t('结束时间'), - dataIndex: 'finish_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - 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', - className: isAdminUser ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return isAdminUser ? ( -
- } - onClick={() => { - copyText(text); - }} - > - {text} - -
- ) : ( - <> - ); - }, - }, - { - key: COLUMN_KEYS.PLATFORM, - title: t('平台'), - dataIndex: 'platform', - render: (text, record, index) => { - return
{renderPlatform(text)}
; - }, - }, - { - key: COLUMN_KEYS.TYPE, - title: t('类型'), - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - key: COLUMN_KEYS.TASK_ID, - title: t('任务ID'), - dataIndex: 'task_id', - render: (text, record, index) => { - return ( - { - setModalContent(JSON.stringify(record, null, 2)); - setIsModalOpen(true); - }} - > -
{text}
-
- ); - }, - }, - { - key: COLUMN_KEYS.TASK_STATUS, - title: t('任务状态'), - dataIndex: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - key: COLUMN_KEYS.PROGRESS, - title: t('进度'), - dataIndex: 'progress', - render: (text, record, index) => { - return ( -
- { - isNaN(text?.replace('%', '')) ? ( - text || '-' - ) : ( - - ) - } -
- ); - }, - }, - { - 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 ( - - {t('点击预览视频')} - - ); - } - if (!text) { - return t('无'); - } - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - ]; - - // 根据可见性设置过滤列 - const getVisibleColumns = () => { - return allColumns.filter((column) => visibleColumns[column.key]); - }; - - const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(0); - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(false); - - const [compactMode, setCompactMode] = useTableCompactMode('taskLogs'); - - useEffect(() => { - const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; - setPageSize(localPageSize); - loadLogs(1, localPageSize).then(); - }, []); - - let now = new Date(); - // 初始化start_timestamp为前一天 - let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - // Form 初始值 - const formInitValues = { - channel_id: '', - task_id: '', - dateRange: [ - timestamp2string(zeroNow.getTime() / 1000), - timestamp2string(now.getTime() / 1000 + 3600) - ], - }; - - // Form API 引用 - const [formApi, setFormApi] = useState(null); - - // 获取表单值的辅助函数 - const getFormValues = () => { - const formValues = formApi ? formApi.getValues() : {}; - - // 处理时间范围 - let start_timestamp = timestamp2string(zeroNow.getTime() / 1000); - let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600); - - if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) { - start_timestamp = formValues.dateRange[0]; - end_timestamp = formValues.dateRange[1]; - } - - return { - channel_id: formValues.channel_id || '', - task_id: formValues.task_id || '', - start_timestamp, - end_timestamp, - }; - }; - - const enrichLogs = (items) => { - return items.map((log) => ({ - ...log, - timestamp2string: timestamp2string(log.created_at), - key: '' + log.id, - })); - }; - - const syncPageData = (payload) => { - const items = enrichLogs(payload.items || []); - setLogs(items); - setLogCount(payload.total || 0); - setActivePage(payload.page || 1); - setPageSize(payload.page_size || pageSize); - }; - - const loadLogs = async (page = 1, size = pageSize) => { - setLoading(true); - const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues(); - let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); - let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); - let url = isAdminUser - ? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}` - : `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - const res = await API.get(url); - const { success, message, data } = res.data; - if (success) { - syncPageData(data); - } else { - showError(message); - } - setLoading(false); - }; - - const pageData = logs; - - const handlePageChange = (page) => { - loadLogs(page, pageSize).then(); - }; - - const handlePageSizeChange = async (size) => { - localStorage.setItem('task-page-size', size + ''); - await loadLogs(1, size); - }; - - const refresh = async () => { - await loadLogs(1, pageSize); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制:') + text); - } else { - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - // 列选择器模态框 - const renderColumnSelector = () => { - return ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // 为非管理员用户跳过管理员专用列 - if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - return ( - <> - {renderColumnSelector()} - - -
- - {t('任务记录')} -
- - - } - searchArea={ -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete="off" - layout="vertical" - trigger="change" - stopValidateWithError={false} - > -
-
- {/* 时间选择器 */} -
- -
- - {/* 任务 ID */} - } - placeholder={t('任务 ID')} - showClear - pure - size="small" - /> - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - )} -
- - {/* 操作按钮区域 */} -
-
-
- - - -
-
-
-
- } - > - rest) : getVisibleColumns()} - dataSource={logs} - rowKey='key' - loading={loading} - scroll={compactMode ? undefined : { x: 'max-content' }} - className="rounded-xl overflow-hidden" - size="middle" - empty={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - pagination={{ - currentPage: activePage, - pageSize: pageSize, - total: logCount, - pageSizeOptions: [10, 20, 50, 100], - showSizeChanger: true, - onPageSizeChange: handlePageSizeChange, - onPageChange: handlePageChange, - }} - /> - - - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- - - ); -}; - -export default LogsTable; +// 重构后的 TaskLogsTable - 使用新的模块化架构 +export { default } from './task-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsActions.jsx b/web/src/components/table/task-logs/TaskLogsActions.jsx new file mode 100644 index 00000000..0e1cec11 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsActions.jsx @@ -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 ( +
+
+ + {t('任务记录')} +
+ +
+ ); +}; + +export default TaskLogsActions; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js new file mode 100644 index 00000000..92936abc --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -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 ( + }> + {durationSec} 秒 + + ); +} + +const renderType = (type, t) => { + switch (type) { + case 'MUSIC': + return ( + }> + {t('生成音乐')} + + ); + case 'LYRICS': + return ( + }> + {t('生成歌词')} + + ); + case TASK_ACTION_GENERATE: + return ( + }> + {t('图生视频')} + + ); + case TASK_ACTION_TEXT_GENERATE: + return ( + }> + {t('文生视频')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +const renderPlatform = (platform, t) => { + switch (platform) { + case 'suno': + return ( + }> + Suno + + ); + case 'kling': + return ( + }> + Kling + + ); + case 'jimeng': + return ( + }> + Jimeng + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +const renderStatus = (type, t) => { + switch (type) { + case 'SUCCESS': + return ( + }> + {t('成功')} + + ); + case 'NOT_START': + return ( + }> + {t('未启动')} + + ); + case 'SUBMITTED': + return ( + }> + {t('队列中')} + + ); + case 'IN_PROGRESS': + return ( + }> + {t('执行中')} + + ); + case 'FAILURE': + return ( + }> + {t('失败')} + + ); + case 'QUEUED': + return ( + }> + {t('排队中')} + + ); + case 'UNKNOWN': + return ( + }> + {t('未知')} + + ); + case '': + return ( + }> + {t('正在提交')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +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
{text ? renderTimestamp(text) : '-'}
; + }, + }, + { + key: COLUMN_KEYS.FINISH_TIME, + title: t('结束时间'), + dataIndex: 'finish_time', + render: (text, record, index) => { + return
{text ? renderTimestamp(text) : '-'}
; + }, + }, + { + 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 ? ( +
+ } + onClick={() => { + copyText(text); + }} + > + {text} + +
+ ) : ( + <> + ); + }, + }, + { + key: COLUMN_KEYS.PLATFORM, + title: t('平台'), + dataIndex: 'platform', + render: (text, record, index) => { + return
{renderPlatform(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.TYPE, + title: t('类型'), + dataIndex: 'action', + render: (text, record, index) => { + return
{renderType(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.TASK_ID, + title: t('任务ID'), + dataIndex: 'task_id', + render: (text, record, index) => { + return ( + { + openContentModal(JSON.stringify(record, null, 2)); + }} + > +
{text}
+
+ ); + }, + }, + { + key: COLUMN_KEYS.TASK_STATUS, + title: t('任务状态'), + dataIndex: 'status', + render: (text, record, index) => { + return
{renderStatus(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.PROGRESS, + title: t('进度'), + dataIndex: 'progress', + render: (text, record, index) => { + return ( +
+ { + isNaN(text?.replace('%', '')) ? ( + text || '-' + ) : ( + + ) + } +
+ ); + }, + }, + { + 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 ( + + {t('点击预览视频')} + + ); + } + if (!text) { + return t('无'); + } + return ( + { + openContentModal(text); + }} + > + {text} + + ); + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx new file mode 100644 index 00000000..509f57b7 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -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 ( +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
+ + {/* 任务 ID */} + } + placeholder={t('任务 ID')} + showClear + pure + size="small" + /> + + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + + +
+
+
+ + ); +}; + +export default TaskLogsFilters; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsTable.jsx b/web/src/components/table/task-logs/TaskLogsTable.jsx new file mode 100644 index 00000000..b9ec6cb6 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsTable.jsx @@ -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 ( +
+ } + darkModeImage={ + + } + 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; \ No newline at end of file diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx new file mode 100644 index 00000000..f0c2b1b7 --- /dev/null +++ b/web/src/components/table/task-logs/index.jsx @@ -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 */} + + + + + } + searchArea={} + > + + + + + ); +}; + +export default TaskLogsPage; \ No newline at end of file diff --git a/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..23624a72 --- /dev/null +++ b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx @@ -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 ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {allColumns.map((column) => { + // Skip admin-only columns for non-admin users + if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) { + return null; + } + + return ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx new file mode 100644 index 00000000..f82baf90 --- /dev/null +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const ContentModal = ({ + isModalOpen, + setIsModalOpen, + modalContent, +}) => { + return ( + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + closable={null} + bodyStyle={{ height: '400px', overflow: 'auto' }} + width={800} + > +

{modalContent}

+
+ ); +}; + +export default ContentModal; \ No newline at end of file diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js new file mode 100644 index 00000000..64f1cc93 --- /dev/null +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -0,0 +1,280 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + copy, + isAdmin, + showError, + showSuccess, + timestamp2string +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useTaskLogsData = () => { + const { t } = useTranslation(); + + // Define column keys for selection + const COLUMN_KEYS = { + SUBMIT_TIME: 'submit_time', + FINISH_TIME: 'finish_time', + DURATION: 'duration', + CHANNEL: 'channel', + PLATFORM: 'platform', + TYPE: 'type', + TASK_ID: 'task_id', + TASK_STATUS: 'task_status', + PROGRESS: 'progress', + FAIL_REASON: 'fail_reason', + RESULT_URL: 'result_url', + }; + + // Basic state + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [activePage, setActivePage] = useState(1); + const [logCount, setLogCount] = useState(0); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + + // User and admin + const isAdminUser = isAdmin(); + + // Modal state + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalContent, setModalContent] = useState(''); + + // Form state + const [formApi, setFormApi] = useState(null); + let now = new Date(); + let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const formInitValues = { + channel_id: '', + task_id: '', + dateRange: [ + timestamp2string(zeroNow.getTime() / 1000), + timestamp2string(now.getTime() / 1000 + 3600) + ], + }; + + // Column visibility state + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Compact mode + const [compactMode, setCompactMode] = useTableCompactMode('taskLogs'); + + // Load saved column preferences from localStorage + useEffect(() => { + const savedColumns = localStorage.getItem('task-logs-table-columns'); + if (savedColumns) { + try { + const parsed = JSON.parse(savedColumns); + const defaults = getDefaultColumnVisibility(); + const merged = { ...defaults, ...parsed }; + setVisibleColumns(merged); + } catch (e) { + console.error('Failed to parse saved column preferences', e); + initDefaultColumns(); + } + } else { + initDefaultColumns(); + } + }, []); + + // Get default column visibility based on user role + const getDefaultColumnVisibility = () => { + return { + [COLUMN_KEYS.SUBMIT_TIME]: true, + [COLUMN_KEYS.FINISH_TIME]: true, + [COLUMN_KEYS.DURATION]: true, + [COLUMN_KEYS.CHANNEL]: isAdminUser, + [COLUMN_KEYS.PLATFORM]: true, + [COLUMN_KEYS.TYPE]: true, + [COLUMN_KEYS.TASK_ID]: true, + [COLUMN_KEYS.TASK_STATUS]: true, + [COLUMN_KEYS.PROGRESS]: true, + [COLUMN_KEYS.FAIL_REASON]: true, + [COLUMN_KEYS.RESULT_URL]: true, + }; + }; + + // Initialize default column visibility + const initDefaultColumns = () => { + const defaults = getDefaultColumnVisibility(); + setVisibleColumns(defaults); + localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults)); + }; + + // Handle column visibility change + const handleColumnVisibilityChange = (columnKey, checked) => { + const updatedColumns = { ...visibleColumns, [columnKey]: checked }; + setVisibleColumns(updatedColumns); + }; + + // Handle "Select All" checkbox + const handleSelectAll = (checked) => { + const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); + const updatedColumns = {}; + + allKeys.forEach((key) => { + if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) { + updatedColumns[key] = false; + } else { + updatedColumns[key] = checked; + } + }); + + setVisibleColumns(updatedColumns); + }; + + // Update table when column visibility changes + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns)); + } + }, [visibleColumns]); + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + + // 处理时间范围 + let start_timestamp = timestamp2string(zeroNow.getTime() / 1000); + let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600); + + if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) { + start_timestamp = formValues.dateRange[0]; + end_timestamp = formValues.dateRange[1]; + } + + return { + channel_id: formValues.channel_id || '', + task_id: formValues.task_id || '', + start_timestamp, + end_timestamp, + }; + }; + + // Enrich logs data + const enrichLogs = (items) => { + return items.map((log) => ({ + ...log, + timestamp2string: timestamp2string(log.created_at), + key: '' + log.id, + })); + }; + + // Sync page data + const syncPageData = (payload) => { + const items = enrichLogs(payload.items || []); + setLogs(items); + setLogCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + }; + + // Load logs function + const loadLogs = async (page = 1, size = pageSize) => { + setLoading(true); + const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues(); + let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); + let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); + let url = isAdminUser + ? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}` + : `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + syncPageData(data); + } else { + showError(message); + } + setLoading(false); + }; + + // Page handlers + const handlePageChange = (page) => { + loadLogs(page, pageSize).then(); + }; + + const handlePageSizeChange = async (size) => { + localStorage.setItem('task-page-size', size + ''); + await loadLogs(1, size); + }; + + // Refresh function + const refresh = async () => { + await loadLogs(1, pageSize); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制:') + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + // Modal handlers + const openContentModal = (content) => { + setModalContent(content); + setIsModalOpen(true); + }; + + // Initialize data + useEffect(() => { + const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; + setPageSize(localPageSize); + loadLogs(1, localPageSize).then(); + }, []); + + return { + // Basic state + logs, + loading, + activePage, + logCount, + pageSize, + isAdminUser, + + // Modal state + isModalOpen, + setIsModalOpen, + modalContent, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Column visibility + visibleColumns, + showColumnSelector, + setShowColumnSelector, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + + // Compact mode + compactMode, + setCompactMode, + + // Functions + loadLogs, + handlePageChange, + handlePageSizeChange, + refresh, + copyText, + openContentModal, + enrichLogs, + syncPageData, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js index fa919964..f4bed060 100644 --- a/web/src/pages/Log/index.js +++ b/web/src/pages/Log/index.js @@ -1,9 +1,9 @@ import React from 'react'; -import LogsTable from '../../components/table/LogsTable'; +import UsageLogsTable from '../../components/table/UsageLogsTable'; const Token = () => (
- +
);