mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
refactor: export action (#5665)
* chore: export xlsx command * chore: container service * chore: report export progress * chore: export * chore: performInsert * chore: export command * chore: base exporter * chore: limit option * chore: export * chore: export * chore: types * chore: ws socket with auth * refactor: refactor exporter, change addRows method to handleRow method to support row-by-row processing * chore: xlsx exporter * chore: base exporter * chore: export limit * chore: import action * chore: load websocket * chore: import action * chore: object to cli args * chore: import options * chore: import action * chore: import runner * chore: import action * chore: import options * chore: import options * chore: plugin load event * chore: test * chore: i18n * chore: i18n * chore: i18n * fix: ws auth status * chore: cache in data source * chore: load datasource * chore: import with field alias * fix: build * fix: test * fix: build * fix: import schema settings * fix: import action * fix: import progress * chore: template creator * fix: import error message * fix: import error message * chore: test * chore: workflow dispatch event * chore: export alias * fix: typo * chore: error render * fix: event error * chore: send message to tags * fix: test * fix: import action setting
This commit is contained in:
parent
98103f7ad5
commit
61e7a89067
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,6 +32,7 @@ storage/plugins
|
|||||||
storage/tar
|
storage/tar
|
||||||
storage/tmp
|
storage/tmp
|
||||||
storage/print-templates
|
storage/print-templates
|
||||||
|
storage/cache
|
||||||
storage/app.watch.ts
|
storage/app.watch.ts
|
||||||
storage/.upgrading
|
storage/.upgrading
|
||||||
storage/logs-e2e
|
storage/logs-e2e
|
||||||
|
@ -13,12 +13,10 @@ import { getConfig } from './config';
|
|||||||
async function initializeGateway() {
|
async function initializeGateway() {
|
||||||
await runPluginStaticImports();
|
await runPluginStaticImports();
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
await Gateway.getInstance().run({
|
await Gateway.getInstance().run({
|
||||||
mainAppOptions: config,
|
mainAppOptions: config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeGateway().catch((e) => {
|
initializeGateway();
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
@ -76,6 +76,8 @@ export interface ApplicationOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Application {
|
export class Application {
|
||||||
|
public eventBus = new EventTarget();
|
||||||
|
|
||||||
public providers: ComponentAndProps[] = [];
|
public providers: ComponentAndProps[] = [];
|
||||||
public router: RouterManager;
|
public router: RouterManager;
|
||||||
public scopes: Record<string, any> = {};
|
public scopes: Record<string, any> = {};
|
||||||
@ -96,13 +98,15 @@ export class Application {
|
|||||||
public schemaInitializerManager: SchemaInitializerManager;
|
public schemaInitializerManager: SchemaInitializerManager;
|
||||||
public schemaSettingsManager: SchemaSettingsManager;
|
public schemaSettingsManager: SchemaSettingsManager;
|
||||||
public dataSourceManager: DataSourceManager;
|
public dataSourceManager: DataSourceManager;
|
||||||
|
|
||||||
public name: string;
|
public name: string;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
maintained = false;
|
maintained = false;
|
||||||
maintaining = false;
|
maintaining = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
|
private wsAuthorized = false;
|
||||||
|
|
||||||
get pm() {
|
get pm() {
|
||||||
return this.pluginManager;
|
return this.pluginManager;
|
||||||
}
|
}
|
||||||
@ -110,6 +114,14 @@ export class Application {
|
|||||||
return this.options.disableAcl;
|
return this.options.disableAcl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isWsAuthorized() {
|
||||||
|
return this.wsAuthorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWsAuthorized(authorized: boolean) {
|
||||||
|
this.wsAuthorized = authorized;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(protected options: ApplicationOptions = {}) {
|
constructor(protected options: ApplicationOptions = {}) {
|
||||||
this.initRequireJs();
|
this.initRequireJs();
|
||||||
define(this, {
|
define(this, {
|
||||||
@ -140,6 +152,46 @@ export class Application {
|
|||||||
this.i18n.on('languageChanged', (lng) => {
|
this.i18n.on('languageChanged', (lng) => {
|
||||||
this.apiClient.auth.locale = lng;
|
this.apiClient.auth.locale = lng;
|
||||||
});
|
});
|
||||||
|
this.initListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initListeners() {
|
||||||
|
this.eventBus.addEventListener('auth:tokenChanged', (event: CustomEvent) => {
|
||||||
|
this.setTokenInWebSocket(event.detail.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.addEventListener('maintaining:end', () => {
|
||||||
|
if (this.apiClient.auth.token) {
|
||||||
|
this.setTokenInWebSocket(this.apiClient.auth.token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setTokenInWebSocket(token: string) {
|
||||||
|
if (this.maintaining) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'auth:token',
|
||||||
|
payload: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaintaining(maintaining: boolean) {
|
||||||
|
// if maintaining is the same, do nothing
|
||||||
|
if (this.maintaining === maintaining) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maintaining = maintaining;
|
||||||
|
if (!maintaining) {
|
||||||
|
this.eventBus.dispatchEvent(new Event('maintaining:end'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRequireJs() {
|
private initRequireJs() {
|
||||||
@ -240,40 +292,9 @@ export class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
let loadFailed = false;
|
|
||||||
this.ws.on('message', (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
console.log(data.payload);
|
|
||||||
if (data?.payload?.refresh) {
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.type === 'notification') {
|
|
||||||
this.notification[data.payload?.type || 'info']({ message: data.payload?.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
|
|
||||||
if (maintaining) {
|
|
||||||
this.maintaining = true;
|
|
||||||
this.error = data.payload;
|
|
||||||
} else {
|
|
||||||
// console.log('loadFailed', loadFailed);
|
|
||||||
if (loadFailed) {
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.maintaining = false;
|
|
||||||
this.maintained = true;
|
|
||||||
this.error = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.ws.on('serverDown', () => {
|
|
||||||
this.maintaining = true;
|
|
||||||
this.maintained = false;
|
|
||||||
});
|
|
||||||
this.ws.connect();
|
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
await this.loadWebSocket();
|
||||||
await this.pm.load();
|
await this.pm.load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.ws.enabled) {
|
if (this.ws.enabled) {
|
||||||
@ -281,7 +302,6 @@ export class Application {
|
|||||||
setTimeout(() => resolve(null), 1000);
|
setTimeout(() => resolve(null), 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadFailed = true;
|
|
||||||
const toError = (error) => {
|
const toError = (error) => {
|
||||||
if (typeof error?.response?.data === 'string') {
|
if (typeof error?.response?.data === 'string') {
|
||||||
const tempElement = document.createElement('div');
|
const tempElement = document.createElement('div');
|
||||||
@ -305,6 +325,58 @@ export class Application {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadWebSocket() {
|
||||||
|
this.eventBus.addEventListener('ws:message:authorized', () => {
|
||||||
|
this.setWsAuthorized(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data?.payload?.refresh) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'notification') {
|
||||||
|
this.notification[data.payload?.type || 'info']({ message: data.payload?.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
|
||||||
|
|
||||||
|
if (maintaining) {
|
||||||
|
this.setMaintaining(true);
|
||||||
|
this.error = data.payload;
|
||||||
|
} else {
|
||||||
|
this.setMaintaining(false);
|
||||||
|
this.maintained = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
const type = data.type;
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = `ws:message:${type}`;
|
||||||
|
this.eventBus.dispatchEvent(new CustomEvent(eventName, { detail: data.payload }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('serverDown', () => {
|
||||||
|
this.maintaining = true;
|
||||||
|
this.maintained = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
const token = this.apiClient.auth.token;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
this.setTokenInWebSocket(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.connect();
|
||||||
|
}
|
||||||
|
|
||||||
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {
|
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {
|
||||||
const showError = (msg: string) => isShowError && console.error(msg);
|
const showError = (msg: string) => isShowError && console.error(msg);
|
||||||
if (!Component) {
|
if (!Component) {
|
||||||
|
@ -108,6 +108,7 @@ export class PluginManager {
|
|||||||
|
|
||||||
for (const plugin of this.pluginInstances.values()) {
|
for (const plugin of this.pluginInstances.values()) {
|
||||||
await plugin.load();
|
await plugin.load();
|
||||||
|
this.app.eventBus.dispatchEvent(new CustomEvent(`plugin:${plugin.options.name}:loaded`, { detail: plugin }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -108,6 +108,7 @@ export class WebSocketClient {
|
|||||||
this._reconnectTimes++;
|
this._reconnectTimes++;
|
||||||
const ws = new WebSocket(this.getURL(), this.options.protocols);
|
const ws = new WebSocket(this.getURL(), this.options.protocols);
|
||||||
let pingIntervalTimer: any;
|
let pingIntervalTimer: any;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('[nocobase-ws]: connected.');
|
console.log('[nocobase-ws]: connected.');
|
||||||
this.serverDown = false;
|
this.serverDown = false;
|
||||||
@ -121,6 +122,7 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
pingIntervalTimer = setInterval(() => this.send('ping'), this.pingInterval);
|
pingIntervalTimer = setInterval(() => this.send('ping'), this.pingInterval);
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
this.emit('open', {});
|
||||||
};
|
};
|
||||||
ws.onerror = async () => {
|
ws.onerror = async () => {
|
||||||
// setTimeout(() => this.connect(), this.reconnectInterval);
|
// setTimeout(() => this.connect(), this.reconnectInterval);
|
||||||
|
@ -369,7 +369,7 @@
|
|||||||
"Hide": "隐藏",
|
"Hide": "隐藏",
|
||||||
"Enable actions": "启用操作",
|
"Enable actions": "启用操作",
|
||||||
"Import": "导入",
|
"Import": "导入",
|
||||||
"Export": "导出",
|
"Export": "导出记录",
|
||||||
"Customize": "自定义",
|
"Customize": "自定义",
|
||||||
"Custom": "自定义",
|
"Custom": "自定义",
|
||||||
"Function": "Function",
|
"Function": "Function",
|
||||||
@ -740,7 +740,7 @@
|
|||||||
"Plugin starting...": "插件启动中...",
|
"Plugin starting...": "插件启动中...",
|
||||||
"Plugin stopping...": "插件停止中...",
|
"Plugin stopping...": "插件停止中...",
|
||||||
"Are you sure to delete this plugin?": "确定要删除此插件吗?",
|
"Are you sure to delete this plugin?": "确定要删除此插件吗?",
|
||||||
"Are you sure to disable this plugin?": "确定要禁用此插件吗?",
|
"Are you sure to disable this plugin?": "确定要禁用此插件吗?",
|
||||||
"re-download file": "重新下载文件",
|
"re-download file": "重新下载文件",
|
||||||
"Not enabled": "未启用",
|
"Not enabled": "未启用",
|
||||||
"Search plugin": "搜索插件",
|
"Search plugin": "搜索插件",
|
||||||
|
@ -166,6 +166,10 @@ export class Auth {
|
|||||||
*/
|
*/
|
||||||
setToken(token: string) {
|
setToken(token: string) {
|
||||||
this.setOption('token', token);
|
this.setOption('token', token);
|
||||||
|
|
||||||
|
if (this.api['app']) {
|
||||||
|
this.api['app'].eventBus.dispatchEvent(new CustomEvent('auth:tokenChanged', { detail: token }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,6 +73,7 @@ import { createPubSubManager, PubSubManager, PubSubManagerOptions } from './pub-
|
|||||||
import { SyncMessageManager } from './sync-message-manager';
|
import { SyncMessageManager } from './sync-message-manager';
|
||||||
|
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
|
import { ServiceContainer } from './service-container';
|
||||||
import { availableActions } from './acl/available-action';
|
import { availableActions } from './acl/available-action';
|
||||||
import { AuditManager } from './audit-manager';
|
import { AuditManager } from './audit-manager';
|
||||||
|
|
||||||
@ -245,6 +246,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||||
private _actionCommand: Command;
|
private _actionCommand: Command;
|
||||||
|
|
||||||
|
public container = new ServiceContainer();
|
||||||
public lockManager: LockManager;
|
public lockManager: LockManager;
|
||||||
|
|
||||||
constructor(public options: ApplicationOptions) {
|
constructor(public options: ApplicationOptions) {
|
||||||
@ -774,9 +776,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const commandName = options?.from === 'user' ? argv[0] : argv[2];
|
const commandName = options?.from === 'user' ? argv[0] : argv[2];
|
||||||
|
|
||||||
if (!this.cli.hasCommand(commandName)) {
|
if (!this.cli.hasCommand(commandName)) {
|
||||||
await this.pm.loadCommands();
|
await this.pm.loadCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = await this.cli.parseAsync(argv, options);
|
const command = await this.cli.parseAsync(argv, options);
|
||||||
|
|
||||||
this.setMaintaining({
|
this.setMaintaining({
|
||||||
|
@ -29,6 +29,8 @@ import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
|||||||
import { IPCSocketClient } from './ipc-socket-client';
|
import { IPCSocketClient } from './ipc-socket-client';
|
||||||
import { IPCSocketServer } from './ipc-socket-server';
|
import { IPCSocketServer } from './ipc-socket-server';
|
||||||
import { WSServer } from './ws-server';
|
import { WSServer } from './ws-server';
|
||||||
|
import { isMainThread, workerData } from 'node:worker_threads';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
const compress = promisify(compression());
|
const compress = promisify(compression());
|
||||||
|
|
||||||
@ -346,11 +348,14 @@ export class Gateway extends EventEmitter {
|
|||||||
|
|
||||||
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
||||||
|
|
||||||
|
let runArgs: any = [process.argv, { throwError: true, from: 'node' }];
|
||||||
|
|
||||||
|
if (!isMainThread) {
|
||||||
|
runArgs = [workerData.argv, { throwError: true, from: 'user' }];
|
||||||
|
}
|
||||||
|
|
||||||
mainApp
|
mainApp
|
||||||
.runAsCLI(process.argv, {
|
.runAsCLI(...runArgs)
|
||||||
throwError: true,
|
|
||||||
from: 'node',
|
|
||||||
})
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (!isStart && !(await mainApp.isStarted())) {
|
if (!isStart && !(await mainApp.isStarted())) {
|
||||||
await mainApp.stop({ logging: false });
|
await mainApp.stop({ logging: false });
|
||||||
@ -358,8 +363,13 @@ export class Gateway extends EventEmitter {
|
|||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
if (e.code !== 'commander.helpDisplayed') {
|
if (e.code !== 'commander.helpDisplayed') {
|
||||||
|
if (!isMainThread) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
mainApp.log.error(e);
|
mainApp.log.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStart && !(await mainApp.isStarted())) {
|
if (!isStart && !(await mainApp.isStarted())) {
|
||||||
await mainApp.stop({ logging: false });
|
await mainApp.stop({ logging: false });
|
||||||
}
|
}
|
||||||
@ -416,6 +426,55 @@ export class Gateway extends EventEmitter {
|
|||||||
|
|
||||||
this.wsServer = new WSServer();
|
this.wsServer = new WSServer();
|
||||||
|
|
||||||
|
this.wsServer.on('message', async ({ client, message }) => {
|
||||||
|
const app = await AppSupervisor.getInstance().getApp(client.app);
|
||||||
|
const parsedMessage = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
if (!parsedMessage.type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check app has event listener
|
||||||
|
|
||||||
|
if (!app.listenerCount(`ws:setTag`)) {
|
||||||
|
app.on('ws:setTag', ({ clientId, tagKey, tagValue }) => {
|
||||||
|
this.wsServer.setClientTag(clientId, tagKey, tagValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('ws:sendToTag', ({ tagKey, tagValue, message }) => {
|
||||||
|
this.wsServer.sendToConnectionsByTags(
|
||||||
|
[
|
||||||
|
{ tagName: tagKey, tagValue },
|
||||||
|
{ tagName: 'app', tagValue: app.name },
|
||||||
|
],
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('ws:sendToTags', ({ tags, message }) => {
|
||||||
|
this.wsServer.sendToConnectionsByTags(tags, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('ws:authorized', ({ clientId, userId }) => {
|
||||||
|
this.wsServer.sendToConnectionsByTags(
|
||||||
|
[
|
||||||
|
{ tagName: 'userId', tagValue: userId },
|
||||||
|
{ tagName: 'app', tagValue: app.name },
|
||||||
|
],
|
||||||
|
{ type: 'authorized' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = `ws:message:${parsedMessage.type}`;
|
||||||
|
|
||||||
|
app.emit(eventName, {
|
||||||
|
clientId: client.id,
|
||||||
|
tags: [...client.tags],
|
||||||
|
payload: parsedMessage.payload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.server.on('upgrade', (request, socket, head) => {
|
this.server.on('upgrade', (request, socket, head) => {
|
||||||
const { pathname } = parse(request.url);
|
const { pathname } = parse(request.url);
|
||||||
|
|
||||||
|
@ -8,13 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Gateway, IncomingRequest } from '../gateway';
|
import { Gateway, IncomingRequest } from '../gateway';
|
||||||
import WebSocket, { WebSocketServer } from 'ws';
|
import WebSocket, { WebSocketServer as WSS } from 'ws';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import { AppSupervisor } from '../app-supervisor';
|
import { AppSupervisor } from '../app-supervisor';
|
||||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { Logger } from '@nocobase/logger';
|
import { Logger } from '@nocobase/logger';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
declare class WebSocketWithId extends WebSocket {
|
declare class WebSocketWithId extends WebSocket {
|
||||||
id: string;
|
id: string;
|
||||||
@ -22,10 +23,11 @@ declare class WebSocketWithId extends WebSocket {
|
|||||||
|
|
||||||
interface WebSocketClient {
|
interface WebSocketClient {
|
||||||
ws: WebSocketWithId;
|
ws: WebSocketWithId;
|
||||||
tags: string[];
|
tags: Set<string>;
|
||||||
url: string;
|
url: string;
|
||||||
headers: any;
|
headers: any;
|
||||||
app?: string;
|
app?: string;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPayloadByErrorCode(code, options) {
|
function getPayloadByErrorCode(code, options) {
|
||||||
@ -33,13 +35,14 @@ function getPayloadByErrorCode(code, options) {
|
|||||||
return lodash.omit(applyErrorWithArgs(error, options), ['status', 'maintaining']);
|
return lodash.omit(applyErrorWithArgs(error, options), ['status', 'maintaining']);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WSServer {
|
export class WSServer extends EventEmitter {
|
||||||
wss: WebSocket.Server;
|
wss: WebSocket.Server;
|
||||||
webSocketClients = new Map<string, WebSocketClient>();
|
webSocketClients = new Map<string, WebSocketClient>();
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.wss = new WebSocketServer({ noServer: true });
|
super();
|
||||||
|
this.wss = new WSS({ noServer: true });
|
||||||
|
|
||||||
this.wss.on('connection', (ws: WebSocketWithId, request: IncomingMessage) => {
|
this.wss.on('connection', (ws: WebSocketWithId, request: IncomingMessage) => {
|
||||||
const client = this.addNewConnection(ws, request);
|
const client = this.addNewConnection(ws, request);
|
||||||
@ -53,18 +56,33 @@ export class WSServer {
|
|||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.removeConnection(ws.id);
|
this.removeConnection(ws.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
if (message.toString() === 'ping') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Gateway.getInstance().on('appSelectorChanged', () => {
|
Gateway.getInstance().on('appSelectorChanged', () => {
|
||||||
// reset connection app tags
|
|
||||||
this.loopThroughConnections(async (client) => {
|
this.loopThroughConnections(async (client) => {
|
||||||
const handleAppName = await Gateway.getInstance().getRequestHandleAppName({
|
const handleAppName = await Gateway.getInstance().getRequestHandleAppName({
|
||||||
url: client.url,
|
url: client.url,
|
||||||
headers: client.headers,
|
headers: client.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.tags = client.tags.filter((tag) => !tag.startsWith('app#'));
|
for (const tag of client.tags) {
|
||||||
client.tags.push(`app#${handleAppName}`);
|
if (tag.startsWith('app#')) {
|
||||||
|
client.tags.delete(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.tags.add(`app#${handleAppName}`);
|
||||||
|
|
||||||
AppSupervisor.getInstance().bootStrapApp(handleAppName);
|
AppSupervisor.getInstance().bootStrapApp(handleAppName);
|
||||||
});
|
});
|
||||||
@ -121,21 +139,26 @@ export class WSServer {
|
|||||||
|
|
||||||
addNewConnection(ws: WebSocketWithId, request: IncomingMessage) {
|
addNewConnection(ws: WebSocketWithId, request: IncomingMessage) {
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
|
|
||||||
ws.id = id;
|
ws.id = id;
|
||||||
|
|
||||||
this.webSocketClients.set(id, {
|
this.webSocketClients.set(id, {
|
||||||
ws,
|
ws,
|
||||||
tags: [],
|
tags: new Set(),
|
||||||
url: request.url,
|
url: request.url,
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
|
id,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setClientApp(this.webSocketClients.get(id));
|
this.setClientApp(this.webSocketClients.get(id));
|
||||||
|
|
||||||
return this.webSocketClients.get(id);
|
return this.webSocketClients.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setClientTag(clientId: string, tagKey: string, tagValue: string) {
|
||||||
|
const client = this.webSocketClients.get(clientId);
|
||||||
|
client.tags.add(`${tagKey}#${tagValue}`);
|
||||||
|
console.log(`client tags: ${Array.from(client.tags)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async setClientApp(client: WebSocketClient) {
|
async setClientApp(client: WebSocketClient) {
|
||||||
const req: IncomingRequest = {
|
const req: IncomingRequest = {
|
||||||
url: client.url,
|
url: client.url,
|
||||||
@ -146,7 +169,7 @@ export class WSServer {
|
|||||||
|
|
||||||
client.app = handleAppName;
|
client.app = handleAppName;
|
||||||
console.log(`client tags: app#${handleAppName}`);
|
console.log(`client tags: app#${handleAppName}`);
|
||||||
client.tags.push(`app#${handleAppName}`);
|
client.tags.add(`app#${handleAppName}`);
|
||||||
|
|
||||||
const hasApp = AppSupervisor.getInstance().hasApp(handleAppName);
|
const hasApp = AppSupervisor.getInstance().hasApp(handleAppName);
|
||||||
|
|
||||||
@ -191,8 +214,19 @@ export class WSServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendToConnectionsByTag(tagName: string, tagValue: string, sendMessage: object) {
|
sendToConnectionsByTag(tagName: string, tagValue: string, sendMessage: object) {
|
||||||
|
this.sendToConnectionsByTags([{ tagName, tagValue }], sendMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to clients that match all the given tag conditions
|
||||||
|
* @param tags Array of tag conditions, each condition is an object with tagName and tagValue
|
||||||
|
* @param sendMessage Message to be sent
|
||||||
|
*/
|
||||||
|
sendToConnectionsByTags(tags: Array<{ tagName: string; tagValue: string }>, sendMessage: object) {
|
||||||
this.loopThroughConnections((client: WebSocketClient) => {
|
this.loopThroughConnections((client: WebSocketClient) => {
|
||||||
if (client.tags.includes(`${tagName}#${tagValue}`)) {
|
const allTagsMatch = tags.every(({ tagName, tagValue }) => client.tags.has(`${tagName}#${tagValue}`));
|
||||||
|
|
||||||
|
if (allTagsMatch) {
|
||||||
this.sendMessageToConnection(client, sendMessage);
|
this.sendMessageToConnection(client, sendMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -395,7 +395,7 @@ export class PluginManager {
|
|||||||
const source = [];
|
const source = [];
|
||||||
for (const packageName of packageNames) {
|
for (const packageName of packageNames) {
|
||||||
const dirname = await getPluginBasePath(packageName);
|
const dirname = await getPluginBasePath(packageName);
|
||||||
const directory = join(dirname, 'server/commands/*.' + (basename(dirname) === 'src' ? 'ts' : 'js'));
|
const directory = join(dirname, 'server/commands/*.' + (basename(dirname) === 'src' ? '{ts,js}' : 'js'));
|
||||||
|
|
||||||
source.push(directory.replaceAll(sep, '/'));
|
source.push(directory.replaceAll(sep, '/'));
|
||||||
}
|
}
|
||||||
@ -403,7 +403,7 @@ export class PluginManager {
|
|||||||
if (typeof plugin === 'string') {
|
if (typeof plugin === 'string') {
|
||||||
const { packageName } = await PluginManager.parseName(plugin);
|
const { packageName } = await PluginManager.parseName(plugin);
|
||||||
const dirname = await getPluginBasePath(packageName);
|
const dirname = await getPluginBasePath(packageName);
|
||||||
const directory = join(dirname, 'server/commands/*.' + (basename(dirname) === 'src' ? 'ts' : 'js'));
|
const directory = join(dirname, 'server/commands/*.' + (basename(dirname) === 'src' ? '{ts,js}' : 'js'));
|
||||||
source.push(directory.replaceAll(sep, '/'));
|
source.push(directory.replaceAll(sep, '/'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -411,6 +411,7 @@ export class PluginManager {
|
|||||||
ignore: ['**/*.d.ts'],
|
ignore: ['**/*.d.ts'],
|
||||||
cwd: process.env.NODE_MODULES_PATH,
|
cwd: process.env.NODE_MODULES_PATH,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const callback = await importModule(file);
|
const callback = await importModule(file);
|
||||||
callback(this.app);
|
callback(this.app);
|
||||||
|
15
packages/core/server/src/service-container.ts
Normal file
15
packages/core/server/src/service-container.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export class ServiceContainer {
|
||||||
|
private services: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
public register<T>(name: string, service: T) {
|
||||||
|
if (typeof service === 'function') {
|
||||||
|
service = service();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.services.set(name, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get<T>(name: string): T {
|
||||||
|
return this.services.get(name);
|
||||||
|
}
|
||||||
|
}
|
@ -36,6 +36,6 @@ export * from './uid';
|
|||||||
export * from './url';
|
export * from './url';
|
||||||
export * from './i18n';
|
export * from './i18n';
|
||||||
export * from './wrap-middleware';
|
export * from './wrap-middleware';
|
||||||
|
export * from './object-to-cli-args';
|
||||||
export { dayjs, lodash };
|
export { dayjs, lodash };
|
||||||
export { Schema } from '@formily/json-schema';
|
export { Schema } from '@formily/json-schema';
|
||||||
|
13
packages/core/utils/src/object-to-cli-args.ts
Normal file
13
packages/core/utils/src/object-to-cli-args.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Convert object to CLI arguments array
|
||||||
|
* @param obj Object to convert
|
||||||
|
* @returns CLI arguments array
|
||||||
|
*/
|
||||||
|
export function objectToCliArgs(obj: Record<string, any>): string[] {
|
||||||
|
return Object.entries(obj)
|
||||||
|
.filter(([_, value]) => value !== null && value !== undefined)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const stringValue = typeof value === 'object' ? JSON.stringify(value) : value;
|
||||||
|
return `--${key}=${stringValue}`;
|
||||||
|
});
|
||||||
|
}
|
@ -54,11 +54,14 @@ export const useExportAction = () => {
|
|||||||
content: t('Export warning', { limit: exportLimit }),
|
content: t('Export warning', { limit: exportLimit }),
|
||||||
okText: t('Start export'),
|
okText: t('Start export'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
field.data.loading = true;
|
field.data.loading = true;
|
||||||
const { exportSettings } = lodash.cloneDeep(actionSchema?.['x-action-settings'] ?? {});
|
const { exportSettings } = lodash.cloneDeep(actionSchema?.['x-action-settings'] ?? {});
|
||||||
|
|
||||||
exportSettings.forEach((es) => {
|
exportSettings.forEach((es) => {
|
||||||
const { uiSchema, interface: fieldInterface } =
|
const { uiSchema, interface: fieldInterface } =
|
||||||
getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {};
|
getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Export warning": "每次最多导出 {{limit}} 行数据,超出的将被忽略。",
|
"Export warning": "每次最多导出记录 {{limit}} 行数据,超出的将被忽略。",
|
||||||
"Start export": "开始导出",
|
"Start export": "开始导出记录",
|
||||||
"another export action is running, please try again later.": "另一导出任务正在运行,请稍后重试。",
|
"another export action is running, please try again later.": "另一导出记录任务正在运行,请稍后重试。",
|
||||||
"True": "是",
|
"True": "是",
|
||||||
"False": "否"
|
"False": "否"
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { createMockServer, MockServer } from '@nocobase/test';
|
import { createMockServer, MockServer } from '@nocobase/test';
|
||||||
import { uid } from '@nocobase/utils';
|
import { uid } from '@nocobase/utils';
|
||||||
import XlsxExporter from '../xlsx-exporter';
|
import { XlsxExporter } from '../services/xlsx-exporter';
|
||||||
import XLSX from 'xlsx';
|
import XLSX from 'xlsx';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -510,6 +510,7 @@ describe('export to xlsx', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.destroy();
|
await app.destroy();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when export field not exists', async () => {
|
it('should throw error when export field not exists', async () => {
|
||||||
@ -1374,4 +1375,127 @@ describe('export to xlsx', () => {
|
|||||||
fs.unlinkSync(xlsxFilePath);
|
fs.unlinkSync(xlsxFilePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should respect the EXPORT_LIMIT env variable', async () => {
|
||||||
|
vi.stubEnv('EXPORT_LIMIT', '30'); // Set a small limit
|
||||||
|
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{ type: 'string', name: 'name' },
|
||||||
|
{ type: 'integer', name: 'age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const values = Array.from({ length: 100 }).map((_, index) => {
|
||||||
|
return {
|
||||||
|
name: `user${index}`,
|
||||||
|
age: index % 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.model.bulkCreate(values);
|
||||||
|
|
||||||
|
const exporter = new XlsxExporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
chunkSize: 10,
|
||||||
|
columns: [
|
||||||
|
{ dataIndex: ['name'], defaultTitle: 'Name' },
|
||||||
|
{ dataIndex: ['age'], defaultTitle: 'Age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wb = await exporter.run();
|
||||||
|
const firstSheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
|
||||||
|
|
||||||
|
expect(sheetData.length).toBe(31); // 30 users + 1 header
|
||||||
|
expect(sheetData[0]).toEqual(['Name', 'Age']); // header
|
||||||
|
expect(sheetData[1]).toEqual(['user0', 0]); // first user
|
||||||
|
expect(sheetData[30]).toEqual(['user29', 29]); // last user
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default EXPORT_LIMIT (2000) when env not set', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{ type: 'string', name: 'name' },
|
||||||
|
{ type: 'integer', name: 'age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const values = Array.from({ length: 2500 }).map((_, index) => {
|
||||||
|
return {
|
||||||
|
name: `user${index}`,
|
||||||
|
age: index % 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.model.bulkCreate(values);
|
||||||
|
|
||||||
|
const exporter = new XlsxExporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
chunkSize: 10,
|
||||||
|
columns: [
|
||||||
|
{ dataIndex: ['name'], defaultTitle: 'Name' },
|
||||||
|
{ dataIndex: ['age'], defaultTitle: 'Age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wb = await exporter.run();
|
||||||
|
const firstSheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
|
||||||
|
|
||||||
|
expect(sheetData.length).toBe(2001); // 2000 users + 1 header
|
||||||
|
expect(sheetData[0]).toEqual(['Name', 'Age']); // header
|
||||||
|
expect(sheetData[1]).toEqual(['user0', 0]); // first user
|
||||||
|
expect(sheetData[2000]).toEqual(['user1999', 99]); // last user
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect the limit option when exporting data', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{ type: 'string', name: 'name' },
|
||||||
|
{ type: 'integer', name: 'age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const values = Array.from({ length: 100 }).map((_, index) => {
|
||||||
|
return {
|
||||||
|
name: `user${index}`,
|
||||||
|
age: index % 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.model.bulkCreate(values);
|
||||||
|
|
||||||
|
const exporter = new XlsxExporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
chunkSize: 10,
|
||||||
|
limit: 10,
|
||||||
|
columns: [
|
||||||
|
{ dataIndex: ['name'], defaultTitle: 'Name' },
|
||||||
|
{ dataIndex: ['age'], defaultTitle: 'Age' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wb = await exporter.run();
|
||||||
|
const firstSheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const sheetData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
|
||||||
|
|
||||||
|
expect(sheetData.length).toBe(11); // 10 users + 1 header
|
||||||
|
expect(sheetData[0]).toEqual(['Name', 'Age']); // header
|
||||||
|
expect(sheetData[1]).toEqual(['user0', 0]); // first user
|
||||||
|
expect(sheetData[10]).toEqual(['user9', 9]); // last user
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
import { Context, Next } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
import { Repository } from '@nocobase/database';
|
import { Repository } from '@nocobase/database';
|
||||||
|
|
||||||
import XlsxExporter from '../xlsx-exporter';
|
import { XlsxExporter } from '../services/xlsx-exporter';
|
||||||
import XLSX from 'xlsx';
|
import XLSX from 'xlsx';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
import { DataSource } from '@nocobase/data-source-manager';
|
import { DataSource } from '@nocobase/data-source-manager';
|
||||||
@ -54,6 +54,10 @@ async function exportXlsxAction(ctx: Context, next: Next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exportXlsx(ctx: Context, next: Next) {
|
export async function exportXlsx(ctx: Context, next: Next) {
|
||||||
|
if (ctx.exportHandled) {
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
|
||||||
if (mutex.isLocked()) {
|
if (mutex.isLocked()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
ctx.t(`another export action is running, please try again later.`, {
|
ctx.t(`another export action is running, please try again later.`, {
|
||||||
|
@ -46,9 +46,13 @@ export class PluginActionExportServer extends Plugin {
|
|||||||
dataSource.acl.setAvailableAction('export', {
|
dataSource.acl.setAvailableAction('export', {
|
||||||
displayName: '{{t("Export")}}',
|
displayName: '{{t("Export")}}',
|
||||||
allowConfigureFields: true,
|
allowConfigureFields: true,
|
||||||
|
aliases: ['export', 'exportAttachments'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PluginActionExportServer;
|
export default PluginActionExportServer;
|
||||||
|
|
||||||
|
export * from './services/base-exporter';
|
||||||
|
export * from './services/xlsx-exporter';
|
||||||
|
@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
FindOptions,
|
||||||
|
ICollection,
|
||||||
|
ICollectionManager,
|
||||||
|
IField,
|
||||||
|
IModel,
|
||||||
|
IRelationField,
|
||||||
|
} from '@nocobase/data-source-manager';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { deepGet } from '../utils/deep-get';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
export type ExportOptions = {
|
||||||
|
collectionManager: ICollectionManager;
|
||||||
|
collection: ICollection;
|
||||||
|
repository?: any;
|
||||||
|
fields: Array<Array<string>>;
|
||||||
|
findOptions?: FindOptions;
|
||||||
|
chunkSize?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
abstract class BaseExporter<T extends ExportOptions = ExportOptions> extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* You can adjust the maximum number of exported rows based on business needs and system
|
||||||
|
* available resources. However, please note that you need to fully understand the risks
|
||||||
|
* after the modification. Increasing the maximum number of rows that can be exported may
|
||||||
|
* increase system resource usage, leading to increased processing delays for other
|
||||||
|
* requests, or even server processes being recycled by the operating system.
|
||||||
|
*
|
||||||
|
* 您可以根据业务需求和系统可用资源等参数,调整最大导出数量的限制。但请注意,您需要充分了解修改之后的风险,
|
||||||
|
* 增加最大可导出的行数可能会导致系统资源占用率升高,导致其他请求处理延迟增加、无法处理、甚至
|
||||||
|
* 服务端进程被操作系统回收等问题。
|
||||||
|
*/
|
||||||
|
protected limit: number;
|
||||||
|
|
||||||
|
protected constructor(protected options: T) {
|
||||||
|
super();
|
||||||
|
this.limit = options.limit ?? (process.env['EXPORT_LIMIT'] ? parseInt(process.env['EXPORT_LIMIT']) : 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract init(ctx?): Promise<void>;
|
||||||
|
abstract finalize(): Promise<any>;
|
||||||
|
abstract handleRow(row: any, ctx?): Promise<void>;
|
||||||
|
|
||||||
|
async run(ctx?): Promise<any> {
|
||||||
|
await this.init(ctx);
|
||||||
|
|
||||||
|
const { collection, chunkSize, repository } = this.options;
|
||||||
|
|
||||||
|
const total = await (repository || collection.repository).count(this.getFindOptions());
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
await (repository || collection.repository).chunk({
|
||||||
|
...this.getFindOptions(),
|
||||||
|
chunkSize: chunkSize || 200,
|
||||||
|
callback: async (rows, options) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.handleRow(row, ctx);
|
||||||
|
current += 1;
|
||||||
|
|
||||||
|
this.emit('progress', {
|
||||||
|
total,
|
||||||
|
current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAppendOptionsFromFields() {
|
||||||
|
return this.options.fields
|
||||||
|
.map((field) => {
|
||||||
|
const fieldInstance = this.options.collection.getField(field[0]);
|
||||||
|
if (!fieldInstance) {
|
||||||
|
throw new Error(`Field "${field[0]}" not found: , please check the fields configuration.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldInstance.isRelationField()) {
|
||||||
|
return field.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFindOptions() {
|
||||||
|
const { findOptions = {} } = this.options;
|
||||||
|
|
||||||
|
if (this.limit) {
|
||||||
|
findOptions.limit = this.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendOptions = this.getAppendOptionsFromFields();
|
||||||
|
|
||||||
|
if (appendOptions.length) {
|
||||||
|
return {
|
||||||
|
...findOptions,
|
||||||
|
appends: appendOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return findOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected findFieldByDataIndex(dataIndex: Array<string>): IField {
|
||||||
|
const { collection } = this.options;
|
||||||
|
const currentField = collection.getField(dataIndex[0]);
|
||||||
|
|
||||||
|
if (dataIndex.length > 1) {
|
||||||
|
let targetCollection: ICollection;
|
||||||
|
|
||||||
|
for (let i = 0; i < dataIndex.length; i++) {
|
||||||
|
const isLast = i === dataIndex.length - 1;
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
return targetCollection.getField(dataIndex[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetCollection = (currentField as IRelationField).targetCollection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentField;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderRawValue(value) {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFieldRenderer(field?: IField, ctx?): (value) => any {
|
||||||
|
const InterfaceClass = this.options.collectionManager.getFieldInterface(field?.options?.interface);
|
||||||
|
if (!InterfaceClass) {
|
||||||
|
return this.renderRawValue;
|
||||||
|
}
|
||||||
|
const fieldInterface = new InterfaceClass(field?.options);
|
||||||
|
return (value) => fieldInterface.toString(value, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatValue(rowData: IModel, dataIndex: Array<string>, ctx?) {
|
||||||
|
rowData = rowData.toJSON();
|
||||||
|
const value = rowData[dataIndex[0]];
|
||||||
|
const field = this.findFieldByDataIndex(dataIndex);
|
||||||
|
const render = this.getFieldRenderer(field, ctx);
|
||||||
|
|
||||||
|
if (dataIndex.length > 1) {
|
||||||
|
const deepValue = deepGet(rowData, dataIndex);
|
||||||
|
|
||||||
|
if (Array.isArray(deepValue)) {
|
||||||
|
return deepValue.map(render).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(deepValue);
|
||||||
|
}
|
||||||
|
return render(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateOutputPath(prefix = 'export', ext = '', destination = os.tmpdir()): string {
|
||||||
|
const fileName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
||||||
|
return path.join(destination, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BaseExporter };
|
@ -0,0 +1,95 @@
|
|||||||
|
import XLSX from 'xlsx';
|
||||||
|
import { BaseExporter, ExportOptions } from './base-exporter';
|
||||||
|
import { NumberField } from '@nocobase/database';
|
||||||
|
|
||||||
|
type ExportColumn = {
|
||||||
|
dataIndex: Array<string>;
|
||||||
|
title?: string;
|
||||||
|
defaultTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type XlsxExportOptions = Omit<ExportOptions, 'fields'> & {
|
||||||
|
columns: Array<ExportColumn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class XlsxExporter extends BaseExporter<XlsxExportOptions & { fields: Array<Array<string>> }> {
|
||||||
|
/**
|
||||||
|
* You can adjust the maximum number of exported rows based on business needs and system
|
||||||
|
* available resources. However, please note that you need to fully understand the risks
|
||||||
|
* after the modification. Increasing the maximum number of rows that can be exported may
|
||||||
|
* increase system resource usage, leading to increased processing delays for other
|
||||||
|
* requests, or even server processes being recycled by the operating system.
|
||||||
|
*
|
||||||
|
* 您可以根据业务需求和系统可用资源等参数,调整最大导出数量的限制。但请注意,您需要充分了解修改之后的风险,
|
||||||
|
* 增加最大可导出的行数可能会导致系统资源占用率升高,导致其他请求处理延迟增加、无法处理、甚至
|
||||||
|
* 服务端进程被操作系统回收等问题。
|
||||||
|
*/
|
||||||
|
|
||||||
|
private workbook: XLSX.WorkBook;
|
||||||
|
private worksheet: XLSX.WorkSheet;
|
||||||
|
private startRowNumber: number;
|
||||||
|
|
||||||
|
constructor(options: XlsxExportOptions) {
|
||||||
|
const fields = options.columns.map((col) => col.dataIndex);
|
||||||
|
super({ ...options, fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(ctx?): Promise<void> {
|
||||||
|
this.workbook = XLSX.utils.book_new();
|
||||||
|
this.worksheet = XLSX.utils.sheet_new();
|
||||||
|
|
||||||
|
// write headers
|
||||||
|
XLSX.utils.sheet_add_aoa(this.worksheet, [this.renderHeaders(this.options.columns)], {
|
||||||
|
origin: 'A1',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.startRowNumber = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRow(row: any, ctx?): Promise<void> {
|
||||||
|
const rowData = [
|
||||||
|
this.options.columns.map((col) => {
|
||||||
|
return this.formatValue(row, col.dataIndex, ctx);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.sheet_add_aoa(this.worksheet, rowData, {
|
||||||
|
origin: `A${this.startRowNumber}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.startRowNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(): Promise<XLSX.WorkBook> {
|
||||||
|
for (const col of this.options.columns) {
|
||||||
|
const fieldInstance = this.findFieldByDataIndex(col.dataIndex);
|
||||||
|
if (fieldInstance instanceof NumberField) {
|
||||||
|
// set column cell type to number
|
||||||
|
const colIndex = this.options.columns.indexOf(col);
|
||||||
|
const cellRange = XLSX.utils.decode_range(this.worksheet['!ref']);
|
||||||
|
|
||||||
|
for (let r = 1; r <= cellRange.e.r; r++) {
|
||||||
|
const cell = this.worksheet[XLSX.utils.encode_cell({ c: colIndex, r })];
|
||||||
|
// if cell and cell.v is a number, set cell.t to 'n'
|
||||||
|
if (cell && isNumeric(cell.v)) {
|
||||||
|
cell.t = 'n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(this.workbook, this.worksheet, 'Data');
|
||||||
|
return this.workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHeaders(columns: Array<ExportColumn>) {
|
||||||
|
return columns.map((col) => {
|
||||||
|
const fieldInstance = this.findFieldByDataIndex(col.dataIndex);
|
||||||
|
return col.title || fieldInstance?.options.title || col.defaultTitle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumeric(n) {
|
||||||
|
return !isNaN(parseFloat(n)) && isFinite(n);
|
||||||
|
}
|
@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 {
|
|
||||||
FindOptions,
|
|
||||||
ICollection,
|
|
||||||
ICollectionManager,
|
|
||||||
IField,
|
|
||||||
IModel,
|
|
||||||
IRelationField,
|
|
||||||
} from '@nocobase/data-source-manager';
|
|
||||||
|
|
||||||
import XLSX from 'xlsx';
|
|
||||||
import { deepGet } from './utils/deep-get';
|
|
||||||
import { NumberField } from '@nocobase/database';
|
|
||||||
|
|
||||||
type ExportColumn = {
|
|
||||||
dataIndex: Array<string>;
|
|
||||||
title?: string;
|
|
||||||
defaultTitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExportOptions = {
|
|
||||||
collectionManager: ICollectionManager;
|
|
||||||
collection: ICollection;
|
|
||||||
repository?: any;
|
|
||||||
columns: Array<ExportColumn>;
|
|
||||||
findOptions?: FindOptions;
|
|
||||||
chunkSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class XlsxExporter {
|
|
||||||
/**
|
|
||||||
* You can adjust the maximum number of exported rows based on business needs and system
|
|
||||||
* available resources. However, please note that you need to fully understand the risks
|
|
||||||
* after the modification. Increasing the maximum number of rows that can be exported may
|
|
||||||
* increase system resource usage, leading to increased processing delays for other
|
|
||||||
* requests, or even server processes being recycled by the operating system.
|
|
||||||
*
|
|
||||||
* 您可以根据业务需求和系统可用资源等参数,调整最大导出数量的限制。但请注意,您需要充分了解修改之后的风险,
|
|
||||||
* 增加最大可导出的行数可能会导致系统资源占用率升高,导致其他请求处理延迟增加、无法处理、甚至
|
|
||||||
* 服务端进程被操作系统回收等问题。
|
|
||||||
*/
|
|
||||||
limit = process.env['EXPORT_LIMIT'] ? parseInt(process.env['EXPORT_LIMIT']) : 2000;
|
|
||||||
|
|
||||||
constructor(private options: ExportOptions) {}
|
|
||||||
|
|
||||||
async run(ctx?): Promise<XLSX.WorkBook> {
|
|
||||||
const { collection, columns, chunkSize, repository } = this.options;
|
|
||||||
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
const worksheet = XLSX.utils.sheet_new();
|
|
||||||
|
|
||||||
// write headers
|
|
||||||
XLSX.utils.sheet_add_aoa(worksheet, [this.renderHeaders()], {
|
|
||||||
origin: 'A1',
|
|
||||||
});
|
|
||||||
|
|
||||||
let startRowNumber = 2;
|
|
||||||
|
|
||||||
await (repository || collection.repository).chunk({
|
|
||||||
...this.getFindOptions(),
|
|
||||||
chunkSize: chunkSize || 200,
|
|
||||||
callback: async (rows, options) => {
|
|
||||||
const chunkData = rows.map((r) => {
|
|
||||||
return columns.map((col) => {
|
|
||||||
return this.renderCellValue(r, col, ctx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
XLSX.utils.sheet_add_aoa(worksheet, chunkData, {
|
|
||||||
origin: `A${startRowNumber}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
startRowNumber += rows.length;
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 50);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const col of columns) {
|
|
||||||
const field = this.findFieldByDataIndex(col.dataIndex);
|
|
||||||
if (field instanceof NumberField) {
|
|
||||||
// set column cell type to number
|
|
||||||
const colIndex = columns.indexOf(col);
|
|
||||||
const cellRange = XLSX.utils.decode_range(worksheet['!ref']);
|
|
||||||
|
|
||||||
for (let r = 1; r <= cellRange.e.r; r++) {
|
|
||||||
const cell = worksheet[XLSX.utils.encode_cell({ c: colIndex, r })];
|
|
||||||
// if cell and cell.v is a number, set cell.t to 'n'
|
|
||||||
if (cell && isNumeric(cell.v)) {
|
|
||||||
cell.t = 'n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
|
|
||||||
return workbook;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAppendOptionsFromColumns() {
|
|
||||||
return this.options.columns
|
|
||||||
.map((col) => {
|
|
||||||
if (col.dataIndex.length > 1) {
|
|
||||||
return col.dataIndex.join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = this.options.collection.getField(col.dataIndex[0]);
|
|
||||||
if (!field) {
|
|
||||||
throw new Error(`Field "${col.dataIndex[0]}" not found: , please check the columns configuration.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.isRelationField()) {
|
|
||||||
return col.dataIndex[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFindOptions() {
|
|
||||||
const { findOptions = {} } = this.options;
|
|
||||||
|
|
||||||
findOptions.limit = this.limit;
|
|
||||||
|
|
||||||
const appendOptions = this.getAppendOptionsFromColumns();
|
|
||||||
|
|
||||||
if (appendOptions.length) {
|
|
||||||
return {
|
|
||||||
...findOptions,
|
|
||||||
appends: appendOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return findOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findFieldByDataIndex(dataIndex: Array<string>): IField {
|
|
||||||
const { collection } = this.options;
|
|
||||||
const currentField = collection.getField(dataIndex[0]);
|
|
||||||
|
|
||||||
if (dataIndex.length > 1) {
|
|
||||||
let targetCollection: ICollection;
|
|
||||||
|
|
||||||
for (let i = 0; i < dataIndex.length; i++) {
|
|
||||||
const isLast = i === dataIndex.length - 1;
|
|
||||||
|
|
||||||
if (isLast) {
|
|
||||||
return targetCollection.getField(dataIndex[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
targetCollection = (currentField as IRelationField).targetCollection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentField;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderHeaders() {
|
|
||||||
return this.options.columns.map((col) => {
|
|
||||||
const field = this.findFieldByDataIndex(col.dataIndex);
|
|
||||||
if (col.title) {
|
|
||||||
return col.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return field?.options.title || col.defaultTitle;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderRawValue(value) {
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFieldRenderer(field?: IField, ctx?): (value) => any {
|
|
||||||
const InterfaceClass = this.options.collectionManager.getFieldInterface(field?.options?.interface);
|
|
||||||
if (!InterfaceClass) {
|
|
||||||
return this.renderRawValue;
|
|
||||||
}
|
|
||||||
const fieldInternface = new InterfaceClass(field?.options);
|
|
||||||
return (value) => fieldInternface.toString(value, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderCellValue(rowData: IModel, column: ExportColumn, ctx?) {
|
|
||||||
const { dataIndex } = column;
|
|
||||||
rowData = rowData.toJSON();
|
|
||||||
const value = rowData[dataIndex[0]];
|
|
||||||
const field = this.findFieldByDataIndex(dataIndex);
|
|
||||||
const render = this.getFieldRenderer(field, ctx);
|
|
||||||
|
|
||||||
if (dataIndex.length > 1) {
|
|
||||||
const deepValue = deepGet(rowData, dataIndex);
|
|
||||||
|
|
||||||
if (Array.isArray(deepValue)) {
|
|
||||||
return deepValue.map(render).join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(deepValue);
|
|
||||||
}
|
|
||||||
return render(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNumeric(n) {
|
|
||||||
return !isNaN(parseFloat(n)) && isFinite(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default XlsxExporter;
|
|
@ -25,7 +25,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^11.15.1",
|
"react-i18next": "^11.15.1",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
|
||||||
|
"exceljs": "^4.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nocobase/actions": "1.x",
|
"@nocobase/actions": "1.x",
|
||||||
|
@ -0,0 +1,130 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { ISchema, useFieldSchema } from '@formily/react';
|
||||||
|
import { Action, ActionContextProvider, SchemaComponent } from '@nocobase/client';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NAMESPACE } from './constants';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const importFormSchema: ISchema = {
|
||||||
|
type: 'void',
|
||||||
|
name: 'import-modal',
|
||||||
|
title: `{{ t("Import Data", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
'x-component': 'Action.Modal',
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component-props': {
|
||||||
|
width: '100%',
|
||||||
|
style: {
|
||||||
|
maxWidth: '750px',
|
||||||
|
},
|
||||||
|
className: css`
|
||||||
|
.ant-formily-item-label {
|
||||||
|
height: var(--controlHeightLG);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
formLayout: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'FormLayout',
|
||||||
|
properties: {
|
||||||
|
warning: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ImportWarning',
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{ t("Step 1: Download template", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
'x-component': 'FormItem',
|
||||||
|
'x-acl-ignore': true,
|
||||||
|
properties: {
|
||||||
|
tip: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'DownloadTips',
|
||||||
|
},
|
||||||
|
downloadAction: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{ t("Download template", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
className: css`
|
||||||
|
margin-top: 5px;
|
||||||
|
`,
|
||||||
|
useAction: '{{ useDownloadXlsxTemplateAction }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
type: 'array',
|
||||||
|
title: `{{ t("Step 2: Upload Excel", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-acl-ignore': true,
|
||||||
|
'x-component': 'Upload.Dragger',
|
||||||
|
'x-validator': '{{ uploadValidator }}',
|
||||||
|
'x-component-props': {
|
||||||
|
action: '',
|
||||||
|
height: '150px',
|
||||||
|
tipContent: `{{ t("Upload placeholder", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
beforeUpload: '{{ beforeUploadHandler }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
'x-component': 'Action.Modal.Footer',
|
||||||
|
'x-component-props': {},
|
||||||
|
properties: {
|
||||||
|
actions: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-component-props': {},
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{ t("Cancel") }}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{ cm.useCancelAction }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startImport: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{ t("Start import", {ns: "${NAMESPACE}" }) }}`,
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
htmlType: 'submit',
|
||||||
|
useAction: '{{ useImportStartAction }}',
|
||||||
|
},
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['upload'],
|
||||||
|
fulfill: {
|
||||||
|
run: 'validateUpload($form, $self, $deps)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImportAction = (props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { t } = useTranslation(NAMESPACE);
|
||||||
|
const fieldSchema = useFieldSchema();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionContextProvider value={{ visible, setVisible, fieldSchema }}>
|
||||||
|
<Action
|
||||||
|
icon={props.icon || <UploadOutlined />}
|
||||||
|
title={fieldSchema?.title || t('Import', { ns: 'action-import' })}
|
||||||
|
{...props}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
/>
|
||||||
|
<SchemaComponent schema={importFormSchema} />
|
||||||
|
</ActionContextProvider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Schema } from '@formily/react';
|
||||||
|
|
||||||
|
export const ImportActionContext = React.createContext<any>({});
|
||||||
|
|
||||||
|
export const useImportActionContext = () => {
|
||||||
|
return React.useContext(ImportActionContext);
|
||||||
|
};
|
@ -11,17 +11,17 @@ import type { ISchema } from '@formily/react';
|
|||||||
import { Schema } from '@formily/react';
|
import { Schema } from '@formily/react';
|
||||||
import { merge } from '@formily/shared';
|
import { merge } from '@formily/shared';
|
||||||
import {
|
import {
|
||||||
css,
|
|
||||||
SchemaInitializerItem,
|
SchemaInitializerItem,
|
||||||
useCollection_deprecated,
|
useCollection_deprecated,
|
||||||
useSchemaInitializer,
|
useSchemaInitializer,
|
||||||
useSchemaInitializerItem,
|
useSchemaInitializerItem,
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import { Alert } from 'antd';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NAMESPACE } from './constants';
|
import { NAMESPACE } from './constants';
|
||||||
import { useImportTranslation } from './locale';
|
import { useImportTranslation } from './locale';
|
||||||
import { useFields } from './useFields';
|
import { useFields } from './useFields';
|
||||||
|
import { Alert } from 'antd';
|
||||||
|
import { lodash } from '@nocobase/utils/client';
|
||||||
|
|
||||||
const findSchema = (schema: Schema, key: string, action: string) => {
|
const findSchema = (schema: Schema, key: string, action: string) => {
|
||||||
return schema.reduceProperties((buf, s) => {
|
return schema.reduceProperties((buf, s) => {
|
||||||
@ -36,11 +36,38 @@ const findSchema = (schema: Schema, key: string, action: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const initImportSettings = (fields) => {
|
export const initImportSettings = (fields) => {
|
||||||
const importColumns = fields?.filter((f) => !f.children).map((f) => ({ dataIndex: [f.name] }));
|
const importColumns = fields?.filter((f) => !f.children).map((f) => ({ dataIndex: [f.name] }));
|
||||||
return { importColumns, explain: '' };
|
return { importColumns, explain: '' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ImportActionInitializer = () => {
|
||||||
|
const itemConfig = useSchemaInitializerItem();
|
||||||
|
const { insert } = useSchemaInitializer();
|
||||||
|
const { name } = useCollection_deprecated();
|
||||||
|
const fields = useFields(name);
|
||||||
|
|
||||||
|
const schema: ISchema = {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{ t("Import") }}',
|
||||||
|
'x-component': 'ImportAction',
|
||||||
|
'x-action': 'importXlsx',
|
||||||
|
'x-settings': 'actionSettings:import',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaInitializerItem
|
||||||
|
title={itemConfig.title}
|
||||||
|
onClick={() => {
|
||||||
|
lodash.set(schema, 'x-action-settings.importSettings', initImportSettings(fields));
|
||||||
|
const s = merge(schema || {}, itemConfig.schema || {});
|
||||||
|
itemConfig?.schemaInitialize?.(s);
|
||||||
|
insert(s);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ImportWarning = () => {
|
export const ImportWarning = () => {
|
||||||
const { t } = useImportTranslation();
|
const { t } = useImportTranslation();
|
||||||
return <Alert type="warning" style={{ marginBottom: '10px' }} message={t('Import warnings', { limit: 2000 })} />;
|
return <Alert type="warning" style={{ marginBottom: '10px' }} message={t('Import warnings', { limit: 2000 })} />;
|
||||||
@ -50,142 +77,3 @@ export const DownloadTips = () => {
|
|||||||
const { t } = useImportTranslation();
|
const { t } = useImportTranslation();
|
||||||
return <Alert type="info" style={{ marginBottom: '10px', whiteSpace: 'pre-line' }} message={t('Download tips')} />;
|
return <Alert type="info" style={{ marginBottom: '10px', whiteSpace: 'pre-line' }} message={t('Download tips')} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImportActionInitializer = () => {
|
|
||||||
const itemConfig = useSchemaInitializerItem();
|
|
||||||
const { insert } = useSchemaInitializer();
|
|
||||||
const { name } = useCollection_deprecated();
|
|
||||||
const fields = useFields(name);
|
|
||||||
const schema: ISchema = {
|
|
||||||
type: 'void',
|
|
||||||
title: '{{ t("Import") }}',
|
|
||||||
'x-action': 'importXlsx',
|
|
||||||
'x-action-settings': {
|
|
||||||
importSettings: { importColumns: [], explain: '' },
|
|
||||||
},
|
|
||||||
'x-toolbar': 'ActionSchemaToolbar',
|
|
||||||
'x-settings': 'actionSettings:import',
|
|
||||||
'x-component': 'Action',
|
|
||||||
'x-component-props': {
|
|
||||||
icon: 'CloudUploadOutlined',
|
|
||||||
openMode: 'modal',
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
modal: {
|
|
||||||
type: 'void',
|
|
||||||
title: `{{ t("Import Data", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
'x-component': 'Action.Modal',
|
|
||||||
'x-decorator': 'Form',
|
|
||||||
'x-component-props': {
|
|
||||||
width: '100%',
|
|
||||||
style: {
|
|
||||||
maxWidth: '750px',
|
|
||||||
},
|
|
||||||
className: css`
|
|
||||||
.ant-formily-item-label {
|
|
||||||
height: var(--controlHeightLG);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
formLayout: {
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'FormLayout',
|
|
||||||
properties: {
|
|
||||||
warning: {
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'ImportWarning',
|
|
||||||
},
|
|
||||||
download: {
|
|
||||||
type: 'void',
|
|
||||||
title: `{{ t("Step 1: Download template", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
'x-component': 'FormItem',
|
|
||||||
'x-acl-ignore': true,
|
|
||||||
properties: {
|
|
||||||
tip: {
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'DownloadTips',
|
|
||||||
},
|
|
||||||
downloadAction: {
|
|
||||||
type: 'void',
|
|
||||||
title: `{{ t("Download template", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
'x-component': 'Action',
|
|
||||||
'x-component-props': {
|
|
||||||
className: css`
|
|
||||||
margin-top: 5px;
|
|
||||||
`,
|
|
||||||
useAction: '{{ useDownloadXlsxTemplateAction }}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
upload: {
|
|
||||||
type: 'array',
|
|
||||||
title: `{{ t("Step 2: Upload Excel", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-acl-ignore': true,
|
|
||||||
'x-component': 'Upload.Dragger',
|
|
||||||
'x-validator': '{{ uploadValidator }}',
|
|
||||||
'x-component-props': {
|
|
||||||
action: '',
|
|
||||||
height: '150px',
|
|
||||||
tipContent: `{{ t("Upload placeholder", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
beforeUpload: '{{ beforeUploadHandler }}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
'x-component': 'Action.Modal.Footer',
|
|
||||||
'x-component-props': {},
|
|
||||||
properties: {
|
|
||||||
actions: {
|
|
||||||
type: 'void',
|
|
||||||
'x-component': 'ActionBar',
|
|
||||||
'x-component-props': {},
|
|
||||||
properties: {
|
|
||||||
cancel: {
|
|
||||||
type: 'void',
|
|
||||||
title: '{{ t("Cancel") }}',
|
|
||||||
'x-component': 'Action',
|
|
||||||
'x-component-props': {
|
|
||||||
useAction: '{{ cm.useCancelAction }}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startImport: {
|
|
||||||
type: 'void',
|
|
||||||
title: `{{ t("Start import", {ns: "${NAMESPACE}" }) }}`,
|
|
||||||
'x-component': 'Action',
|
|
||||||
'x-component-props': {
|
|
||||||
type: 'primary',
|
|
||||||
htmlType: 'submit',
|
|
||||||
useAction: '{{ useImportStartAction }}',
|
|
||||||
},
|
|
||||||
'x-reactions': {
|
|
||||||
dependencies: ['upload'],
|
|
||||||
fulfill: {
|
|
||||||
run: 'validateUpload($form, $self, $deps)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SchemaInitializerItem
|
|
||||||
title={itemConfig.title}
|
|
||||||
onClick={() => {
|
|
||||||
schema['x-action-settings']['importSettings'] = initImportSettings(fields);
|
|
||||||
const s = merge(schema || {}, itemConfig.schema || {});
|
|
||||||
itemConfig?.schemaInitialize?.(s);
|
|
||||||
insert(s);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -15,12 +15,13 @@ import { ImportContext } from './context';
|
|||||||
import { ImportModal, ImportStatus } from './ImportModal';
|
import { ImportModal, ImportStatus } from './ImportModal';
|
||||||
import { useDownloadXlsxTemplateAction, useImportStartAction } from './useImportAction';
|
import { useDownloadXlsxTemplateAction, useImportStartAction } from './useImportAction';
|
||||||
import { useShared } from './useShared';
|
import { useShared } from './useShared';
|
||||||
|
import { ImportAction } from './ImportAction';
|
||||||
|
|
||||||
export const ImportPluginProvider = (props: any) => {
|
export const ImportPluginProvider = (props: any) => {
|
||||||
const { uploadValidator, beforeUploadHandler, validateUpload } = useShared();
|
const { uploadValidator, beforeUploadHandler, validateUpload } = useShared();
|
||||||
return (
|
return (
|
||||||
<SchemaComponentOptions
|
<SchemaComponentOptions
|
||||||
components={{ ImportActionInitializer, ImportDesigner, ImportWarning, DownloadTips }}
|
components={{ ImportActionInitializer, ImportDesigner, ImportWarning, DownloadTips, ImportAction }}
|
||||||
scope={{
|
scope={{
|
||||||
uploadValidator,
|
uploadValidator,
|
||||||
validateUpload,
|
validateUpload,
|
||||||
|
@ -9,10 +9,14 @@
|
|||||||
|
|
||||||
import { ArrayItems } from '@formily/antd-v5';
|
import { ArrayItems } from '@formily/antd-v5';
|
||||||
import { ISchema, useField, useFieldSchema } from '@formily/react';
|
import { ISchema, useField, useFieldSchema } from '@formily/react';
|
||||||
import { ButtonEditor, SchemaSettings, useDesignable, useSchemaToolbar } from '@nocobase/client';
|
import { ButtonEditor, SchemaSettings, type, useDesignable, useSchemaToolbar } from '@nocobase/client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useShared } from './useShared';
|
import { useShared } from './useShared';
|
||||||
|
import { Button, Space } from 'antd';
|
||||||
|
import { Action } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useDownloadXlsxTemplateAction } from './useImportAction';
|
||||||
|
|
||||||
export const importActionSchemaSettings = new SchemaSettings({
|
export const importActionSchemaSettings = new SchemaSettings({
|
||||||
name: 'actionSettings:import',
|
name: 'actionSettings:import',
|
||||||
@ -42,7 +46,9 @@ export const importActionSchemaSettings = new SchemaSettings({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: t('Importable fields'),
|
title: t('Importable fields'),
|
||||||
schema: schema,
|
schema: {
|
||||||
|
...schema,
|
||||||
|
},
|
||||||
initialValues: { ...(fieldSchema?.['x-action-settings']?.importSettings ?? {}) },
|
initialValues: { ...(fieldSchema?.['x-action-settings']?.importSettings ?? {}) },
|
||||||
components: { ArrayItems },
|
components: { ArrayItems },
|
||||||
onSubmit: ({ importColumns, explain }: any) => {
|
onSubmit: ({ importColumns, explain }: any) => {
|
||||||
@ -51,6 +57,7 @@ export const importActionSchemaSettings = new SchemaSettings({
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
dataIndex: item.dataIndex.map((di) => di.name ?? di),
|
dataIndex: item.dataIndex.map((di) => di.name ?? di),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
}));
|
}));
|
||||||
fieldSchema['x-action-settings']['importSettings'] = { importColumns: columns, explain };
|
fieldSchema['x-action-settings']['importSettings'] = { importColumns: columns, explain };
|
||||||
|
|
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
useAPIClient,
|
||||||
|
useRecord,
|
||||||
|
useBlockRequestContext,
|
||||||
|
useCollection_deprecated,
|
||||||
|
useCompile,
|
||||||
|
} from '@nocobase/client';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
|
export const useDownloadXlsxTemplateAction = () => {
|
||||||
|
const { resource } = useBlockRequestContext();
|
||||||
|
const compile = useCompile();
|
||||||
|
const record = useRecord();
|
||||||
|
const { title } = useCollection_deprecated();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
const { explain, importColumns } = record;
|
||||||
|
const { data } = await resource.downloadXlsxTemplate(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
title: compile(title),
|
||||||
|
explain,
|
||||||
|
columns: compile(importColumns),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
responseType: 'blob',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
saveAs(blob, `${compile(title)}.xlsx`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -26,11 +26,18 @@ import { NAMESPACE } from './constants';
|
|||||||
import { useImportContext } from './context';
|
import { useImportContext } from './context';
|
||||||
import { ImportStatus } from './ImportModal';
|
import { ImportStatus } from './ImportModal';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useFields } from './useFields';
|
||||||
|
import { initImportSettings } from './ImportActionInitializer';
|
||||||
|
import { useImportActionContext } from './ImportActionContext';
|
||||||
|
|
||||||
const useImportSchema = (s: Schema) => {
|
const useImportSchema = () => {
|
||||||
let schema = s;
|
const { fieldSchema: actionSchema } = useActionContext();
|
||||||
|
const fieldSchema = useFieldSchema();
|
||||||
|
|
||||||
|
let schema = actionSchema || fieldSchema;
|
||||||
while (schema && schema['x-action'] !== 'importXlsx') {
|
while (schema && schema['x-action'] !== 'importXlsx') {
|
||||||
schema = schema.parent;
|
schema = schema.parent;
|
||||||
|
console.log('schema', schema);
|
||||||
}
|
}
|
||||||
return { schema };
|
return { schema };
|
||||||
};
|
};
|
||||||
@ -50,7 +57,8 @@ export const useDownloadXlsxTemplateAction = () => {
|
|||||||
const { getCollectionJoinField, getCollectionField } = useCollectionManager_deprecated();
|
const { getCollectionJoinField, getCollectionField } = useCollectionManager_deprecated();
|
||||||
const { name, title, getField } = useCollection_deprecated();
|
const { name, title, getField } = useCollection_deprecated();
|
||||||
const { t } = useTranslation(NAMESPACE);
|
const { t } = useTranslation(NAMESPACE);
|
||||||
const { schema: importSchema } = useImportSchema(actionSchema);
|
const { schema: importSchema } = useImportSchema();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async run() {
|
async run() {
|
||||||
const { importColumns, explain } = lodash.cloneDeep(
|
const { importColumns, explain } = lodash.cloneDeep(
|
||||||
@ -103,18 +111,19 @@ export const useImportStartAction = () => {
|
|||||||
const { getCollectionJoinField, getCollectionField } = useCollectionManager_deprecated();
|
const { getCollectionJoinField, getCollectionField } = useCollectionManager_deprecated();
|
||||||
const { name, title, getField } = useCollection_deprecated();
|
const { name, title, getField } = useCollection_deprecated();
|
||||||
const { t } = useTranslation(NAMESPACE);
|
const { t } = useTranslation(NAMESPACE);
|
||||||
const { schema: importSchema } = useImportSchema(actionSchema);
|
const { schema: importSchema } = useImportSchema();
|
||||||
const form = useForm();
|
const form = useForm();
|
||||||
const { setVisible, fieldSchema } = useActionContext();
|
const { setVisible, fieldSchema } = useActionContext();
|
||||||
const { setImportModalVisible, setImportStatus, setImportResult } = useImportContext();
|
const { setImportModalVisible, setImportStatus, setImportResult } = useImportContext();
|
||||||
const { upload } = form.values;
|
const { upload } = form.values;
|
||||||
const dataBlockProps = useDataBlockProps() || ({} as any);
|
const dataBlockProps = useDataBlockProps();
|
||||||
const headers = useDataSourceHeaders(dataBlockProps.dataSource);
|
const headers = useDataSourceHeaders(dataBlockProps.dataSource);
|
||||||
const newResource = useDataBlockResource();
|
const newResource = useDataBlockResource();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async run() {
|
async run() {
|
||||||
const { importColumns, explain } = lodash.cloneDeep(
|
const { importColumns, explain } = lodash.cloneDeep(
|
||||||
@ -140,11 +149,30 @@ export const useImportStartAction = () => {
|
|||||||
return column;
|
return column;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
const uploadFiles = form.values.upload.map((f) => f.originFileObj);
|
const uploadFiles = form.values.upload.map((f) => f.originFileObj);
|
||||||
formData.append('file', uploadFiles[0]);
|
formData.append('file', uploadFiles[0]);
|
||||||
formData.append('columns', JSON.stringify(columns));
|
formData.append('columns', JSON.stringify(columns));
|
||||||
formData.append('explain', explain);
|
formData.append('explain', explain);
|
||||||
|
|
||||||
|
const { triggerWorkflow, identifyDuplicates, uniqueField, duplicateStrategy } = form.values;
|
||||||
|
|
||||||
|
if (triggerWorkflow !== undefined) {
|
||||||
|
formData.append('triggerWorkflow', JSON.stringify(triggerWorkflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identifyDuplicates) {
|
||||||
|
formData.append(
|
||||||
|
'duplicateOption',
|
||||||
|
JSON.stringify({
|
||||||
|
uniqueField,
|
||||||
|
mode: duplicateStrategy,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setImportModalVisible(true);
|
setImportModalVisible(true);
|
||||||
setImportStatus(ImportStatus.IMPORTING);
|
setImportStatus(ImportStatus.IMPORTING);
|
||||||
@ -161,8 +189,15 @@ export const useImportStartAction = () => {
|
|||||||
|
|
||||||
setImportResult(data);
|
setImportResult(data);
|
||||||
form.reset();
|
form.reset();
|
||||||
await service?.refresh?.();
|
|
||||||
setImportStatus(ImportStatus.IMPORTED);
|
if (!data.data.taskId) {
|
||||||
|
setImportResult(data);
|
||||||
|
await service?.refresh?.();
|
||||||
|
setImportStatus(ImportStatus.IMPORTED);
|
||||||
|
} else {
|
||||||
|
setImportModalVisible(false);
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setImportModalVisible(false);
|
setImportModalVisible(false);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
@ -73,6 +73,22 @@ export const useShared = () => {
|
|||||||
changeOnSelect: false,
|
changeOnSelect: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: '{{ t("Custom column title") }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: `{{ t("Field description placeholder", {ns: "${NAMESPACE}"}) }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
remove: {
|
remove: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { name } from '../package.json';
|
import { name } from '../package.json';
|
||||||
|
|
||||||
export * from './server';
|
export * from './server';
|
||||||
export { default } from './server';
|
export { default } from './server';
|
||||||
export const namespace = name;
|
export const namespace = name;
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
"Download template": "Download template",
|
"Download template": "Download template",
|
||||||
"Step 1: Download template": "Step 1: Download template",
|
"Step 1: Download template": "Step 1: Download template",
|
||||||
"Step 2: Upload Excel": "Step 2: Upload Excel",
|
"Step 2: Upload Excel": "Step 2: Upload Excel",
|
||||||
|
"Step 3: Import options": "Step 3: Import options",
|
||||||
"Download tips": "- Download the template and fill in the data according to the format \r\n - Import only the first worksheet \r\n - Do not change the header of the template to prevent import failure",
|
"Download tips": "- Download the template and fill in the data according to the format \r\n - Import only the first worksheet \r\n - Do not change the header of the template to prevent import failure",
|
||||||
"Import warnings": "You can import up to {{limit}} rows of data at a time, any excess will be ignored.",
|
"Import warnings": "You can import up to {{limit}} rows of data at a time, any excess will be ignored.",
|
||||||
"Upload placeholder": "Drag and drop the file here or click to upload, file size should not exceed 30M",
|
"Upload placeholder": "Drag and drop the file here or click to upload, file size should not exceed 30M",
|
||||||
@ -24,5 +25,18 @@
|
|||||||
"Incorrect time format": "Incorrect time format",
|
"Incorrect time format": "Incorrect time format",
|
||||||
"Incorrect date format": "Incorrect date format",
|
"Incorrect date format": "Incorrect date format",
|
||||||
"Incorrect email format": "Incorrect email format",
|
"Incorrect email format": "Incorrect email format",
|
||||||
"Illegal percentage format": "Illegal percentage format"
|
"Illegal percentage format": "Illegal percentage format",
|
||||||
|
"Imported template does not match, please download again.": "Imported template does not match, please download again.",
|
||||||
|
"another import action is running, please try again later.": "another import action is running, please try again later.",
|
||||||
|
"Please select": "Please select",
|
||||||
|
"Custom column title": "Custom column title",
|
||||||
|
"Field description": "Field description",
|
||||||
|
"Field description placeholder": "Enter field description",
|
||||||
|
"Columns configuration is empty": "Columns configuration is empty",
|
||||||
|
"Field not found: {{field}}": "Field not found: {{field}}",
|
||||||
|
"Headers not found. Expected headers: {{headers}}": "Headers not found. Expected headers: {{headers}}",
|
||||||
|
"Header mismatch at column {{column}}: expected \"{{expected}}\", but got \"{{actual}}\"": "Header mismatch at column {{column}}: expected \"{{expected}}\", but got \"{{actual}}\"",
|
||||||
|
"No data to import": "No data to import",
|
||||||
|
"Failed to import row {{row}}, {{message}}, row data: {{data}}": "Failed to import row {{row}}, {{message}}, row data: {{data}}",
|
||||||
|
"import-error": "Failed to import row {{rowIndex}}, row data: {{rowData}}, cause: {{causeMessage}}"
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,13 @@
|
|||||||
"File size cannot exceed 10M": "文件大小不能超过10M",
|
"File size cannot exceed 10M": "文件大小不能超过10M",
|
||||||
"Please upload the file of Excel": "请上传Excel的文件",
|
"Please upload the file of Excel": "请上传Excel的文件",
|
||||||
"Import Data": "导入数据",
|
"Import Data": "导入数据",
|
||||||
|
"Import": "导入",
|
||||||
"Start import": "开始导入",
|
"Start import": "开始导入",
|
||||||
"Import explain": "说明",
|
"Import explain": "说明",
|
||||||
"Download template": "下载模板",
|
"Download template": "下载模板",
|
||||||
"Step 1: Download template": "1.下载模板",
|
"Step 1: Download template": "1.下载模板",
|
||||||
"Step 2: Upload Excel": "2.上传完善后的表格",
|
"Step 2: Upload Excel": "2.上传完善后的表格",
|
||||||
|
"Step 3: Import options": "3.导入选项",
|
||||||
"Download tips": "- 下载模板后,按格式填写数据\r\n - 只导入第一张工作表\r\n - 请勿改模板表头,防止导入失败",
|
"Download tips": "- 下载模板后,按格式填写数据\r\n - 只导入第一张工作表\r\n - 请勿改模板表头,防止导入失败",
|
||||||
"Import warnings": "每次最多导入 {{limit}} 行数据,超出的将被忽略。",
|
"Import warnings": "每次最多导入 {{limit}} 行数据,超出的将被忽略。",
|
||||||
"Upload placeholder": "将文件拖曳到此处或点击上传,文件大小不超过10M",
|
"Upload placeholder": "将文件拖曳到此处或点击上传,文件大小不超过10M",
|
||||||
@ -18,6 +20,7 @@
|
|||||||
"Done": "完成",
|
"Done": "完成",
|
||||||
"Yes": "是",
|
"Yes": "是",
|
||||||
"No": "否",
|
"No": "否",
|
||||||
|
"Please select": "请选择",
|
||||||
"Field {{fieldName}} does not exist": "字段 {{fieldName}} 不存在",
|
"Field {{fieldName}} does not exist": "字段 {{fieldName}} 不存在",
|
||||||
"can not find value": "找不到对应值",
|
"can not find value": "找不到对应值",
|
||||||
"password is empty": "密码为空",
|
"password is empty": "密码为空",
|
||||||
@ -26,5 +29,15 @@
|
|||||||
"Incorrect email format": "邮箱格式不正确",
|
"Incorrect email format": "邮箱格式不正确",
|
||||||
"Illegal percentage format": "百分比格式有误",
|
"Illegal percentage format": "百分比格式有误",
|
||||||
"Imported template does not match, please download again.": "导入模板不匹配,请检查导入文件标题行或重新下载导入模板",
|
"Imported template does not match, please download again.": "导入模板不匹配,请检查导入文件标题行或重新下载导入模板",
|
||||||
"another import action is running, please try again later.": "另一导入任务正在运行,请稍后重试。"
|
"another import action is running, please try again later.": "另一导入任务正在运行,请稍后重试。",
|
||||||
|
"Custom column title": "自定义列标题",
|
||||||
|
"Field description": "字段说明",
|
||||||
|
"Field description placeholder": "请输入字段说明",
|
||||||
|
"Columns configuration is empty": "列配置为空",
|
||||||
|
"Field not found: {{field}}": "字段未找到:{{field}}",
|
||||||
|
"Headers not found. Expected headers: {{headers}}": "未找到表头。预期的表头:{{headers}}",
|
||||||
|
"Header mismatch at column {{column}}: expected \"{{expected}}\", but got \"{{actual}}\"": "第 {{column}} 列的表头不匹配:预期 \"{{expected}}\",实际是 \"{{actual}}\"",
|
||||||
|
"No data to import": "没有数据可导入",
|
||||||
|
"Failed to import row {{row}}, {{message}}, row data: {{data}}": "导入第 {{row}} 行失败,{{message}},行数据:{{data}}",
|
||||||
|
"import-error": "导入第 {{rowIndex}} 行失败,行数据:{{rowData}}, 原因:{{causeMessage}}"
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@ describe('download template', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const workbook = await templateCreator.run();
|
const workbook = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
const sheet0 = workbook.Sheets[workbook.SheetNames[0]];
|
const sheet0 = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
const sheetData = XLSX.utils.sheet_to_json(sheet0, { header: 1, defval: null, raw: false });
|
const sheetData = XLSX.utils.sheet_to_json(sheet0, { header: 1, defval: null, raw: false });
|
||||||
|
|
||||||
@ -64,7 +64,8 @@ describe('download template', () => {
|
|||||||
expect(explainData[0]).toEqual(explain);
|
expect(explainData[0]).toEqual(explain);
|
||||||
|
|
||||||
const headerData = sheetData[1];
|
const headerData = sheetData[1];
|
||||||
expect(headerData).toEqual(['Name', 'Email']);
|
expect(headerData[0]).toEqual('Name');
|
||||||
|
expect(headerData[1]).toEqual('Email');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render template', async () => {
|
it('should render template', async () => {
|
||||||
@ -99,7 +100,7 @@ describe('download template', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const workbook = await templateCreator.run();
|
const workbook = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
const sheet0 = workbook.Sheets[workbook.SheetNames[0]];
|
const sheet0 = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
const sheetData = XLSX.utils.sheet_to_json(sheet0, { header: 1, defval: null, raw: false });
|
const sheetData = XLSX.utils.sheet_to_json(sheet0, { header: 1, defval: null, raw: false });
|
||||||
const headerData = sheetData[0];
|
const headerData = sheetData[0];
|
||||||
|
@ -26,6 +26,85 @@ describe('xlsx importer', () => {
|
|||||||
await app.destroy();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validate empty data', () => {
|
||||||
|
let User;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await app.db.sync();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when file only has header row', async () => {
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
// template already has header row, no need to add data
|
||||||
|
|
||||||
|
const importer = new XlsxImporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workbook: template,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(importer.validate()).rejects.toThrow('No data to import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass validation when file has header and data rows', async () => {
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
|
XLSX.utils.sheet_add_aoa(worksheet, [['Test Data 1'], ['Test Data 2']], {
|
||||||
|
origin: 'A2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const importer = new XlsxImporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: 'Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workbook: template,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(importer.validate()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('import with date field', () => {
|
describe('import with date field', () => {
|
||||||
let User;
|
let User;
|
||||||
|
|
||||||
@ -96,7 +175,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -142,7 +221,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -188,7 +267,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -234,7 +313,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -336,7 +415,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -378,7 +457,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -420,7 +499,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -524,7 +603,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -566,7 +645,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -686,7 +765,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -737,7 +816,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -780,7 +859,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -839,7 +918,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -872,7 +951,6 @@ describe('xlsx importer', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(error).toBeTruthy();
|
expect(error).toBeTruthy();
|
||||||
console.log(error.message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report validation error message on unique validation', async () => {
|
it('should report validation error message on unique validation', async () => {
|
||||||
@ -907,7 +985,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
XLSX.utils.sheet_add_aoa(
|
XLSX.utils.sheet_add_aoa(
|
||||||
@ -946,7 +1024,6 @@ describe('xlsx importer', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(error).toBeTruthy();
|
expect(error).toBeTruthy();
|
||||||
console.log(error.message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should import china region field', async () => {
|
it('should import china region field', async () => {
|
||||||
@ -980,7 +1057,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1078,7 +1155,7 @@ describe('xlsx importer', () => {
|
|||||||
columns,
|
columns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1168,7 +1245,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1269,7 +1346,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1372,7 +1449,7 @@ describe('xlsx importer', () => {
|
|||||||
|
|
||||||
let error;
|
let error;
|
||||||
try {
|
try {
|
||||||
importer.getData();
|
await importer.validate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
}
|
}
|
||||||
@ -1409,7 +1486,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const importer = new XlsxImporter({
|
const importer = new XlsxImporter({
|
||||||
collectionManager: app.mainDataSource.collectionManager,
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
@ -1427,9 +1504,15 @@ describe('xlsx importer', () => {
|
|||||||
workbook: template,
|
workbook: template,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// insert a row for test
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
XLSX.utils.sheet_add_aoa(worksheet, [['User1', 'test@test.com']], {
|
||||||
|
origin: 'A2',
|
||||||
|
});
|
||||||
|
|
||||||
let error;
|
let error;
|
||||||
try {
|
try {
|
||||||
importer.getData();
|
await importer.getData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
}
|
}
|
||||||
@ -1567,7 +1650,7 @@ describe('xlsx importer', () => {
|
|||||||
columns: importColumns,
|
columns: importColumns,
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1629,7 +1712,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1698,7 +1781,7 @@ describe('xlsx importer', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const template = await templateCreator.run();
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
const worksheet = template.Sheets[template.SheetNames[0]];
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
@ -1739,4 +1822,249 @@ describe('xlsx importer', () => {
|
|||||||
expect(await User.repository.count()).toBe(0);
|
expect(await User.repository.count()).toBe(0);
|
||||||
expect(error).toBeTruthy();
|
expect(error).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should import data with multiline explain and field descriptions', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'email',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
explain: '这是第一行说明\n这是第二行说明',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
description: '请输入用户姓名',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: ['email'],
|
||||||
|
defaultTitle: '邮箱',
|
||||||
|
description: '请输入有效的邮箱地址',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
|
||||||
|
const headerRowIndex = templateCreator.getHeaderRowIndex();
|
||||||
|
|
||||||
|
console.log({ headerRowIndex });
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
|
XLSX.utils.sheet_add_aoa(
|
||||||
|
worksheet,
|
||||||
|
[
|
||||||
|
['User1', 'test@test.com'],
|
||||||
|
['User2', 'test2@test.com'],
|
||||||
|
],
|
||||||
|
{
|
||||||
|
origin: `A${headerRowIndex + 1}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const importer = new XlsxImporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
description: '请输入用户姓名',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: ['email'],
|
||||||
|
defaultTitle: '邮箱',
|
||||||
|
description: '请输入有效的邮箱地址',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workbook: template,
|
||||||
|
});
|
||||||
|
|
||||||
|
await importer.run();
|
||||||
|
|
||||||
|
expect(await User.repository.count()).toBe(2);
|
||||||
|
|
||||||
|
const users = await User.repository.find();
|
||||||
|
expect(users[0].get('name')).toBe('User1');
|
||||||
|
expect(users[0].get('email')).toBe('test@test.com');
|
||||||
|
expect(users[1].get('name')).toBe('User2');
|
||||||
|
expect(users[1].get('email')).toBe('test2@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template creator', () => {
|
||||||
|
it('should create template with explain and field descriptions', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'email',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
explain: '这是导入说明\n第二行说明',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
description: '请输入用户姓名',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: ['email'],
|
||||||
|
defaultTitle: '邮箱',
|
||||||
|
description: '请输入有效的邮箱地址',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as string[][];
|
||||||
|
|
||||||
|
// 验证说明文本
|
||||||
|
expect(data[0][0]).toBe('这是导入说明');
|
||||||
|
expect(data[1][0]).toBe('第二行说明');
|
||||||
|
// 验证空行
|
||||||
|
expect(data[2][0]).toBe('');
|
||||||
|
// 验证字段描述
|
||||||
|
expect(data[3][0]).toBe('姓名:请输入用户姓名');
|
||||||
|
expect(data[4][0]).toBe('邮箱:请输入有效的邮箱地址');
|
||||||
|
// 验证表头
|
||||||
|
expect(data[5]).toEqual(['姓名', '邮箱']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create template with only field descriptions', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
description: '请输入用户姓名',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as string[][];
|
||||||
|
|
||||||
|
// 验证字段描述
|
||||||
|
expect(data[0][0]).toBe('姓名:请输入用户姓名');
|
||||||
|
// 验证表头
|
||||||
|
expect(data[1]).toEqual(['姓名']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create template with only explain', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
explain: '这是导入说明',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as string[][];
|
||||||
|
|
||||||
|
// 验证说明文本
|
||||||
|
expect(data[0][0]).toBe('这是导入说明');
|
||||||
|
// 验证表头
|
||||||
|
expect(data[1]).toEqual(['姓名']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import data with single column', async () => {
|
||||||
|
const User = app.db.collection({
|
||||||
|
name: 'users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.db.sync();
|
||||||
|
|
||||||
|
const templateCreator = new TemplateCreator({
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
|
||||||
|
const worksheet = template.Sheets[template.SheetNames[0]];
|
||||||
|
|
||||||
|
XLSX.utils.sheet_add_aoa(worksheet, [['User1'], ['User2']], {
|
||||||
|
origin: 'A2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const importer = new XlsxImporter({
|
||||||
|
collectionManager: app.mainDataSource.collectionManager,
|
||||||
|
collection: User,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
dataIndex: ['name'],
|
||||||
|
defaultTitle: '姓名',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workbook: template,
|
||||||
|
});
|
||||||
|
|
||||||
|
await importer.run();
|
||||||
|
|
||||||
|
expect(await User.repository.count()).toBe(2);
|
||||||
|
const users = await User.repository.find();
|
||||||
|
expect(users[0].get('name')).toBe('User1');
|
||||||
|
expect(users[1].get('name')).toBe('User2');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,31 +9,24 @@
|
|||||||
|
|
||||||
import { Context, Next } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
import { TemplateCreator } from '../services/template-creator';
|
import { TemplateCreator } from '../services/template-creator';
|
||||||
import XLSX from 'xlsx';
|
import { Workbook } from 'exceljs';
|
||||||
|
|
||||||
export async function downloadXlsxTemplate(ctx: Context, next: Next) {
|
export async function downloadXlsxTemplate(ctx: Context, next: Next) {
|
||||||
let { columns } = ctx.request.body as any;
|
const { resourceName, values = {} } = ctx.action.params;
|
||||||
const { explain, title } = ctx.request.body as any;
|
const { collection } = ctx.db;
|
||||||
if (typeof columns === 'string') {
|
|
||||||
columns = JSON.parse(columns);
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateCreator = new TemplateCreator({
|
const templateCreator = new TemplateCreator({
|
||||||
explain,
|
collection,
|
||||||
title,
|
...values,
|
||||||
columns,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const workbook = await templateCreator.run();
|
const workbook = await templateCreator.run();
|
||||||
|
|
||||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
const buffer = await (workbook as Workbook).xlsx.writeBuffer();
|
||||||
|
|
||||||
|
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
ctx.set('Content-Disposition', `attachment; filename="${resourceName}-import-template.xlsx"`);
|
||||||
ctx.body = buffer;
|
ctx.body = buffer;
|
||||||
|
|
||||||
ctx.set({
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'Content-Disposition': `attachment; filename=${encodeURIComponent(title)}.xlsx`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
export class ImportValidationError extends Error {
|
||||||
|
code: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
|
||||||
|
constructor(code: string, params?: Record<string, any>) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.params = params;
|
||||||
|
this.name = 'ImportValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportErrorOptions {
|
||||||
|
rowIndex: number;
|
||||||
|
rowData: any;
|
||||||
|
cause?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImportError extends Error {
|
||||||
|
rowIndex: number;
|
||||||
|
rowData: any;
|
||||||
|
cause?: Error;
|
||||||
|
|
||||||
|
constructor(message: string, options: ImportErrorOptions) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ImportError';
|
||||||
|
this.rowIndex = options.rowIndex;
|
||||||
|
this.rowData = options.rowData;
|
||||||
|
this.cause = options.cause;
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@
|
|||||||
import { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
import { downloadXlsxTemplate, importXlsx } from './actions';
|
import { downloadXlsxTemplate, importXlsx } from './actions';
|
||||||
import { importMiddleware } from './middleware';
|
import { importMiddleware } from './middleware';
|
||||||
|
import { ImportError, ImportValidationError } from './errors';
|
||||||
export class PluginActionImportServer extends Plugin {
|
export class PluginActionImportServer extends Plugin {
|
||||||
beforeLoad() {
|
beforeLoad() {
|
||||||
this.app.on('afterInstall', async () => {
|
this.app.on('afterInstall', async () => {
|
||||||
@ -55,7 +55,50 @@ export class PluginActionImportServer extends Plugin {
|
|||||||
|
|
||||||
dataSource.acl.allow('*', 'downloadXlsxTemplate', 'loggedIn');
|
dataSource.acl.allow('*', 'downloadXlsxTemplate', 'loggedIn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorHandlerPlugin = this.app.getPlugin<any>('error-handler');
|
||||||
|
|
||||||
|
errorHandlerPlugin.errorHandler.register(
|
||||||
|
(err) => err instanceof ImportValidationError,
|
||||||
|
(err: ImportValidationError, ctx) => {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: ctx.i18n.t(err.code, {
|
||||||
|
...err.params,
|
||||||
|
ns: 'action-import',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
errorHandlerPlugin.errorHandler.register(
|
||||||
|
(err) => err.name === 'ImportError',
|
||||||
|
(err: ImportError, ctx) => {
|
||||||
|
ctx.status = 400;
|
||||||
|
const causeError = err.cause;
|
||||||
|
errorHandlerPlugin.errorHandler.renderError(causeError, ctx);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: ctx.i18n.t('import-error', {
|
||||||
|
ns: 'action-import',
|
||||||
|
rowData: err.rowData,
|
||||||
|
rowIndex: err.rowIndex,
|
||||||
|
causeMessage: ctx.body.errors[0].message,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PluginActionImportServer;
|
export default PluginActionImportServer;
|
||||||
|
export * from './services/xlsx-importer';
|
||||||
|
export * from './services/template-creator';
|
||||||
|
@ -9,40 +9,153 @@
|
|||||||
|
|
||||||
import { ICollection } from '@nocobase/data-source-manager';
|
import { ICollection } from '@nocobase/data-source-manager';
|
||||||
import { ImportColumn } from './xlsx-importer';
|
import { ImportColumn } from './xlsx-importer';
|
||||||
import XLSX, { WorkBook } from 'xlsx';
|
import { WorkbookConverter } from '../utils/workbook-converter';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { Workbook as ExcelJSWorkbook } from 'exceljs';
|
||||||
|
|
||||||
type TemplateCreatorOptions = {
|
export type TemplateCreatorOptions = {
|
||||||
collection?: ICollection;
|
collection?: ICollection;
|
||||||
title?: string;
|
title?: string;
|
||||||
explain?: string;
|
explain?: string;
|
||||||
columns: Array<ImportColumn>;
|
columns: Array<ImportColumn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TemplateResult = {
|
||||||
|
workbook: XLSX.WorkBook | ExcelJSWorkbook;
|
||||||
|
headerRowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class TemplateCreator {
|
export class TemplateCreator {
|
||||||
|
private headerRowIndex: number;
|
||||||
|
|
||||||
constructor(private options: TemplateCreatorOptions) {}
|
constructor(private options: TemplateCreatorOptions) {}
|
||||||
|
|
||||||
async run(): Promise<WorkBook> {
|
getHeaderRowIndex() {
|
||||||
const workbook = XLSX.utils.book_new();
|
return this.headerRowIndex;
|
||||||
const worksheet = XLSX.utils.sheet_new();
|
}
|
||||||
|
|
||||||
const data = [this.renderHeaders()];
|
async run(options?: any): Promise<XLSX.WorkBook | ExcelJSWorkbook> {
|
||||||
|
const workbook = new ExcelJSWorkbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Sheet 1');
|
||||||
|
|
||||||
|
const headers = this.renderHeaders();
|
||||||
|
let currentRow = 1;
|
||||||
|
|
||||||
|
let explainText = '';
|
||||||
|
|
||||||
if (this.options.explain && this.options.explain?.trim() !== '') {
|
if (this.options.explain && this.options.explain?.trim() !== '') {
|
||||||
data.unshift([this.options.explain]);
|
explainText = this.options.explain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// write headers
|
const fieldDescriptions = this.options.columns
|
||||||
XLSX.utils.sheet_add_aoa(worksheet, data, {
|
.filter((col) => col.description)
|
||||||
origin: 'A1',
|
.map((col) => `${col.title || col.defaultTitle}:${col.description}`);
|
||||||
|
|
||||||
|
if (fieldDescriptions.length > 0) {
|
||||||
|
if (explainText) {
|
||||||
|
explainText += '\n\n';
|
||||||
|
}
|
||||||
|
explainText += fieldDescriptions.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explainText.trim() !== '') {
|
||||||
|
const lines = explainText.split('\n');
|
||||||
|
const EXPLAIN_MERGE_COLUMNS = 5; // Default merge 5 columns
|
||||||
|
|
||||||
|
// Pre-set the required number of columns and their widths
|
||||||
|
const columnsNeeded = Math.max(EXPLAIN_MERGE_COLUMNS, headers.length);
|
||||||
|
for (let i = 1; i <= columnsNeeded; i++) {
|
||||||
|
const col = worksheet.getColumn(i);
|
||||||
|
// Set first column wider
|
||||||
|
col.width = i === 1 ? 60 : 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const row = worksheet.getRow(index + 1);
|
||||||
|
// Merge first 5 cells for explanation text
|
||||||
|
worksheet.mergeCells(index + 1, 1, index + 1, EXPLAIN_MERGE_COLUMNS);
|
||||||
|
|
||||||
|
const cell = row.getCell(1);
|
||||||
|
cell.value = line;
|
||||||
|
|
||||||
|
cell.alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
horizontal: 'left',
|
||||||
|
indent: 1,
|
||||||
|
wrapText: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
currentRow = lines.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write headers and set styles
|
||||||
|
const headerRow = worksheet.getRow(currentRow);
|
||||||
|
headerRow.height = 25;
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const cell = headerRow.getCell(index + 1);
|
||||||
|
cell.value = header;
|
||||||
|
cell.font = {
|
||||||
|
bold: true,
|
||||||
|
size: 10,
|
||||||
|
name: 'Arial',
|
||||||
|
};
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin' },
|
||||||
|
bottom: { style: 'thin' },
|
||||||
|
left: { style: 'thin' },
|
||||||
|
right: { style: 'thin' },
|
||||||
|
};
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFE9ECEF' }, // Darker gray background for headers
|
||||||
|
};
|
||||||
|
cell.alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
horizontal: 'left',
|
||||||
|
indent: 1,
|
||||||
|
wrapText: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是第一列,恢复到正常宽度
|
||||||
|
if (index === 0) {
|
||||||
|
worksheet.getColumn(1).width = 30;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet 1');
|
// Set column widths
|
||||||
|
headers.forEach((_, index) => {
|
||||||
|
const col = worksheet.getColumn(index + 1);
|
||||||
|
col.width = 30;
|
||||||
|
|
||||||
|
// Set style for the first column
|
||||||
|
if (index === 0) {
|
||||||
|
col.eachCell({ includeEmpty: false }, (cell, rowNumber) => {
|
||||||
|
// Only set background color for headers, not for other rows
|
||||||
|
if (rowNumber === currentRow) {
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFE9ECEF' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.headerRowIndex = currentRow;
|
||||||
|
|
||||||
|
if (options?.returnXLSXWorkbook) {
|
||||||
|
return await WorkbookConverter.excelJSToXLSX(workbook);
|
||||||
|
}
|
||||||
|
|
||||||
return workbook;
|
return workbook;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHeaders() {
|
renderHeaders() {
|
||||||
return this.options.columns.map((col) => {
|
return this.options.columns.map((col) => {
|
||||||
return col.defaultTitle;
|
return col.title || col.defaultTitle;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,29 +7,32 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import XLSX, { WorkBook } from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { ICollection, ICollectionManager, IRelationField } from '@nocobase/data-source-manager';
|
import { ICollection, ICollectionManager, IRelationField } from '@nocobase/data-source-manager';
|
||||||
import { Collection as DBCollection, Database } from '@nocobase/database';
|
import { Collection as DBCollection, Database } from '@nocobase/database';
|
||||||
import { Transaction } from 'sequelize';
|
import { Transaction } from 'sequelize';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
import { ImportValidationError, ImportError } from '../errors';
|
||||||
|
|
||||||
export type ImportColumn = {
|
export type ImportColumn = {
|
||||||
dataIndex: Array<string>;
|
dataIndex: Array<string>;
|
||||||
defaultTitle: string;
|
defaultTitle: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImporterOptions = {
|
export type ImporterOptions = {
|
||||||
collectionManager: ICollectionManager;
|
collectionManager: ICollectionManager;
|
||||||
collection: ICollection;
|
collection: ICollection;
|
||||||
columns: Array<ImportColumn>;
|
columns: Array<ImportColumn>;
|
||||||
workbook: WorkBook;
|
workbook: any;
|
||||||
chunkSize?: number;
|
chunkSize?: number;
|
||||||
explain?: string;
|
explain?: string;
|
||||||
repository?: any;
|
repository?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunOptions = {
|
export type RunOptions = {
|
||||||
transaction?: Transaction;
|
transaction?: Transaction;
|
||||||
context?: any;
|
context?: any;
|
||||||
};
|
};
|
||||||
@ -37,9 +40,13 @@ type RunOptions = {
|
|||||||
export class XlsxImporter extends EventEmitter {
|
export class XlsxImporter extends EventEmitter {
|
||||||
private repository;
|
private repository;
|
||||||
|
|
||||||
constructor(private options: ImporterOptions) {
|
constructor(protected options: ImporterOptions) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
if (typeof options.columns === 'string') {
|
||||||
|
options.columns = JSON.parse(options.columns);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.columns.length == 0) {
|
if (options.columns.length == 0) {
|
||||||
throw new Error(`columns is empty`);
|
throw new Error(`columns is empty`);
|
||||||
}
|
}
|
||||||
@ -47,6 +54,21 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
this.repository = options.repository ? options.repository : options.collection.repository;
|
this.repository = options.repository ? options.repository : options.collection.repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validate() {
|
||||||
|
if (this.options.columns.length == 0) {
|
||||||
|
throw new ImportValidationError('Columns configuration is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of this.options.columns) {
|
||||||
|
const field = this.options.collection.getField(column.dataIndex[0]);
|
||||||
|
if (!field) {
|
||||||
|
throw new ImportValidationError('Field not found: {{field}}', { field: column.dataIndex[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getData();
|
||||||
|
}
|
||||||
|
|
||||||
async run(options: RunOptions = {}) {
|
async run(options: RunOptions = {}) {
|
||||||
let transaction = options.transaction;
|
let transaction = options.transaction;
|
||||||
|
|
||||||
@ -57,6 +79,7 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.validate();
|
||||||
const imported = await this.performImport(options);
|
const imported = await this.performImport(options);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -69,7 +92,6 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
return imported;
|
return imported;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
transaction && (await transaction.rollback());
|
transaction && (await transaction.rollback());
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,19 +149,21 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
this.emit('seqReset', { maxVal, seqName: autoIncrInfo.seqName });
|
this.emit('seqReset', { maxVal, seqName: autoIncrInfo.seqName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async performImport(options?: RunOptions) {
|
async performImport(options?: RunOptions): Promise<any> {
|
||||||
const transaction = options?.transaction;
|
const transaction = options?.transaction;
|
||||||
const rows = this.getData();
|
const data = await this.getData();
|
||||||
const chunks = lodash.chunk(rows, this.options.chunkSize || 200);
|
const chunks = lodash.chunk(data.slice(1), this.options.chunkSize || 200);
|
||||||
|
|
||||||
let handingRowIndex = 1;
|
let handingRowIndex = 1;
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
// Calculate total rows to be imported
|
||||||
|
const total = data.length - 1; // Subtract header row
|
||||||
|
|
||||||
if (this.options.explain) {
|
if (this.options.explain) {
|
||||||
handingRowIndex += 1;
|
handingRowIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let imported = 0;
|
|
||||||
|
|
||||||
for (const chunkRows of chunks) {
|
for (const chunkRows of chunks) {
|
||||||
for (const row of chunkRows) {
|
for (const row of chunkRows) {
|
||||||
const rowValues = {};
|
const rowValues = {};
|
||||||
@ -151,7 +175,9 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
const field = this.options.collection.getField(column.dataIndex[0]);
|
const field = this.options.collection.getField(column.dataIndex[0]);
|
||||||
|
|
||||||
if (!field) {
|
if (!field) {
|
||||||
throw new Error(`Field not found: ${column.dataIndex[0]}`);
|
throw new ImportValidationError('Import validation.Field not found', {
|
||||||
|
field: column.dataIndex[0],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const str = row[index];
|
const str = row[index];
|
||||||
@ -185,32 +211,49 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
rowValues[dataKey] = await interfaceInstance.toValue(this.trimString(str), ctx);
|
rowValues[dataKey] = await interfaceInstance.toValue(this.trimString(str), ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.repository.create({
|
await this.performInsert({
|
||||||
values: rowValues,
|
values: rowValues,
|
||||||
context: options?.context,
|
|
||||||
transaction,
|
transaction,
|
||||||
|
context: options?.context,
|
||||||
});
|
});
|
||||||
|
|
||||||
imported += 1;
|
imported += 1;
|
||||||
|
|
||||||
|
// Emit progress event
|
||||||
|
this.emit('progress', {
|
||||||
|
total,
|
||||||
|
current: imported,
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new ImportError(`Import failed at row ${handingRowIndex}`, {
|
||||||
`failed to import row ${handingRowIndex}, ${this.renderErrorMessage(error)}, rowData: ${JSON.stringify(
|
rowIndex: handingRowIndex,
|
||||||
rowValues,
|
rowData: Object.entries(rowValues)
|
||||||
)}`,
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
{ cause: error },
|
.join(', '),
|
||||||
);
|
cause: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// await to prevent high cpu usage
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async performInsert(insertOptions: { values: any; transaction: Transaction; context: any; hooks?: boolean }) {
|
||||||
|
const { values, transaction, context } = insertOptions;
|
||||||
|
|
||||||
|
return this.repository.create({
|
||||||
|
values,
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
hooks: insertOptions.hooks == undefined ? true : insertOptions.hooks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderErrorMessage(error) {
|
renderErrorMessage(error) {
|
||||||
let message = error.message;
|
let message = error.message;
|
||||||
if (error.parent) {
|
if (error.parent) {
|
||||||
@ -227,33 +270,70 @@ export class XlsxImporter extends EventEmitter {
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
getData() {
|
private getExpectedHeaders(): string[] {
|
||||||
const firstSheet = this.firstSheet();
|
return this.options.columns.map((col) => col.title || col.defaultTitle);
|
||||||
const rows = XLSX.utils.sheet_to_json(firstSheet, { header: 1, defval: null });
|
}
|
||||||
|
|
||||||
if (this.options.explain) {
|
async getData() {
|
||||||
rows.shift();
|
const workbook = this.options.workbook;
|
||||||
|
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
|
||||||
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: null }) as string[][];
|
||||||
|
|
||||||
|
// Find and validate header row
|
||||||
|
const expectedHeaders = this.getExpectedHeaders();
|
||||||
|
const { headerRowIndex, headers } = this.findAndValidateHeaders(data);
|
||||||
|
if (headerRowIndex === -1) {
|
||||||
|
throw new ImportValidationError('Headers not found. Expected headers: {{headers}}', {
|
||||||
|
headers: expectedHeaders.join(', '),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = rows[0];
|
// Extract data rows
|
||||||
|
const rows = data.slice(headerRowIndex + 1);
|
||||||
|
|
||||||
const columns = this.options.columns;
|
// if no data rows, throw error
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new ImportValidationError('No data to import');
|
||||||
|
}
|
||||||
|
|
||||||
// validate headers
|
return [headers, ...rows];
|
||||||
for (let i = 0; i < columns.length; i++) {
|
}
|
||||||
const column = columns[i];
|
|
||||||
if (column.defaultTitle !== headers[i]) {
|
private findAndValidateHeaders(data: string[][]): { headerRowIndex: number; headers: string[] } {
|
||||||
throw new Error(`Invalid header: ${column.defaultTitle} !== ${headers[i]}`);
|
const expectedHeaders = this.getExpectedHeaders();
|
||||||
|
|
||||||
|
// Find header row and validate
|
||||||
|
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
||||||
|
const row = data[rowIndex];
|
||||||
|
const actualHeaders = row.filter((cell) => cell !== null && cell !== '');
|
||||||
|
|
||||||
|
const allHeadersFound = expectedHeaders.every((header) => actualHeaders.includes(header));
|
||||||
|
const noExtraHeaders = actualHeaders.length === expectedHeaders.length;
|
||||||
|
|
||||||
|
if (allHeadersFound && noExtraHeaders) {
|
||||||
|
const mismatchIndex = expectedHeaders.findIndex((title, index) => actualHeaders[index] !== title);
|
||||||
|
|
||||||
|
if (mismatchIndex === -1) {
|
||||||
|
// All headers match
|
||||||
|
return { headerRowIndex: rowIndex, headers: actualHeaders };
|
||||||
|
} else {
|
||||||
|
// Found potential header row but with mismatch
|
||||||
|
throw new ImportValidationError(
|
||||||
|
'Header mismatch at column {{column}}: expected "{{expected}}", but got "{{actual}}"',
|
||||||
|
{
|
||||||
|
column: mismatchIndex + 1,
|
||||||
|
expected: expectedHeaders[mismatchIndex],
|
||||||
|
actual: actualHeaders[mismatchIndex] || 'empty',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove header
|
// No row with matching headers found
|
||||||
rows.shift();
|
throw new ImportValidationError('Headers not found. Expected headers: {{headers}}', {
|
||||||
|
headers: expectedHeaders.join(', '),
|
||||||
return rows;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
firstSheet() {
|
|
||||||
return this.options.workbook.Sheets[this.options.workbook.SheetNames[0]];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { Workbook as ExcelJSWorkbook } from 'exceljs';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
export class WorkbookConverter {
|
||||||
|
/**
|
||||||
|
* Convert ExcelJS Workbook to XLSX Workbook
|
||||||
|
*/
|
||||||
|
static async excelJSToXLSX(workbook: ExcelJSWorkbook): Promise<XLSX.WorkBook> {
|
||||||
|
// Convert ExcelJS workbook to buffer in memory
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
// Convert buffer to XLSX workbook
|
||||||
|
return XLSX.read(buffer, { type: 'buffer' });
|
||||||
|
}
|
||||||
|
}
|
@ -112,6 +112,33 @@ export class PluginAuthServer extends Plugin {
|
|||||||
await this.cache.del(`auth:${userId}`);
|
await this.cache.del(`auth:${userId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.app.on('ws:message:auth:token', async ({ clientId, payload }) => {
|
||||||
|
const auth = await this.app.authManager.get('basic', {
|
||||||
|
getBearerToken: () => payload.token,
|
||||||
|
app: this.app,
|
||||||
|
db: this.app.db,
|
||||||
|
cache: this.app.cache,
|
||||||
|
logger: this.app.logger,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const user = await auth.check();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.app.logger.error(`Invalid token: ${payload.token}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app.emit(`ws:setTag`, {
|
||||||
|
clientId,
|
||||||
|
tagKey: 'userId',
|
||||||
|
tagValue: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.emit(`ws:authorized`, {
|
||||||
|
clientId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
this.app.auditManager.registerActions([
|
this.app.auditManager.registerActions([
|
||||||
{
|
{
|
||||||
name: 'auth:signIn',
|
name: 'auth:signIn',
|
||||||
|
@ -13,6 +13,7 @@ import { setCurrentRole } from '@nocobase/plugin-acl';
|
|||||||
import { ACL, AvailableActionOptions } from '@nocobase/acl';
|
import { ACL, AvailableActionOptions } from '@nocobase/acl';
|
||||||
import { DataSourcesRolesModel } from './data-sources-roles-model';
|
import { DataSourcesRolesModel } from './data-sources-roles-model';
|
||||||
import PluginDataSourceManagerServer from '../plugin';
|
import PluginDataSourceManagerServer from '../plugin';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
const availableActions: {
|
const availableActions: {
|
||||||
[key: string]: AvailableActionOptions;
|
[key: string]: AvailableActionOptions;
|
||||||
@ -98,6 +99,8 @@ export class DataSourceModel extends Model {
|
|||||||
name: dataSourceKey,
|
name: dataSourceKey,
|
||||||
logger: app.logger.child({ dataSourceKey }),
|
logger: app.logger.child({ dataSourceKey }),
|
||||||
sqlLogger: app.sqlLogger.child({ dataSourceKey }),
|
sqlLogger: app.sqlLogger.child({ dataSourceKey }),
|
||||||
|
cache: app.cache,
|
||||||
|
storagePath: path.join(process.cwd(), 'storage', 'cache', 'apps', app.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
dataSource.on('loadingProgress', (progress) => {
|
dataSource.on('loadingProgress', (progress) => {
|
||||||
|
@ -36,6 +36,16 @@ export class ErrorHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderError(err, ctx) {
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
if (handler.guard(err)) {
|
||||||
|
return handler.render(err, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.defaultHandler(err, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
middleware() {
|
middleware() {
|
||||||
const self = this;
|
const self = this;
|
||||||
return async function errorHandler(ctx, next) {
|
return async function errorHandler(ctx, next) {
|
||||||
@ -48,13 +58,7 @@ export class ErrorHandler {
|
|||||||
ctx.status = err.statusCode;
|
ctx.status = err.statusCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const handler of self.handlers) {
|
self.renderError(err, ctx);
|
||||||
if (handler.guard(err)) {
|
|
||||||
return handler.render(err, ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.defaultHandler(err, ctx);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -8,3 +8,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { PluginErrorHandlerServer as default } from './server';
|
export { PluginErrorHandlerServer as default } from './server';
|
||||||
|
export { ErrorHandler } from './error-handler';
|
||||||
|
@ -45,7 +45,9 @@ export class PluginErrorHandlerServer extends Plugin {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.errorHandler.register(
|
this.errorHandler.register(
|
||||||
(err) => err?.errors?.length && err instanceof BaseError,
|
(err) =>
|
||||||
|
err?.errors?.length &&
|
||||||
|
(err.name === 'SequelizeValidationError' || err.name === 'SequelizeUniqueConstraintError'),
|
||||||
(err, ctx) => {
|
(err, ctx) => {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
errors: err.errors.map((err) => {
|
errors: err.errors.map((err) => {
|
||||||
|
@ -301,6 +301,11 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
this.dispatch();
|
this.dispatch();
|
||||||
}, 300_000);
|
}, 300_000);
|
||||||
|
|
||||||
|
this.app.on('workflow:dispatch', () => {
|
||||||
|
this.app.logger.info('workflow:dispatch');
|
||||||
|
this.dispatch();
|
||||||
|
});
|
||||||
|
|
||||||
// check for not started executions
|
// check for not started executions
|
||||||
this.dispatch();
|
this.dispatch();
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user