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:
chenos 2025-03-28 11:01:20 +08:00 committed by GitHub
parent eba3f79134
commit 1b782c4f95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 220 additions and 298 deletions

View File

@ -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}

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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 }} />
)
);
};

View File

@ -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) {

View File

@ -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();