mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +08:00
refactor: async tasks (#6531)
* refactor: async tasks * refactor: selectWithTitle support optionRender * refactor: adjust export filename * fix: build error * fix: export file name --------- Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
parent
eba3f79134
commit
1b782c4f95
@ -18,7 +18,14 @@ export interface SelectWithTitleProps {
|
|||||||
onChange?: (...args: any[]) => void;
|
onChange?: (...args: any[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectWithTitle({ title, defaultValue, onChange, options, fieldNames }: SelectWithTitleProps) {
|
export function SelectWithTitle({
|
||||||
|
title,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
fieldNames,
|
||||||
|
...others
|
||||||
|
}: SelectWithTitleProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const timerRef = useRef<any>(null);
|
const timerRef = useRef<any>(null);
|
||||||
return (
|
return (
|
||||||
@ -36,6 +43,7 @@ export function SelectWithTitle({ title, defaultValue, onChange, options, fieldN
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
<Select
|
<Select
|
||||||
|
{...others}
|
||||||
open={open}
|
open={open}
|
||||||
data-testid={`select-${title}`}
|
data-testid={`select-${title}`}
|
||||||
popupMatchSelectWidth={false}
|
popupMatchSelectWidth={false}
|
||||||
|
@ -568,13 +568,14 @@ export interface SchemaSettingsSelectItemProps
|
|||||||
extends Omit<SchemaSettingsItemProps, 'onChange' | 'onClick'>,
|
extends Omit<SchemaSettingsItemProps, 'onChange' | 'onClick'>,
|
||||||
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
|
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
|
||||||
value?: SelectWithTitleProps['defaultValue'];
|
value?: SelectWithTitleProps['defaultValue'];
|
||||||
|
optionRender?: (option: any, info: { index: number }) => React.ReactNode;
|
||||||
}
|
}
|
||||||
export const SchemaSettingsSelectItem: FC<SchemaSettingsSelectItemProps> = (props) => {
|
export const SchemaSettingsSelectItem: FC<SchemaSettingsSelectItemProps> = (props) => {
|
||||||
const { title, options, value, onChange, ...others } = props;
|
const { title, options, value, onChange, optionRender, ...others } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchemaSettingsItem title={title} {...others}>
|
<SchemaSettingsItem title={title} {...others}>
|
||||||
<SelectWithTitle {...{ title, defaultValue: value, onChange, options }} />
|
<SelectWithTitle {...{ title, defaultValue: value, onChange, options, optionRender }} />
|
||||||
</SchemaSettingsItem>
|
</SchemaSettingsItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,176 +1,24 @@
|
|||||||
import { PinnedPluginListProvider, SchemaComponentOptions, useApp } from '@nocobase/client';
|
/**
|
||||||
|
* 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 { PinnedPluginListProvider, SchemaComponentOptions, useRequest } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
import { AsyncTasks } from './components/AsyncTasks';
|
import { AsyncTasks } from './components/AsyncTasks';
|
||||||
import React, { useEffect, useState, createContext, useContext, useCallback } from 'react';
|
|
||||||
import { message } from 'antd';
|
|
||||||
import { useT } from './locale';
|
|
||||||
|
|
||||||
export const AsyncTaskContext = createContext<any>(null);
|
|
||||||
|
|
||||||
export const useAsyncTask = () => {
|
|
||||||
const context = useContext(AsyncTaskContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAsyncTask must be used within AsyncTaskManagerProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AsyncTaskManagerProvider = (props) => {
|
export const AsyncTaskManagerProvider = (props) => {
|
||||||
const app = useApp();
|
|
||||||
const t = useT();
|
|
||||||
const [tasks, setTasks] = useState<any[]>([]);
|
|
||||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
|
||||||
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
|
|
||||||
const [cancellingTasks, setCancellingTasks] = useState<Set<string>>(new Set());
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const [currentError, setCurrentError] = useState<any>(null);
|
|
||||||
const [resultModalVisible, setResultModalVisible] = useState(false);
|
|
||||||
const [currentTask, setCurrentTask] = useState(null);
|
|
||||||
const [wsAuthorized, setWsAuthorized] = useState(() => app.isWsAuthorized);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
|
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
const handleTaskMessage = useCallback((event: CustomEvent) => {
|
|
||||||
const tasks = event.detail;
|
|
||||||
setTasks(tasks ? tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) : []);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskCreated = useCallback((event: CustomEvent) => {
|
|
||||||
const taskData = event.detail;
|
|
||||||
setTasks((prev) => {
|
|
||||||
const newTasks = [taskData, ...prev];
|
|
||||||
return newTasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
});
|
|
||||||
setPopoverVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskProgress = useCallback((event: CustomEvent) => {
|
|
||||||
const { taskId, progress } = event.detail;
|
|
||||||
setTasks((prev) => prev.map((task) => (task.taskId === taskId ? { ...task, progress } : task)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskStatus = useCallback((event: CustomEvent) => {
|
|
||||||
const { taskId, status } = event.detail;
|
|
||||||
if (status.type === 'cancelled') {
|
|
||||||
setTasks((prev) => prev.filter((task) => task.taskId !== taskId));
|
|
||||||
} else {
|
|
||||||
setTasks((prev) => {
|
|
||||||
const newTasks = prev.map((task) => {
|
|
||||||
if (task.taskId === taskId) {
|
|
||||||
if (status.type === 'success' && task.status.type !== 'success') {
|
|
||||||
message.success(t('Task completed'));
|
|
||||||
}
|
|
||||||
if (status.type === 'failed' && task.status.type !== 'failed') {
|
|
||||||
message.error(t('Task failed'));
|
|
||||||
}
|
|
||||||
return { ...task, status };
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
});
|
|
||||||
return newTasks;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleWsAuthorized = useCallback(() => {
|
|
||||||
setWsAuthorized(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskCancelled = useCallback((event: CustomEvent) => {
|
|
||||||
const { taskId } = event.detail;
|
|
||||||
setCancellingTasks((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(taskId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
message.success(t('Task cancelled'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
app.eventBus.addEventListener('ws:message:async-tasks', handleTaskMessage);
|
|
||||||
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:authorized', handleWsAuthorized);
|
|
||||||
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
|
||||||
|
|
||||||
if (wsAuthorized) {
|
|
||||||
app.ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'request:async-tasks:list',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
app.eventBus.removeEventListener('ws:message:async-tasks', handleTaskMessage);
|
|
||||||
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:authorized', handleWsAuthorized);
|
|
||||||
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
app,
|
|
||||||
handleTaskMessage,
|
|
||||||
handleTaskCreated,
|
|
||||||
handleTaskProgress,
|
|
||||||
handleTaskStatus,
|
|
||||||
handleWsAuthorized,
|
|
||||||
handleTaskCancelled,
|
|
||||||
wsAuthorized,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCancelTask = async (taskId: string) => {
|
|
||||||
setCancellingTasks((prev) => new Set(prev).add(taskId));
|
|
||||||
try {
|
|
||||||
app.ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'request:async-tasks:cancel',
|
|
||||||
payload: { taskId },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to cancel task:', error);
|
|
||||||
setCancellingTasks((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(taskId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
tasks,
|
|
||||||
popoverVisible,
|
|
||||||
setPopoverVisible,
|
|
||||||
hasProcessingTasks,
|
|
||||||
cancellingTasks,
|
|
||||||
modalVisible,
|
|
||||||
setModalVisible,
|
|
||||||
currentError,
|
|
||||||
setCurrentError,
|
|
||||||
resultModalVisible,
|
|
||||||
setResultModalVisible,
|
|
||||||
currentTask,
|
|
||||||
setCurrentTask,
|
|
||||||
handleCancelTask,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncTaskContext.Provider value={contextValue}>
|
|
||||||
<PinnedPluginListProvider
|
<PinnedPluginListProvider
|
||||||
items={
|
items={{
|
||||||
tasks.length > 0
|
|
||||||
? {
|
|
||||||
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
|
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
|
||||||
}
|
}}
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
|
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
|
||||||
</PinnedPluginListProvider>
|
</PinnedPluginListProvider>
|
||||||
</AsyncTaskContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,13 +1,31 @@
|
|||||||
import React, { useEffect } from 'react';
|
/**
|
||||||
import { Button, Popover, Table, Tag, Progress, Space, Tooltip, Popconfirm, Modal, Empty } from 'antd';
|
* This file is part of the NocoBase (R) project.
|
||||||
import { createStyles, Icon, useApp, usePlugin } from '@nocobase/client';
|
* 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 from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { useT } from '../locale';
|
import { useT } from '../locale';
|
||||||
import { useAsyncTask } from '../AsyncTaskManagerProvider';
|
|
||||||
import { useCurrentAppInfo } from '@nocobase/client';
|
|
||||||
const useStyles = createStyles(({ token }) => {
|
const useStyles = createStyles(({ token }) => {
|
||||||
return {
|
return {
|
||||||
button: {
|
button: {
|
||||||
@ -34,93 +52,27 @@ const renderTaskResult = (status, t) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AsyncTasks = () => {
|
const useAsyncTask = () => {
|
||||||
const {
|
const { data, refreshAsync, loading } = useRequest<any>({
|
||||||
tasks,
|
url: 'asyncTasks:list',
|
||||||
popoverVisible,
|
});
|
||||||
setPopoverVisible,
|
return { loading, tasks: data?.data || [], refresh: refreshAsync };
|
||||||
hasProcessingTasks,
|
};
|
||||||
cancellingTasks,
|
|
||||||
modalVisible,
|
|
||||||
setModalVisible,
|
|
||||||
currentError,
|
|
||||||
setCurrentError,
|
|
||||||
resultModalVisible,
|
|
||||||
setResultModalVisible,
|
|
||||||
currentTask,
|
|
||||||
setCurrentTask,
|
|
||||||
handleCancelTask,
|
|
||||||
} = useAsyncTask();
|
|
||||||
|
|
||||||
const plugin = usePlugin<any>('async-task-manager');
|
const AsyncTasksButton = (props) => {
|
||||||
|
const { popoverVisible, setPopoverVisible, tasks, refresh, loading, hasProcessingTasks } = props;
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const api = useAPIClient();
|
||||||
const appInfo = useCurrentAppInfo();
|
const appInfo = useCurrentAppInfo();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
|
const plugin = usePlugin<any>('async-task-manager');
|
||||||
useEffect(() => {
|
const cm = useCollectionManager();
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const compile = useCompile();
|
||||||
if (popoverVisible) {
|
|
||||||
const popoverElements = document.querySelectorAll('.ant-popover');
|
|
||||||
const buttonElement = document.querySelector('.sync-task-button');
|
|
||||||
let clickedInside = false;
|
|
||||||
|
|
||||||
popoverElements.forEach((element) => {
|
|
||||||
if (element.contains(event.target as Node)) {
|
|
||||||
clickedInside = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (buttonElement?.contains(event.target as Node)) {
|
|
||||||
clickedInside = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clickedInside) {
|
|
||||||
setPopoverVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [popoverVisible, setPopoverVisible]);
|
|
||||||
|
|
||||||
const showTaskResult = (task) => {
|
const showTaskResult = (task) => {
|
||||||
setCurrentTask(task);
|
|
||||||
setResultModalVisible(true);
|
|
||||||
setPopoverVisible(false);
|
setPopoverVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTaskResultModal = () => {
|
|
||||||
if (!currentTask) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { payload } = currentTask.status;
|
|
||||||
const renderer = plugin.taskResultRendererManager.get(currentTask.title.actionType);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={t('Task result')}
|
|
||||||
open={resultModalVisible}
|
|
||||||
footer={[
|
|
||||||
<Button key="close" onClick={() => setResultModalVisible(false)}>
|
|
||||||
{t('Close')}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
onCancel={() => setResultModalVisible(false)}
|
|
||||||
>
|
|
||||||
{renderer ? (
|
|
||||||
React.createElement(renderer, { payload, task: currentTask })
|
|
||||||
) : (
|
|
||||||
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('Created at'),
|
title: t('Created at'),
|
||||||
@ -140,7 +92,7 @@ export const AsyncTasks = () => {
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
const collection = cm.getCollection(title.collection);
|
||||||
const actionTypeMap = {
|
const actionTypeMap = {
|
||||||
export: t('Export'),
|
export: t('Export'),
|
||||||
import: t('Import'),
|
import: t('Import'),
|
||||||
@ -156,7 +108,7 @@ export const AsyncTasks = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const taskTemplate = taskTypeMap[title.actionType] || `${actionText}`;
|
const taskTemplate = taskTypeMap[title.actionType] || `${actionText}`;
|
||||||
return taskTemplate.replace('{collection}', title.collection);
|
return taskTemplate.replace('{collection}', compile(collection.title));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -274,7 +226,7 @@ export const AsyncTasks = () => {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (_, record: any) => {
|
render: (_, record: any) => {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
const isTaskCancelling = cancellingTasks.has(record.taskId);
|
const isTaskCancelling = false;
|
||||||
|
|
||||||
if ((record.status.type === 'running' || record.status.type === 'pending') && record.cancelable) {
|
if ((record.status.type === 'running' || record.status.type === 'pending') && record.cancelable) {
|
||||||
actions.push(
|
actions.push(
|
||||||
@ -282,7 +234,15 @@ export const AsyncTasks = () => {
|
|||||||
key="cancel"
|
key="cancel"
|
||||||
title={t('Confirm cancel')}
|
title={t('Confirm cancel')}
|
||||||
description={t('Confirm cancel description')}
|
description={t('Confirm cancel description')}
|
||||||
onConfirm={() => handleCancelTask(record.taskId)}
|
onConfirm={async () => {
|
||||||
|
await api.request({
|
||||||
|
url: 'asyncTasks:cancel',
|
||||||
|
params: {
|
||||||
|
filterByTk: record.taskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
okText={t('Confirm')}
|
okText={t('Confirm')}
|
||||||
cancelText={t('Cancel')}
|
cancelText={t('Cancel')}
|
||||||
disabled={isTaskCancelling}
|
disabled={isTaskCancelling}
|
||||||
@ -309,8 +269,15 @@ export const AsyncTasks = () => {
|
|||||||
icon={<Icon type="DownloadOutlined" />}
|
icon={<Icon type="DownloadOutlined" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const token = app.apiClient.auth.token;
|
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 = encodeURIComponent(fileText); // 避免中文或特殊字符问题
|
||||||
const url = app.getApiUrl(
|
const url = app.getApiUrl(
|
||||||
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${appInfo?.data?.name || app.name}`,
|
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${encodeURIComponent(
|
||||||
|
appInfo?.data?.name || app.name,
|
||||||
|
)}&filename=${filename}`,
|
||||||
);
|
);
|
||||||
window.open(url);
|
window.open(url);
|
||||||
}}
|
}}
|
||||||
@ -325,7 +292,19 @@ export const AsyncTasks = () => {
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<Icon type="EyeOutlined" />}
|
icon={<Icon type="EyeOutlined" />}
|
||||||
onClick={() => showTaskResult(record)}
|
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')}
|
{t('View result')}
|
||||||
</Button>,
|
</Button>,
|
||||||
@ -341,9 +320,22 @@ export const AsyncTasks = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<Icon type="ExclamationCircleOutlined" />}
|
icon={<Icon type="ExclamationCircleOutlined" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentError(record.status.errors);
|
|
||||||
setModalVisible(true);
|
|
||||||
setPopoverVisible(false);
|
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')}
|
{t('Error details')}
|
||||||
@ -357,9 +349,9 @@ export const AsyncTasks = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div style={{ width: tasks.length > 0 ? 800 : 200 }}>
|
<div style={{ maxHeight: '70vh', overflow: 'auto', width: tasks.length > 0 ? 800 : 200 }}>
|
||||||
{tasks.length > 0 ? (
|
{tasks.length > 0 ? (
|
||||||
<Table columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
<Table loading={loading} columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
@ -383,30 +375,53 @@ export const AsyncTasks = () => {
|
|||||||
onClick={() => setPopoverVisible(!popoverVisible)}
|
onClick={() => setPopoverVisible(!popoverVisible)}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
{renderTaskResultModal()}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={t('Error Details')}
|
|
||||||
open={modalVisible}
|
|
||||||
onCancel={() => setModalVisible(false)}
|
|
||||||
footer={[
|
|
||||||
<Button key="ok" type="primary" onClick={() => setModalVisible(false)}>
|
|
||||||
{t('OK')}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
width={400}
|
|
||||||
>
|
|
||||||
{currentError?.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>
|
|
||||||
))}
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 }} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -1,9 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
import { BaseTaskManager } from './base-task-manager';
|
|
||||||
import { AsyncTasksManager } from './interfaces/async-task-manager';
|
|
||||||
import { CommandTaskType } from './command-task-type';
|
|
||||||
import asyncTasksResource from './resourcers/async-tasks';
|
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
import { BaseTaskManager } from './base-task-manager';
|
||||||
|
import { CommandTaskType } from './command-task-type';
|
||||||
|
import { AsyncTasksManager } from './interfaces/async-task-manager';
|
||||||
|
import asyncTasksResource from './resourcers/async-tasks';
|
||||||
|
|
||||||
export class PluginAsyncExportServer extends Plugin {
|
export class PluginAsyncExportServer extends Plugin {
|
||||||
private progressThrottles: Map<string, Function> = new Map();
|
private progressThrottles: Map<string, Function> = new Map();
|
||||||
@ -20,7 +29,7 @@ export class PluginAsyncExportServer extends Plugin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.app.container.get<AsyncTasksManager>('AsyncTaskManager').registerTaskType(CommandTaskType);
|
this.app.container.get<AsyncTasksManager>('AsyncTaskManager').registerTaskType(CommandTaskType);
|
||||||
this.app.acl.allow('asyncTasks', ['get', 'fetchFile'], 'loggedIn');
|
this.app.acl.allow('asyncTasks', ['list', 'get', 'fetchFile', 'cancel'], 'loggedIn');
|
||||||
}
|
}
|
||||||
|
|
||||||
getThrottledProgressEmitter(taskId: string, userId: string) {
|
getThrottledProgressEmitter(taskId: string, userId: string) {
|
||||||
|
@ -1,8 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 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 fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import _ from 'lodash';
|
||||||
import { basename } from 'path';
|
import { basename } from 'path';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'asyncTasks',
|
name: 'asyncTasks',
|
||||||
actions: {
|
actions: {
|
||||||
|
async list(ctx, next) {
|
||||||
|
const userId = ctx.auth.user.id;
|
||||||
|
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
|
||||||
|
const tasks = await asyncTaskManager.getTasksByTag('userId', userId);
|
||||||
|
ctx.body = _.orderBy(tasks, 'createdAt', 'desc');
|
||||||
|
await next();
|
||||||
|
},
|
||||||
async get(ctx, next) {
|
async get(ctx, next) {
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||||
@ -11,8 +29,29 @@ export default {
|
|||||||
ctx.body = taskStatus;
|
ctx.body = taskStatus;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async fetchFile(ctx, next) {
|
async cancel(ctx, next) {
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
|
const userId = ctx.auth.user.id;
|
||||||
|
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
|
||||||
|
|
||||||
|
const task = asyncTaskManager.getTask(filterByTk);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
ctx.body = 'ok';
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.tags['userId'] != userId) {
|
||||||
|
ctx.throw(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelled = await asyncTaskManager.cancelTask(filterByTk);
|
||||||
|
ctx.body = cancelled;
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async fetchFile(ctx, next) {
|
||||||
|
const { filterByTk, filename } = ctx.action.params;
|
||||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||||
const taskStatus = await taskManager.getTaskStatus(filterByTk);
|
const taskStatus = await taskManager.getTaskStatus(filterByTk);
|
||||||
// throw error if task is not success
|
// throw error if task is not success
|
||||||
@ -28,10 +67,12 @@ export default {
|
|||||||
|
|
||||||
// send file to client
|
// send file to client
|
||||||
ctx.body = fs.createReadStream(filePath);
|
ctx.body = fs.createReadStream(filePath);
|
||||||
|
// 处理文件名
|
||||||
|
let finalFileName = filename ? filename : basename(filePath);
|
||||||
|
finalFileName = encodeURIComponent(finalFileName); // 避免中文或特殊字符问题
|
||||||
ctx.set({
|
ctx.set({
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Disposition': `attachment; filename=${basename(filePath)}`,
|
'Content-Disposition': `attachment; filename=${finalFileName}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user