mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-09 23:49:27 +08:00
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
/**
|
|
* This file is part of the NocoBase (R) project.
|
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
* Authors: NocoBase Team.
|
|
*
|
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
*/
|
|
|
|
import {
|
|
createStyles,
|
|
Icon,
|
|
useAPIClient,
|
|
useApp,
|
|
usePlugin,
|
|
useRequest,
|
|
useCollectionManager,
|
|
useCompile,
|
|
} from '@nocobase/client';
|
|
import { Button, Empty, Modal, Popconfirm, Popover, Progress, Space, Table, Tag, Tooltip } from 'antd';
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
|
|
import { useCurrentAppInfo } from '@nocobase/client';
|
|
import dayjs from 'dayjs';
|
|
import 'dayjs/locale/zh-cn';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import { useT } from '../locale';
|
|
|
|
const useStyles = createStyles(({ token }) => {
|
|
return {
|
|
button: {
|
|
// @ts-ignore
|
|
color: token.colorTextHeaderMenu + ' !important',
|
|
},
|
|
};
|
|
});
|
|
|
|
// Configure dayjs
|
|
dayjs.extend(relativeTime);
|
|
|
|
const renderTaskResult = (status, t) => {
|
|
if (status.type !== 'success' || !status.payload?.message?.messageId) {
|
|
return null;
|
|
}
|
|
|
|
const { messageId, messageValues } = status.payload.message;
|
|
|
|
return (
|
|
<div style={{ marginLeft: 8 }}>
|
|
<Tag color="success">{t(messageId, messageValues)}</Tag>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const useAsyncTask = () => {
|
|
const { data, refreshAsync, loading } = useRequest<any>({
|
|
url: 'asyncTasks:list',
|
|
});
|
|
return { loading, tasks: data?.data || [], refresh: refreshAsync };
|
|
};
|
|
|
|
const AsyncTasksButton = (props) => {
|
|
const { popoverVisible, setPopoverVisible, tasks, refresh, loading, hasProcessingTasks } = props;
|
|
const app = useApp();
|
|
const api = useAPIClient();
|
|
const appInfo = useCurrentAppInfo();
|
|
const t = useT();
|
|
const { styles } = useStyles();
|
|
const plugin = usePlugin<any>('async-task-manager');
|
|
const cm = useCollectionManager();
|
|
const compile = useCompile();
|
|
const showTaskResult = (task) => {
|
|
setPopoverVisible(false);
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: t('Created at'),
|
|
dataIndex: 'createdAt',
|
|
key: 'createdAt',
|
|
width: 180,
|
|
render: (createdAt: string) => (
|
|
<Tooltip title={dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss')}>{dayjs(createdAt).fromNow()}</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
title: t('Task'),
|
|
dataIndex: 'title',
|
|
key: 'title',
|
|
render: (_, record: any) => {
|
|
const title = record.title;
|
|
if (!title) {
|
|
return '-';
|
|
}
|
|
const collection = cm.getCollection(title.collection);
|
|
const actionTypeMap = {
|
|
export: t('Export'),
|
|
import: t('Import'),
|
|
'export-attachments': t('Export attachments'),
|
|
};
|
|
|
|
const actionText = actionTypeMap[title.actionType] || title.actionType;
|
|
|
|
const taskTypeMap = {
|
|
'export-attachments': t('Export {collection} attachments'),
|
|
export: t('Export {collection} data'),
|
|
import: t('Import {collection} data'),
|
|
};
|
|
|
|
const taskTemplate = taskTypeMap[title.actionType] || `${actionText}`;
|
|
return taskTemplate.replace('{collection}', compile(collection?.title || title.collection));
|
|
},
|
|
},
|
|
{
|
|
title: t('Status'),
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 160,
|
|
render: (status: any, record: any) => {
|
|
const statusMap = {
|
|
pending: {
|
|
color: 'default',
|
|
text: t('Waiting'),
|
|
icon: 'ClockCircleOutlined',
|
|
},
|
|
running: {
|
|
color: 'processing',
|
|
text: t('Processing'),
|
|
icon: 'LoadingOutlined',
|
|
},
|
|
success: {
|
|
color: 'success',
|
|
text: t('Completed'),
|
|
icon: 'CheckCircleOutlined',
|
|
},
|
|
failed: {
|
|
color: 'error',
|
|
text: t('Failed'),
|
|
icon: 'CloseCircleOutlined',
|
|
},
|
|
cancelled: {
|
|
color: 'warning',
|
|
text: t('Cancelled'),
|
|
icon: 'StopOutlined',
|
|
},
|
|
};
|
|
|
|
const { color, text } = statusMap[status.type] || {};
|
|
|
|
const renderProgress = () => {
|
|
const commonStyle = {
|
|
width: 100,
|
|
margin: 0,
|
|
};
|
|
|
|
switch (status.indicator) {
|
|
case 'spinner':
|
|
return (
|
|
<Progress
|
|
type="line"
|
|
size="small"
|
|
strokeWidth={4}
|
|
percent={100}
|
|
status="active"
|
|
showInfo={false}
|
|
style={commonStyle}
|
|
/>
|
|
);
|
|
case 'progress':
|
|
return (
|
|
<Progress
|
|
type="line"
|
|
size="small"
|
|
strokeWidth={4}
|
|
percent={Number(((record.progress?.current / record.progress?.total) * 100).toFixed(2))}
|
|
status="active"
|
|
style={commonStyle}
|
|
format={(percent) => `${percent.toFixed(1)}%`}
|
|
/>
|
|
);
|
|
case 'success':
|
|
return (
|
|
<Progress
|
|
type="line"
|
|
size="small"
|
|
strokeWidth={4}
|
|
percent={100}
|
|
status="success"
|
|
style={commonStyle}
|
|
format={() => ''}
|
|
/>
|
|
);
|
|
case 'error':
|
|
return (
|
|
<Progress
|
|
type="line"
|
|
size="small"
|
|
strokeWidth={4}
|
|
percent={100}
|
|
status="exception"
|
|
style={commonStyle}
|
|
format={() => ''}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ flex: 1 }}>{renderProgress()}</div>
|
|
<Tag
|
|
color={color}
|
|
icon={statusMap[status.type]?.icon ? <Icon type={statusMap[status.type].icon} /> : null}
|
|
style={{ margin: 0, padding: '0 4px', height: 22, width: 22 }}
|
|
/>
|
|
{renderTaskResult(status, t)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: t('Actions'),
|
|
key: 'actions',
|
|
width: 180,
|
|
render: (_, record: any) => {
|
|
const actions = [];
|
|
const isTaskCancelling = false;
|
|
|
|
if ((record.status.type === 'running' || record.status.type === 'pending') && record.cancelable) {
|
|
actions.push(
|
|
<Popconfirm
|
|
key="cancel"
|
|
title={t('Confirm cancel')}
|
|
description={t('Confirm cancel description')}
|
|
onConfirm={async () => {
|
|
await api.request({
|
|
url: 'asyncTasks:cancel',
|
|
params: {
|
|
filterByTk: record.taskId,
|
|
},
|
|
});
|
|
refresh();
|
|
}}
|
|
okText={t('Confirm')}
|
|
cancelText={t('Cancel')}
|
|
disabled={isTaskCancelling}
|
|
>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<Icon type={isTaskCancelling ? 'LoadingOutlined' : 'StopOutlined'} />}
|
|
disabled={isTaskCancelling}
|
|
>
|
|
{isTaskCancelling ? t('Cancelling') : t('Cancel')}
|
|
</Button>
|
|
</Popconfirm>,
|
|
);
|
|
}
|
|
|
|
if (record.status.type === 'success') {
|
|
if (record.status.resultType === 'file') {
|
|
actions.push(
|
|
<Button
|
|
key="download"
|
|
type="link"
|
|
size="small"
|
|
icon={<Icon type="DownloadOutlined" />}
|
|
onClick={() => {
|
|
const token = app.apiClient.auth.token;
|
|
const collection = cm.getCollection(record.title.collection);
|
|
const compiledTitle = compile(collection?.title);
|
|
const suffix = record?.title?.actionType === 'export-attachments' ? '-attachments.zip' : '.xlsx';
|
|
const fileText = `${compiledTitle}${suffix}`;
|
|
const filename =
|
|
record?.title?.actionType !== 'create migration' ? encodeURIComponent(fileText) : null;
|
|
const url = app.getApiUrl(
|
|
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${encodeURIComponent(
|
|
appInfo?.data?.name || app.name,
|
|
)}${filename ? `&filename=${filename}` : ''}`,
|
|
);
|
|
window.open(url);
|
|
}}
|
|
>
|
|
{t('Download')}
|
|
</Button>,
|
|
);
|
|
} else if (record.status.payload) {
|
|
actions.push(
|
|
<Button
|
|
key="view"
|
|
type="link"
|
|
size="small"
|
|
icon={<Icon type="EyeOutlined" />}
|
|
onClick={() => {
|
|
showTaskResult(record);
|
|
const { payload } = record.status;
|
|
const renderer = plugin.taskResultRendererManager.get(record.title.actionType);
|
|
Modal.info({
|
|
title: t('Task result'),
|
|
content: renderer ? (
|
|
React.createElement(renderer, { payload, task: record })
|
|
) : (
|
|
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
|
|
),
|
|
});
|
|
}}
|
|
>
|
|
{t('View result')}
|
|
</Button>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (record.status.type === 'failed') {
|
|
actions.push(
|
|
<Button
|
|
key="error"
|
|
type="link"
|
|
size="small"
|
|
icon={<Icon type="ExclamationCircleOutlined" />}
|
|
onClick={() => {
|
|
setPopoverVisible(false);
|
|
Modal.info({
|
|
title: t('Error Details'),
|
|
content: record.status.errors?.map((error, index) => (
|
|
<div key={index} style={{ marginBottom: 16 }}>
|
|
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
|
|
{error.code && (
|
|
<div style={{ color: '#999', fontSize: 12 }}>
|
|
{t('Error code')}: {error.code}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)),
|
|
closable: true,
|
|
width: 400,
|
|
});
|
|
}}
|
|
>
|
|
{t('Error details')}
|
|
</Button>,
|
|
);
|
|
}
|
|
|
|
return <Space size="middle">{actions}</Space>;
|
|
},
|
|
},
|
|
];
|
|
|
|
const content = (
|
|
<div style={{ maxHeight: '70vh', overflow: 'auto', width: tasks.length > 0 ? 800 : 200 }}>
|
|
{tasks.length > 0 ? (
|
|
<Table loading={loading} columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
|
) : (
|
|
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
|
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Popover
|
|
content={content}
|
|
trigger="hover"
|
|
placement="bottom"
|
|
open={popoverVisible}
|
|
onOpenChange={setPopoverVisible}
|
|
>
|
|
<Button
|
|
className={['sync-task-button', styles.button].join(' ')}
|
|
icon={<Icon type={'SyncOutlined'} spin={hasProcessingTasks} />}
|
|
onClick={() => setPopoverVisible(!popoverVisible)}
|
|
/>
|
|
</Popover>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const AsyncTasks = () => {
|
|
const { tasks, refresh, ...others } = useAsyncTask();
|
|
const app = useApp();
|
|
const [popoverVisible, setPopoverVisible] = useState(false);
|
|
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
|
|
}, [tasks]);
|
|
|
|
const handleTaskCreated = useCallback(async () => {
|
|
setPopoverVisible(true);
|
|
}, []);
|
|
const handleTaskProgress = useCallback(() => {
|
|
refresh();
|
|
console.log('handleTaskProgress');
|
|
}, []);
|
|
const handleTaskStatus = useCallback(() => {
|
|
refresh();
|
|
console.log('handleTaskStatus');
|
|
}, []);
|
|
const handleTaskCancelled = useCallback(() => {
|
|
refresh();
|
|
console.log('handleTaskCancelled');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
|
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
|
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
|
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
|
|
|
return () => {
|
|
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
|
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
|
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
|
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
|
};
|
|
}, [app, handleTaskCancelled, handleTaskCreated, handleTaskProgress, handleTaskStatus]);
|
|
|
|
return (
|
|
tasks?.length > 0 && (
|
|
<AsyncTasksButton {...{ tasks, refresh, popoverVisible, setPopoverVisible, hasProcessingTasks, ...others }} />
|
|
)
|
|
);
|
|
};
|