mirror of
https://gitee.com/baidu/amis.git
synced 2024-11-29 18:48:45 +08:00
feat: api 添加 concatDataFields 配置支持多次请求将数据字段自动合并功能 (#9038)
This commit is contained in:
parent
fd1d367a68
commit
feba8aef2b
@ -81,6 +81,40 @@ public class StreamingResponseBodyController {
|
||||
|
||||
需要注意有些反向代理有 buffer 设置,比如 nginx 的 [proxy_buffer_size](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size),它会使得即便后端返回内容也需要等 buffer 满了才会真正返回前端,如果需要更实时的效果就需要关掉此功能。
|
||||
|
||||
### 轮询方案
|
||||
|
||||
如果后端无法提供流的方式,也可以通过轮询的方式获取数据,比如:
|
||||
|
||||
```schema
|
||||
{
|
||||
"type": "page",
|
||||
"title": "轮询日志演示,当秒钟到 59 时会停止轮询",
|
||||
"body": [
|
||||
{
|
||||
"type": "service",
|
||||
"api": {
|
||||
"method": "get",
|
||||
"url": "/api/mock2/log/date?offset=${offset}",
|
||||
"autoRefresh": false, // 因为设置了轮询,所以关闭自动刷新
|
||||
"concatDataFields": "log"
|
||||
},
|
||||
"silentPolling": true,
|
||||
"interval": 1000,
|
||||
"stopAutoRefreshWhen": "${finished}",
|
||||
"body": [
|
||||
{
|
||||
"type": "log",
|
||||
"height": 300,
|
||||
"source": "${log}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
此示例利用了 Service 组件的轮询能力,并通过 `concatDataFields` 将多次返回的日志拼接起来,然后通过给 log 设置 `source` 属性来关联日志数据。
|
||||
|
||||
## 对于超长日志的优化
|
||||
|
||||
> 1.10.0 及以上版本
|
||||
|
@ -229,14 +229,17 @@ done
|
||||
[0K
|
||||
|
||||
Done. Your build exited with 0.
|
||||
`
|
||||
`;
|
||||
|
||||
const http = require('http');
|
||||
|
||||
let app = http.createServer((req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
'Access-Control-Allow-Origin': 'http://localhost:8888',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
@ -246,11 +249,11 @@ let app = http.createServer((req, res) => {
|
||||
if (index + random < logLength - 1) {
|
||||
res.write(logs.substring(index, index + random));
|
||||
} else {
|
||||
res.end(logs.substring(index, logLength - 1))
|
||||
res.end(logs.substring(index, logLength - 1));
|
||||
clearInterval(timer);
|
||||
}
|
||||
index = index + random;
|
||||
}, 100);
|
||||
});
|
||||
|
||||
app.listen(3000, '127.0.0.1');
|
||||
app.listen(3000, '127.0.0.1');
|
||||
|
33
mock/cfc/mock/log/date.js
Normal file
33
mock/cfc/mock/log/date.js
Normal file
@ -0,0 +1,33 @@
|
||||
function pad(num) {
|
||||
return num < 10 ? '0' + num : num;
|
||||
}
|
||||
|
||||
module.exports = function (req, res) {
|
||||
const now = new Date();
|
||||
const seconds = now.getSeconds();
|
||||
const logs = [];
|
||||
let from = req.query.offset ? parseInt(req.query.offset) || 0 : 0;
|
||||
const finished = seconds == 59;
|
||||
|
||||
for (let i = from; i <= seconds; i++) {
|
||||
logs.push(
|
||||
`Date: ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
|
||||
now.getDate() + 1
|
||||
)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(i)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
logs.push('Finished');
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 0,
|
||||
msg: '',
|
||||
data: {
|
||||
offset: seconds + 1,
|
||||
log: logs,
|
||||
finished: finished
|
||||
}
|
||||
});
|
||||
};
|
@ -468,7 +468,11 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
|
||||
}
|
||||
|
||||
self.items.replace(rowsData);
|
||||
self.reInitData(data, !!(api as ApiObject).replaceData);
|
||||
self.reInitData(
|
||||
data,
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
options.syncResponse2Query !== false &&
|
||||
updateQuery(
|
||||
pick(rest, Object.keys(self.query)),
|
||||
@ -564,7 +568,8 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
|
||||
{
|
||||
__saved: Date.now()
|
||||
},
|
||||
!!api && (api as ApiObject).replaceData
|
||||
!!api && (api as ApiObject).replaceData,
|
||||
(api as ApiObject)?.concatDataFields
|
||||
);
|
||||
self.updatedAt = Date.now();
|
||||
}
|
||||
|
@ -164,8 +164,13 @@ export const FormStore = ServiceStore.named('FormStore')
|
||||
};
|
||||
})
|
||||
.actions(self => {
|
||||
function setValues(values: object, tag?: object, replace?: boolean) {
|
||||
self.updateData(values, tag, replace);
|
||||
function setValues(
|
||||
values: object,
|
||||
tag?: object,
|
||||
replace?: boolean,
|
||||
concatFields?: string | string[]
|
||||
) {
|
||||
self.updateData(values, tag, replace, concatFields);
|
||||
|
||||
// 如果数据域中有数据变化,就都reset一下,去掉之前残留的验证消息
|
||||
self.items.forEach(item => {
|
||||
@ -368,7 +373,8 @@ export const FormStore = ServiceStore.named('FormStore')
|
||||
__saved: Date.now()
|
||||
}
|
||||
: undefined,
|
||||
!!(api as ApiObject).replaceData
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ import {SimpleMap} from '../utils/SimpleMap';
|
||||
import {StoreNode} from './node';
|
||||
import {IScopedContext} from '../Scoped';
|
||||
import {IRootStore} from './root';
|
||||
import {createObjectFromChain, extractObjectChain} from '../utils';
|
||||
import {concatData, createObjectFromChain, extractObjectChain} from '../utils';
|
||||
|
||||
export const iRendererStore = StoreNode.named('iRendererStore')
|
||||
.props({
|
||||
@ -58,7 +58,16 @@ export const iRendererStore = StoreNode.named('iRendererStore')
|
||||
self.data = self.pristine;
|
||||
},
|
||||
|
||||
updateData(data: object = {}, tag?: object, replace?: boolean) {
|
||||
updateData(
|
||||
data: object = {},
|
||||
tag?: object,
|
||||
replace?: boolean,
|
||||
concatFields?: string | string[]
|
||||
) {
|
||||
if (concatFields) {
|
||||
data = concatData(data, self.data, concatFields);
|
||||
}
|
||||
|
||||
const prev = self.data;
|
||||
let newData;
|
||||
if (tag) {
|
||||
|
@ -5,6 +5,7 @@ import {extendObject, isEmpty, isObject} from '../utils/helper';
|
||||
import {ServerError} from '../utils/errors';
|
||||
import {normalizeApiResponseData} from '../utils/api';
|
||||
import {replaceText} from '../utils/replaceText';
|
||||
import {concatData} from '../utils/concatData';
|
||||
|
||||
export const ServiceStore = iRendererStore
|
||||
.named('ServiceStore')
|
||||
@ -40,7 +41,14 @@ export const ServiceStore = iRendererStore
|
||||
self.busying = busying;
|
||||
}
|
||||
|
||||
function reInitData(data: object | undefined, replace: boolean = false) {
|
||||
function reInitData(
|
||||
data: object | undefined,
|
||||
replace: boolean = false,
|
||||
concatFields?: string | string[]
|
||||
) {
|
||||
if (concatFields) {
|
||||
data = concatData(data, self.data, concatFields);
|
||||
}
|
||||
const newData = extendObject(self.pristine, data, !replace);
|
||||
self.data = self.pristine = newData;
|
||||
}
|
||||
@ -106,7 +114,7 @@ export const ServiceStore = iRendererStore
|
||||
...(replace ? {} : self.data),
|
||||
...normalizeApiResponseData(json.data)
|
||||
};
|
||||
reInitData(data, replace);
|
||||
reInitData(data, replace, (api as ApiObject).concatDataFields);
|
||||
self.hasRemoteData = true;
|
||||
if (options && options.onSuccess) {
|
||||
const ret = options.onSuccess(json, data);
|
||||
@ -191,7 +199,8 @@ export const ServiceStore = iRendererStore
|
||||
self.updateData(
|
||||
normalizeApiResponseData(json.data),
|
||||
undefined,
|
||||
!!(api as ApiObject).replaceData
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
|
||||
self.hasRemoteData = true;
|
||||
@ -289,7 +298,8 @@ export const ServiceStore = iRendererStore
|
||||
self.updateData(
|
||||
normalizeApiResponseData(json.data),
|
||||
undefined,
|
||||
!!(api as ApiObject).replaceData
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
}
|
||||
|
||||
@ -457,7 +467,8 @@ export const ServiceStore = iRendererStore
|
||||
self.updateData(
|
||||
json.data.data,
|
||||
undefined,
|
||||
!!(api as ApiObject).replaceData
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
}
|
||||
|
||||
@ -516,7 +527,8 @@ export const ServiceStore = iRendererStore
|
||||
self.updateData(
|
||||
json.data,
|
||||
undefined,
|
||||
!!(api as ApiObject).replaceData
|
||||
!!(api as ApiObject).replaceData,
|
||||
(api as ApiObject).concatDataFields
|
||||
);
|
||||
|
||||
if (!json.ok) {
|
||||
|
@ -720,7 +720,8 @@ export const TableStore2 = ServiceStore.named('TableStore2')
|
||||
{
|
||||
__saved: Date.now()
|
||||
},
|
||||
!!api && (api as ApiObject).replaceData
|
||||
!!api && (api as ApiObject).replaceData,
|
||||
(api as ApiObject)?.concatDataFields
|
||||
);
|
||||
self.updatedAt = Date.now();
|
||||
}
|
||||
|
@ -153,6 +153,14 @@ export interface BaseApiObject {
|
||||
*/
|
||||
replaceData?: boolean;
|
||||
|
||||
/**
|
||||
* 是否将两次返回的数据字段,做一个合并。配置返回对象中的字段名,支持配置多个。
|
||||
*
|
||||
* 比如:同时返回 log 字段,第一次返回 {log: '1'},第二次返回 {log: '2'},合并后的结果是 {log: ['1', '2']]}
|
||||
* 再比如:同时返回 items 字段,第一次返回 {items: [1, 2]},第二次返回 {items: [3, 4]},合并后的结果是 {items: [1, 2, 3, 4]}
|
||||
*/
|
||||
concatDataFields?: string | Array<string>;
|
||||
|
||||
/**
|
||||
* 是否自动刷新,当 url 中的取值结果变化时,自动刷新数据。
|
||||
*
|
||||
|
35
packages/amis-core/src/utils/concatData.ts
Normal file
35
packages/amis-core/src/utils/concatData.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {getVariable} from './getVariable';
|
||||
import {setVariable} from './object';
|
||||
|
||||
/**
|
||||
* 合并两次数据,满足轮训时总合并数据的需求,比如日志接口,每次返回一部分,但是前端组件需要全部展示。
|
||||
*
|
||||
* @param data
|
||||
* @param origin
|
||||
* @param keys
|
||||
*/
|
||||
export function concatData(data: any, origin: any, keys: string | string[]) {
|
||||
keys = Array.isArray(keys)
|
||||
? keys
|
||||
: keys
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(item => item);
|
||||
let newData = {...data};
|
||||
let modified = false;
|
||||
|
||||
origin &&
|
||||
keys.forEach(key => {
|
||||
const prev = getVariable(origin, key);
|
||||
const next = getVariable(data, key);
|
||||
|
||||
const args: any = [];
|
||||
typeof prev !== 'undefined' && args.push(prev);
|
||||
typeof next !== 'undefined' && args.push(next);
|
||||
|
||||
modified = true;
|
||||
setVariable(newData, key, [].concat.apply([], args));
|
||||
});
|
||||
|
||||
return modified ? newData : data;
|
||||
}
|
@ -3,6 +3,7 @@ export * from './attachmentAdpator';
|
||||
export * from './autobind';
|
||||
export * from './ColorScale';
|
||||
export * from './columnsSplit';
|
||||
export * from './concatData';
|
||||
export * from './dataMapping';
|
||||
export * from './DataSchema';
|
||||
export * from './DataScope';
|
||||
|
@ -8,6 +8,7 @@ import {clearStoresCache} from '../../../src';
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearStoresCache();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Renderer:service', async () => {
|
||||
@ -334,3 +335,67 @@ test('form:service:super-remoteData', async () => {
|
||||
replaceReactAriaIds(container);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('service init api with interval and concatDataFields', async () => {
|
||||
jest.useFakeTimers();
|
||||
let times = 0;
|
||||
const fetcher = jest.fn().mockImplementation(() => {
|
||||
times++;
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
status: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
log: `${times}th log`,
|
||||
finished: times > 2
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const {container, getByText} = render(
|
||||
amisRender(
|
||||
{
|
||||
type: 'service',
|
||||
api: {
|
||||
method: 'get',
|
||||
url: '/api/initData',
|
||||
concatDataFields: 'log'
|
||||
},
|
||||
interval: 3000,
|
||||
stopAutoRefreshWhen: '${finished}',
|
||||
body: [
|
||||
{
|
||||
type: 'tpl',
|
||||
tpl: '${log|json}'
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
makeEnv({
|
||||
fetcher: fetcher
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await wait(10, false);
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
await wait(10, false);
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
await wait(10, false);
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
await wait(10, false);
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
await wait(200, false);
|
||||
const span = container.querySelector('.cxd-TplField>span');
|
||||
expect(span).toBeTruthy();
|
||||
expect(JSON.parse(span?.innerHTML!)).toMatchObject([
|
||||
'1th log',
|
||||
'2th log',
|
||||
'3th log'
|
||||
]);
|
||||
});
|
||||
|
@ -2,7 +2,14 @@
|
||||
* @file 用于显示日志的组件,比如显示命令行的输出结果
|
||||
*/
|
||||
import React from 'react';
|
||||
import {buildApi, isApiOutdated, Renderer, RendererProps} from 'amis-core';
|
||||
import {
|
||||
buildApi,
|
||||
isApiOutdated,
|
||||
isEffectiveApi,
|
||||
Renderer,
|
||||
RendererProps,
|
||||
resolveVariableAndFilter
|
||||
} from 'amis-core';
|
||||
import {BaseSchema} from '../Schema';
|
||||
import {Icon, SearchBox, VirtualList} from 'amis-ui';
|
||||
|
||||
@ -154,7 +161,20 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
);
|
||||
}
|
||||
if (this.props.source) {
|
||||
this.loadLogs();
|
||||
const ret = resolveVariableAndFilter(
|
||||
this.props.source,
|
||||
this.props.data,
|
||||
'| raw'
|
||||
);
|
||||
if (ret && isEffectiveApi(ret)) {
|
||||
this.loadLogs();
|
||||
} else if (
|
||||
typeof ret === 'string' ||
|
||||
(Array.isArray(ret) && ret.every(item => typeof item === 'string'))
|
||||
) {
|
||||
this.clear();
|
||||
this.addLines(Array.isArray(ret) ? ret : [ret]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,15 +182,38 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
if (this.autoScroll && this.logRef && this.logRef.current) {
|
||||
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
|
||||
}
|
||||
if (
|
||||
|
||||
if (!this.props.source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ret = resolveVariableAndFilter(
|
||||
this.props.source,
|
||||
this.props.data,
|
||||
'| raw'
|
||||
);
|
||||
|
||||
if (ret && isEffectiveApi(ret)) {
|
||||
// todo 如果原来的请求还在,应该先取消
|
||||
isApiOutdated(
|
||||
prevProps.source,
|
||||
this.props.source,
|
||||
prevProps.data,
|
||||
this.props.data
|
||||
)
|
||||
) && this.loadLogs();
|
||||
} else if (
|
||||
typeof ret === 'string' ||
|
||||
(Array.isArray(ret) && ret.every(item => typeof item === 'string'))
|
||||
) {
|
||||
this.loadLogs();
|
||||
const prevRet = resolveVariableAndFilter(
|
||||
prevProps.source,
|
||||
prevProps.data,
|
||||
'| raw'
|
||||
);
|
||||
if (prevRet !== ret && ret) {
|
||||
this.clear();
|
||||
this.addLines(Array.isArray(ret) ? ret : [ret]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,16 +237,19 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
clear = (e: React.MouseEvent<HTMLElement>) => {
|
||||
clear = (e?: React.MouseEvent<HTMLElement>) => {
|
||||
this.setState({
|
||||
logs: [],
|
||||
lastLine: '',
|
||||
logs: (this.logs = []),
|
||||
lastLine: (this.lastLine = ''),
|
||||
originLogs: [],
|
||||
originLastLine: ''
|
||||
});
|
||||
e.preventDefault();
|
||||
e?.preventDefault();
|
||||
};
|
||||
|
||||
// 因为 this.state 取不到最新的值,所以用了这个变量
|
||||
lastLine: string;
|
||||
logs: string[];
|
||||
filterWord = (logs: string[], lastLine: string, word: string) => {
|
||||
let originLogs = logs;
|
||||
let originLastLine = lastLine;
|
||||
@ -215,13 +261,39 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
}
|
||||
this.setState({
|
||||
filterWord: word,
|
||||
lastLine: lastLine,
|
||||
logs: logs,
|
||||
lastLine: (this.lastLine = lastLine),
|
||||
logs: (this.logs = logs),
|
||||
originLogs: originLogs,
|
||||
originLastLine: originLastLine
|
||||
});
|
||||
};
|
||||
|
||||
addLines = (lines: string[]) => {
|
||||
lines = lines.concat();
|
||||
const {maxLength} = this.props;
|
||||
let lastLine = this.lastLine || '';
|
||||
let logs = (this.logs || []).concat();
|
||||
// 如果没有换行符就只更新最后一行
|
||||
if (lines.length === 1) {
|
||||
lastLine += lines[0];
|
||||
this.setState({
|
||||
lastLine: (this.lastLine = lastLine)
|
||||
});
|
||||
} else {
|
||||
// 将之前的数据补上
|
||||
lines[0] = lastLine + (lines[0] || '');
|
||||
// 最后一个要么是空,要么是下一行的数据
|
||||
lastLine = lines.pop() || '';
|
||||
if (maxLength) {
|
||||
if (logs.length + lines.length > maxLength) {
|
||||
logs.splice(0, logs.length + lines.length - maxLength);
|
||||
}
|
||||
}
|
||||
logs = logs.concat(lines);
|
||||
this.filterWord(logs, lastLine, this.state.filterWord);
|
||||
}
|
||||
};
|
||||
|
||||
async loadLogs() {
|
||||
const {
|
||||
source,
|
||||
@ -249,8 +321,6 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
return;
|
||||
}
|
||||
const reader = body.getReader();
|
||||
let lastLine = '';
|
||||
let logs: string[] = [];
|
||||
for (;;) {
|
||||
if (!this.state.refresh) {
|
||||
await reader.cancel('click cancel button').then(() => {
|
||||
@ -263,25 +333,7 @@ export class Log extends React.Component<LogProps, LogState> {
|
||||
let text = new TextDecoder(encoding).decode(value, {stream: true});
|
||||
// 不考虑只有 \r 换行符的情况,几乎没人用
|
||||
const lines = text.split('\n');
|
||||
// 如果没有换行符就只更新最后一行
|
||||
if (lines.length === 1) {
|
||||
lastLine += lines[0];
|
||||
this.setState({
|
||||
lastLine: lastLine
|
||||
});
|
||||
} else {
|
||||
// 将之前的数据补上
|
||||
lines[0] = lastLine + lines[0];
|
||||
// 最后一个要么是空,要么是下一行的数据
|
||||
lastLine = lines.pop() || '';
|
||||
if (maxLength) {
|
||||
if (logs.length + lines.length > maxLength) {
|
||||
logs.splice(0, logs.length + lines.length - maxLength);
|
||||
}
|
||||
}
|
||||
logs = logs.concat(lines);
|
||||
this.filterWord(logs, lastLine, this.state.filterWord);
|
||||
}
|
||||
this.addLines(lines);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
|
@ -502,7 +502,7 @@ export default class Service extends React.Component<ServiceProps> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
store.updateData(returndata, undefined, false);
|
||||
store.updateData(returndata, undefined, false, wsApi.concatDataFields);
|
||||
store.setHasRemoteData();
|
||||
|
||||
this.runDataProvider('onWsFetched');
|
||||
|
Loading…
Reference in New Issue
Block a user