flamerest 1.0.118 → 1.0.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/tasks.json +11 -0
- package/REST.ts +837 -0
- package/docs/docs.html +367 -0
- package/package.json +2 -2
- package/tsconfig.json +13 -0
package/REST.ts
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { createHmac } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Базовый ответ от сервера
|
|
5
|
+
*/
|
|
6
|
+
export type BaseResponse<T> = {
|
|
7
|
+
type: string,
|
|
8
|
+
status: number,
|
|
9
|
+
ok: boolean,
|
|
10
|
+
data?: T,
|
|
11
|
+
errors: object | undefined,
|
|
12
|
+
message: string | undefined,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Часть ответа с пагинацией
|
|
17
|
+
*/
|
|
18
|
+
export type Paginated = {
|
|
19
|
+
pages: {
|
|
20
|
+
page: number,
|
|
21
|
+
perPage: number,
|
|
22
|
+
count: number,
|
|
23
|
+
total: number,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Стандартный ответ от Request с несколькими строками
|
|
29
|
+
*/
|
|
30
|
+
export type Rows<T> = BaseResponse<Array<T>> & Paginated;
|
|
31
|
+
|
|
32
|
+
export type Row<T> = BaseResponse<T> & Paginated;
|
|
33
|
+
|
|
34
|
+
export type CustomRequest<T> = BaseResponse<T>;
|
|
35
|
+
|
|
36
|
+
export type CustomAnyRequest = BaseResponse<any>;
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Стандартный ответ от Request с изменённой строкой
|
|
41
|
+
*/
|
|
42
|
+
export type SavedObject<T> = BaseResponse<T>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Результат авторизации
|
|
46
|
+
*/
|
|
47
|
+
export type Authorized = {
|
|
48
|
+
isAuthorized: boolean;
|
|
49
|
+
User: {
|
|
50
|
+
avatar: string,
|
|
51
|
+
country: string,
|
|
52
|
+
id: number,
|
|
53
|
+
lang: string,
|
|
54
|
+
name: string,
|
|
55
|
+
role: string,
|
|
56
|
+
user_hash?: string
|
|
57
|
+
};
|
|
58
|
+
errors: { [key: string]: any };
|
|
59
|
+
token: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class FLAMEREST {
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Адрес серва
|
|
66
|
+
*/
|
|
67
|
+
public SERVER: string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Стандартное число запросов на страницу
|
|
71
|
+
*/
|
|
72
|
+
public perPageDefault: number = 20;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Будет вызван, если любой из запросов вернут требование авторизоваться
|
|
76
|
+
*/
|
|
77
|
+
public unauthorized_callback: (() => void) | undefined;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Токен приложения конкретного клиента для отправки пуш уведомлений именно ему
|
|
81
|
+
*/
|
|
82
|
+
public pushNotificationToken: string | null = null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Версия api
|
|
86
|
+
*/
|
|
87
|
+
public version: string;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Если авторизация по токену, то он сюда подставляется
|
|
91
|
+
*/
|
|
92
|
+
public isAuthByJWTQuery: boolean = true;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Режим авторизации: Bearer|Link
|
|
96
|
+
*/
|
|
97
|
+
public authMode: "Bearer" | "Link" = "Bearer";
|
|
98
|
+
|
|
99
|
+
public token: string | null = null;
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
constructor(server_address?: string, localhost_endpoint?: string, unauthorized_callback?: () => void, version?: string) {
|
|
103
|
+
if (typeof window === 'undefined') {
|
|
104
|
+
// SSR: используем предоставленный адрес или дефолтный
|
|
105
|
+
this.SERVER = server_address ?? "http://localhost/";
|
|
106
|
+
} else {
|
|
107
|
+
// Client: определяем адрес по window.location или из параметра
|
|
108
|
+
if (server_address === undefined) {
|
|
109
|
+
this.SERVER = window.location.protocol + "//" + window.location.host;
|
|
110
|
+
} else {
|
|
111
|
+
this.SERVER = server_address;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (window.location.hostname === 'localhost' && localhost_endpoint !== undefined) {
|
|
115
|
+
this.SERVER = localhost_endpoint;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Убираем слэш в конце, если он есть
|
|
120
|
+
this.SERVER = this.SERVER.endsWith('/') ? this.SERVER.substring(0, this.SERVER.length - 1) : this.SERVER;
|
|
121
|
+
|
|
122
|
+
this.unauthorized_callback = unauthorized_callback;
|
|
123
|
+
this.version = version ?? 'v1';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Установка плагина для Vue
|
|
128
|
+
* @param Vue
|
|
129
|
+
* @param options
|
|
130
|
+
*/
|
|
131
|
+
install(Vue: any, options: any) {
|
|
132
|
+
if (typeof window !== 'undefined') {
|
|
133
|
+
(window as any).REST = this;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
*
|
|
139
|
+
* @param {string} url Адрес
|
|
140
|
+
* @param {object|string|FormData} params Параметры, которые надо передать, могут быть в виде объекта или строки
|
|
141
|
+
* @param {string} type Тип
|
|
142
|
+
* @param {string} responseType Тип ответа: json или blob
|
|
143
|
+
* @param {boolean} isNeedToken нужен ли токен для запроса
|
|
144
|
+
* @param {Object} customHeaders объект с доп заголовками, которые надо включить в запрос
|
|
145
|
+
*/
|
|
146
|
+
request(url: string, params: object | string | FormData, type: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', responseType: 'json' | 'blob' = 'json', isNeedToken = true, customHeaders: Record<string, string> = {}): Promise<BaseResponse<any> & Partial<Paginated>> {
|
|
147
|
+
|
|
148
|
+
// Нормализуем параметры, если они есть
|
|
149
|
+
if (typeof params === "object" && params !== null) {
|
|
150
|
+
if (!(params instanceof FormData)) {
|
|
151
|
+
params = JSON.stringify(params);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === 'GET') {
|
|
155
|
+
type = 'POST';
|
|
156
|
+
}
|
|
157
|
+
responseType = 'json';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Подставляем сервер автоматом в запрос начинающийся с /
|
|
161
|
+
if (url[0] === '/' && url[1] !== '/') {
|
|
162
|
+
url = this.SERVER + url;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const that = this;
|
|
166
|
+
|
|
167
|
+
// Фетч поддерживается - получаем через него, это быстрее
|
|
168
|
+
if (typeof fetch === "function") {
|
|
169
|
+
return new Promise(async (resolve, reject) => {
|
|
170
|
+
try {
|
|
171
|
+
// Авторизация
|
|
172
|
+
if (this.isAuthByJWTQuery && isNeedToken === true && this.token !== null && this.token !== undefined && this.token !== 'undefined') {
|
|
173
|
+
switch (this.authMode) {
|
|
174
|
+
case 'Link':
|
|
175
|
+
url = url + (url.indexOf("?") === -1 ? "?" : "&") + "access-token=" + this.token;
|
|
176
|
+
break;
|
|
177
|
+
default:
|
|
178
|
+
Object.assign(customHeaders, { 'Authorization': 'Bearer ' + this.token });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Уникальный user_hash (только на клиенте)
|
|
184
|
+
let user_hash: string | null = null;
|
|
185
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
186
|
+
user_hash = window.localStorage.getItem('user_hash');
|
|
187
|
+
if (user_hash) {
|
|
188
|
+
Object.assign(customHeaders, { udata: user_hash });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Тело запроса
|
|
193
|
+
const headers: Record<string, string> = { ...customHeaders };
|
|
194
|
+
if (!(params instanceof FormData)) {
|
|
195
|
+
headers['Content-type'] = 'application/json; charset=utf-8';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const requestBody: RequestInit = {
|
|
199
|
+
method: type,
|
|
200
|
+
mode: 'cors',
|
|
201
|
+
headers: headers
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (type !== 'GET') {
|
|
205
|
+
// Принимает и formData тоже и чистую json строку
|
|
206
|
+
requestBody.body = params;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Создаём подпись каждого запроса
|
|
210
|
+
let SIGN = "empty";
|
|
211
|
+
try {
|
|
212
|
+
// Используем user_hash для подписи, если он есть
|
|
213
|
+
SIGN = await this.hmac_sha256(requestBody.body, user_hash ?? "");
|
|
214
|
+
} catch (ex) {
|
|
215
|
+
// console.error('Can`t create a request sign');
|
|
216
|
+
}
|
|
217
|
+
(requestBody.headers as any)['sign'] = SIGN;
|
|
218
|
+
|
|
219
|
+
// Делаем запрос
|
|
220
|
+
const response = await fetch(url, requestBody);
|
|
221
|
+
|
|
222
|
+
// Тело ответа формируется в два этапа: сперва заголовки, затем ответ
|
|
223
|
+
const ResolveBody: any = {
|
|
224
|
+
status: response.status,
|
|
225
|
+
ok: response.ok
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Ответ с ошибкой
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
// Тело ошибки
|
|
231
|
+
ResolveBody.message = response.statusText;
|
|
232
|
+
try {
|
|
233
|
+
ResolveBody.errors = await response.json();
|
|
234
|
+
// Ошибки
|
|
235
|
+
switch (ResolveBody.status) {
|
|
236
|
+
// Ошибка валидации: Собираем все ошибки полей
|
|
237
|
+
case 422:
|
|
238
|
+
const Errs: { [key: string]: string } = {};
|
|
239
|
+
for (const err of ResolveBody.errors) {
|
|
240
|
+
Errs[err['field']] = Errs[err['field']] === undefined ? err['message'] : Errs[err['field']] + ". " + err['message']
|
|
241
|
+
}
|
|
242
|
+
ResolveBody.errors = Errs;
|
|
243
|
+
break;
|
|
244
|
+
case 401:
|
|
245
|
+
if (typeof this.unauthorized_callback === 'function') {
|
|
246
|
+
this.unauthorized_callback();
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
} catch (exjson) {
|
|
251
|
+
ResolveBody.errors = await response.text();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Рапортуем об ошибке
|
|
255
|
+
console.error('Ошибка загрузки [' + response.status + '] ' + url + ": " + response.statusText, ResolveBody, ResolveBody.errors);
|
|
256
|
+
resolve(ResolveBody);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Загрузка успешна
|
|
261
|
+
// Если в заголовках указана паджинация
|
|
262
|
+
let pages: Paginated['pages'] | undefined = undefined;
|
|
263
|
+
if (response.headers.get('X-Pagination-Current-Page') !== null) {
|
|
264
|
+
pages = {
|
|
265
|
+
page: parseInt(response.headers.get('X-Pagination-Current-Page')!),
|
|
266
|
+
perPage: parseInt(response.headers.get('X-Pagination-Per-Page')!),
|
|
267
|
+
count: parseInt(response.headers.get('X-Pagination-Page-Count')!),
|
|
268
|
+
total: parseInt(response.headers.get('X-Pagination-Total-Count')!),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Заполняем тело ответа заголовками
|
|
273
|
+
ResolveBody.type = "json";
|
|
274
|
+
ResolveBody.data = {};
|
|
275
|
+
ResolveBody.pages = pages;
|
|
276
|
+
|
|
277
|
+
// Получаем тело ответа
|
|
278
|
+
switch (responseType) {
|
|
279
|
+
case 'json': ResolveBody.data = await response.text(); break;
|
|
280
|
+
case 'blob': {
|
|
281
|
+
// Записываем имя файла и mime-тип
|
|
282
|
+
ResolveBody.filename = 'file'; // TODO: response.headers.get('content-disposition').split('filename=')[1];
|
|
283
|
+
ResolveBody.MimeType = response.headers.get('content-Type');
|
|
284
|
+
ResolveBody.data = await response.blob();
|
|
285
|
+
|
|
286
|
+
// Если ответ в виде блоба, сразу его отдаём без декодировки
|
|
287
|
+
resolve(ResolveBody);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Декодируем тело ответа, если оно есть
|
|
293
|
+
if (ResolveBody.data === undefined) {
|
|
294
|
+
ResolveBody.errors = ["Принятый ответ пуст"];
|
|
295
|
+
console.error("Принятый ответ пуст", ResolveBody);
|
|
296
|
+
resolve(ResolveBody);
|
|
297
|
+
return;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Пустой ответ конвертируем в валидный пустой объект
|
|
301
|
+
if (ResolveBody.data === "") ResolveBody.data = "{}";
|
|
302
|
+
|
|
303
|
+
// Декодируем
|
|
304
|
+
try {
|
|
305
|
+
ResolveBody.data = JSON.parse(ResolveBody.data);
|
|
306
|
+
} catch (ex) {
|
|
307
|
+
ResolveBody.errors = ["Ошибка декодирования"];
|
|
308
|
+
console.error("Ошибка декодирования", ResolveBody);
|
|
309
|
+
reject(ResolveBody);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Если пришёл ответ: неавторизовано, и указан коллбек авторизации - запускаем его
|
|
314
|
+
if ((ResolveBody.data as any).Auth === false) {
|
|
315
|
+
if (that.unauthorized_callback !== undefined) { that.unauthorized_callback(); resolve(ResolveBody); return; }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Возвращаем успешную загрузку
|
|
319
|
+
resolve(ResolveBody);
|
|
320
|
+
|
|
321
|
+
} catch (err: any) {
|
|
322
|
+
// Ошибка загрузки любого типа
|
|
323
|
+
// TODO: на этом этапе стоит сделать, чтобы он пробовал повторить запрос, если это GET
|
|
324
|
+
if (typeof err !== 'object' || err.message === undefined) {
|
|
325
|
+
err = {
|
|
326
|
+
status: 0,
|
|
327
|
+
message: '',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (typeof err.body === 'object') {
|
|
332
|
+
err.body = await err.body;
|
|
333
|
+
}
|
|
334
|
+
console.error('Ошибка загрузки [' + 0 + '] ' + url + ": " + err.message);
|
|
335
|
+
reject(err);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// Фетч не поддерживается (старые браузеры или окружения) - возвращаем промисифицированный XHR
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const xhr = new XMLHttpRequest();
|
|
343
|
+
xhr.open(type, url, true);
|
|
344
|
+
xhr.responseType = 'json';
|
|
345
|
+
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
|
|
346
|
+
xhr.withCredentials = false;
|
|
347
|
+
xhr.send(params as any);
|
|
348
|
+
|
|
349
|
+
xhr.onload = function () {
|
|
350
|
+
if (xhr.status !== 200) {
|
|
351
|
+
console.error('Ошибка загрузки [' + xhr.status + '] ' + url + ": " + xhr.statusText);
|
|
352
|
+
return reject({
|
|
353
|
+
status: xhr.status,
|
|
354
|
+
message: xhr.statusText
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let pages: Paginated['pages'] | undefined = undefined;
|
|
359
|
+
if (xhr.getResponseHeader('X-Pagination-Current-Page') !== null) {
|
|
360
|
+
pages = {
|
|
361
|
+
page: parseInt(xhr.getResponseHeader('X-Pagination-Current-Page')!),
|
|
362
|
+
perPage: parseInt(xhr.getResponseHeader('X-Pagination-Per-Page')!),
|
|
363
|
+
count: parseInt(xhr.getResponseHeader('X-Pagination-Page-Count')!),
|
|
364
|
+
total: parseInt(xhr.getResponseHeader('X-Pagination-Total-Count')!),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return resolve({
|
|
369
|
+
status: xhr.status,
|
|
370
|
+
type: xhr.responseType,
|
|
371
|
+
data: xhr.response,
|
|
372
|
+
pages: pages,
|
|
373
|
+
ok: true,
|
|
374
|
+
errors: undefined,
|
|
375
|
+
message: ''
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
xhr.onerror = function () {
|
|
380
|
+
console.error('Ошибка загрузки [' + 0 + '] ' + url + ": Нет соединения с сервером");
|
|
381
|
+
return reject({
|
|
382
|
+
status: 0,
|
|
383
|
+
message: "Нет соединения с сервером"
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Получить выборку из таблицы через REST
|
|
393
|
+
* @param {string} table
|
|
394
|
+
* @param {object | string | null} where Позволяет делать выборку из связанных таблиц, надо только их указать через название таблицы sites.id=5, и указать колонку в expand
|
|
395
|
+
* @param {object | Array<string> | null} extfields
|
|
396
|
+
* @param {object | Array<string> | string | null} fields
|
|
397
|
+
* @param {object | Array<string> | string | null} sortfields
|
|
398
|
+
* @param {number | undefined} page
|
|
399
|
+
* @param {number | undefined} perPage
|
|
400
|
+
* @param {boolean | undefined} RemoveDuplicates
|
|
401
|
+
* @param {any} titles Это чтобы мы могли контроллить какие названия полей мы будет загружать при экспорте, чтобы они были как в таблице
|
|
402
|
+
* @param {any} tree дерево
|
|
403
|
+
* @param {any} params Доп параметры для кастомизации запроса на беке
|
|
404
|
+
* @param {any} exportData имя файла для экспорта
|
|
405
|
+
* @return Promise<Rows<T>>
|
|
406
|
+
*/
|
|
407
|
+
get<T>(table: string, where?: object | string | null, extfields?: object | Array<string> | null, fields?: object | Array<string> | string | null, sortfields?: object | Array<string> | string | null, page?: number, perPage?: number, RemoveDuplicates?: boolean, format?: any, titles?: any, tree?: any, params?: any, exportData?: any): Promise<Rows<T>> {
|
|
408
|
+
// Нормализуем имена таблиц
|
|
409
|
+
table = table.replace(/_/g, "");
|
|
410
|
+
let responseType: 'json' | 'blob' = "json";
|
|
411
|
+
// Генерим запрос
|
|
412
|
+
const query = this.SERVER + '/api/' + this.version + '/' + table;
|
|
413
|
+
const json: any = {};
|
|
414
|
+
// Генерим условия
|
|
415
|
+
if (where !== undefined && where !== null) json.where = where;
|
|
416
|
+
if (tree !== undefined && tree !== null) json.tree = tree;
|
|
417
|
+
if (fields !== undefined && fields !== null) json.fields = fields;
|
|
418
|
+
if (sortfields !== undefined && sortfields !== null) json.sort = sortfields;
|
|
419
|
+
if (extfields !== undefined && extfields !== null) json.extfields = extfields;
|
|
420
|
+
if (params !== undefined && params !== null) json.params = params;
|
|
421
|
+
if (RemoveDuplicates !== undefined && RemoveDuplicates !== null) json.RemoveDuplicates = true;
|
|
422
|
+
if (titles !== undefined && titles !== null) json.titles = titles;
|
|
423
|
+
// экспорт
|
|
424
|
+
if (exportData !== undefined && exportData !== null) {
|
|
425
|
+
json.export = {
|
|
426
|
+
format: exportData.format ?? 'xlsx',
|
|
427
|
+
titles: exportData.titles ?? [],
|
|
428
|
+
filename: exportData.filename ?? 'export_' + Date.now() + ".xlsx",
|
|
429
|
+
};
|
|
430
|
+
responseType = "blob";
|
|
431
|
+
}
|
|
432
|
+
// Страницы
|
|
433
|
+
json['per-page'] = perPage === undefined ? this.perPageDefault : perPage;
|
|
434
|
+
json['page'] = page === undefined ? 1 : page;
|
|
435
|
+
return this.request(query, JSON.stringify(json), 'POST', responseType) as Promise<Rows<T>>;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Получить все записи по запросу [постранично]
|
|
441
|
+
* @param {string} table
|
|
442
|
+
* @param {object} params
|
|
443
|
+
* @returns {Promise<Rows<T>>}
|
|
444
|
+
*/
|
|
445
|
+
all<T>(table: string, params?: {
|
|
446
|
+
where?: object,
|
|
447
|
+
extfields?: object | Array<string>,
|
|
448
|
+
fields?: object | Array<string>,
|
|
449
|
+
sort?: Array<string>,
|
|
450
|
+
page?: number,
|
|
451
|
+
perPage?: number,
|
|
452
|
+
tree?: number,
|
|
453
|
+
params?: any,
|
|
454
|
+
export?: {
|
|
455
|
+
format?: 'xlsx' | 'csv',
|
|
456
|
+
titles?: Array<string>,
|
|
457
|
+
filename?: string,
|
|
458
|
+
}
|
|
459
|
+
}): Promise<Rows<T>> {
|
|
460
|
+
return this.get(table, params?.where, params?.extfields, params?.fields, params?.sort, params?.page, params?.perPage, undefined, undefined, undefined, params?.tree, params?.params, params?.export);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Получить одну запись по ID или по условию выборки [первая запись]
|
|
465
|
+
* @param {string} table
|
|
466
|
+
* @param {number|string|object} IDOrWhere
|
|
467
|
+
* @param {object|Array} extfields
|
|
468
|
+
* @param {object|Array} fields
|
|
469
|
+
* @param {string} primaryKeyName если указан ID, то указать название первичного ключа, если от id он отличается
|
|
470
|
+
*/
|
|
471
|
+
async one<T>(table: string, IDOrWhere: number | string | object, extfields: object | Array<string> | null = null, fields: object | Array<string> | null = null, primaryKeyName: string = 'id'): Promise<T | null> {
|
|
472
|
+
let where: object = {};
|
|
473
|
+
if (typeof IDOrWhere === 'string' || typeof IDOrWhere === 'number') where = { [primaryKeyName]: IDOrWhere };
|
|
474
|
+
else if (typeof IDOrWhere === 'object') where = IDOrWhere;
|
|
475
|
+
else throw new Error("Нужно передавать ID или объект");
|
|
476
|
+
|
|
477
|
+
const resp = await this.get<T>(table, where, extfields, fields, null, 1, 1);
|
|
478
|
+
if (resp.errors) return Promise.reject(resp);
|
|
479
|
+
if (!resp.data || resp.data.length === 0) return null;
|
|
480
|
+
|
|
481
|
+
return resp.data[0];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Создать новую запись
|
|
486
|
+
* @param {string} table
|
|
487
|
+
* @param {object} values
|
|
488
|
+
* @param {number|string|null} appendTo
|
|
489
|
+
* @param {number|string|null} insertAfter
|
|
490
|
+
* @param {boolean|null} insertFirst
|
|
491
|
+
*/
|
|
492
|
+
async create<T>(table: string, values: object, appendTo: number | string | null = null, insertAfter: number | string | null = null, insertFirst: boolean | null = null): Promise<SavedObject<T>> {
|
|
493
|
+
// Нормализуем имена таблиц
|
|
494
|
+
table = table.replace(/_/g, "");
|
|
495
|
+
// Подготовить значения
|
|
496
|
+
if (!(values instanceof FormData)) {
|
|
497
|
+
await this.prepare(values);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/create?'
|
|
501
|
+
+ (appendTo !== null ? '&appendTo=' + appendTo : '')
|
|
502
|
+
+ (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
|
|
503
|
+
+ (insertFirst !== null ? '&insertFirst=' + insertFirst : '')
|
|
504
|
+
, (values instanceof FormData ? values : JSON.stringify(values)), 'POST') as Promise<SavedObject<T>>;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Удалить запись
|
|
509
|
+
* @param {string} table
|
|
510
|
+
* @param {number|string} id
|
|
511
|
+
* @param {object|null} byFields Если указан, удаляет по этим параметрам
|
|
512
|
+
*/
|
|
513
|
+
async remove(table: string, id: number | string = 0, byFields: object | null = null): Promise<boolean | any> {
|
|
514
|
+
// Нормализуем имена таблиц
|
|
515
|
+
table = table.replace(/_/g, "");
|
|
516
|
+
let params = {};
|
|
517
|
+
if (byFields instanceof Object) params = byFields;
|
|
518
|
+
const resp = await this.request(this.SERVER + '/api/' + this.version + '/' + table + '/delete?id=' + id, JSON.stringify(params), 'DELETE');
|
|
519
|
+
if (resp.status === 204) return true;
|
|
520
|
+
return resp;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Редактировать значения
|
|
525
|
+
* @param {string} table
|
|
526
|
+
* @param {number|string} ID
|
|
527
|
+
* @param {object} values
|
|
528
|
+
* @param {number|string|null} appendTo
|
|
529
|
+
* @param {number|string|null} insertAfter
|
|
530
|
+
* @param {boolean|null} insertFirst
|
|
531
|
+
*/
|
|
532
|
+
async edit<T>(table: string, ID: number | string, values: object, appendTo: number | string | null = null, insertAfter: number | string | null = null, insertFirst: boolean | null = null): Promise<SavedObject<T>> {
|
|
533
|
+
// Нормализуем имена таблиц
|
|
534
|
+
table = table.replace(/_/g, "");
|
|
535
|
+
// Подготовить значения
|
|
536
|
+
if (!(values instanceof FormData)) {
|
|
537
|
+
await this.prepare(values);
|
|
538
|
+
}
|
|
539
|
+
return this.request(this.SERVER + '/api/' + this.version + '/' + table + '/update?id=' + ID
|
|
540
|
+
+ (appendTo !== null ? '&appendTo=' + appendTo : '')
|
|
541
|
+
+ (insertAfter !== null ? '&insertAfter=' + insertAfter : '')
|
|
542
|
+
+ (insertFirst !== null ? '&insertFirst=' + insertFirst : '')
|
|
543
|
+
, (values instanceof FormData ? values : JSON.stringify(values)), 'POST') as Promise<SavedObject<T>>;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Получить схемы всех таблиц
|
|
548
|
+
*/
|
|
549
|
+
getCRUDInfo(): Promise<object> {
|
|
550
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
551
|
+
const cachedSchema = window.sessionStorage.getItem("crudschema");
|
|
552
|
+
if (cachedSchema) {
|
|
553
|
+
return new Promise((resolve) => { resolve(JSON.parse(cachedSchema)) });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return this.request(this.SERVER + '/site/crudschema', {}, 'GET')
|
|
557
|
+
.then(res => {
|
|
558
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
559
|
+
// Кешируем схему в браузере на время текущей сессии (в пределах ОДНОЙ вкладки)
|
|
560
|
+
window.sessionStorage.setItem("crudschema", JSON.stringify(res));
|
|
561
|
+
}
|
|
562
|
+
return res;
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Авторизоваться
|
|
568
|
+
* @param {string|undefined} username
|
|
569
|
+
* @param {string|undefined} password
|
|
570
|
+
* @param {object|string|null} pushNotificationToken
|
|
571
|
+
*/
|
|
572
|
+
async auth(username?: string, password?: string, pushNotificationToken?: object | string | null): Promise<Authorized> {
|
|
573
|
+
let resp: BaseResponse<Authorized>;
|
|
574
|
+
if (this.token && !username) {
|
|
575
|
+
resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({}), 'POST', 'json', true);
|
|
576
|
+
} else {
|
|
577
|
+
resp = await this.request(this.SERVER + '/auth/auth', JSON.stringify({ login: username, password: password, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null) }), 'POST', 'json', false);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (resp.errors || !resp.data) {
|
|
581
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// после успешной авторизации устанавливаем токен
|
|
585
|
+
if (typeof resp.data.token === 'string') this.token = resp.data.token;
|
|
586
|
+
|
|
587
|
+
// Сохраняем хеш юзера автоматически (только на клиенте)
|
|
588
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
589
|
+
const user_hash = window.localStorage.getItem('user_hash');
|
|
590
|
+
if (resp?.data?.User?.user_hash && resp.data.User.user_hash !== user_hash) {
|
|
591
|
+
window.localStorage.setItem('user_hash', resp.data.User.user_hash);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return resp.data;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Зарегистрироваться с этим логином и паролем
|
|
599
|
+
* @param {string|null} email
|
|
600
|
+
* @param {string|null} username
|
|
601
|
+
* @param {string} password
|
|
602
|
+
* @param {string|null} name
|
|
603
|
+
* @param {object|string|null} pushNotificationToken
|
|
604
|
+
* @param {any} data
|
|
605
|
+
*/
|
|
606
|
+
async signup(email: string | null, username: string | null, password: string, name: string | null = null, pushNotificationToken: object | string | null = null, data: any = null): Promise<Authorized> {
|
|
607
|
+
const resp = await this.request(this.SERVER + '/auth/signup', JSON.stringify({
|
|
608
|
+
login: username, email: email, password: password,
|
|
609
|
+
name: name, data: data, pushNotificationToken: (pushNotificationToken ?? this.pushNotificationToken ?? null)
|
|
610
|
+
}), 'POST', 'json', false);
|
|
611
|
+
|
|
612
|
+
if (resp.errors || !resp.data) {
|
|
613
|
+
return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// после успешной авторизации устанавливаем токен
|
|
617
|
+
if (typeof resp.data.token === 'string') this.token = resp.data.token;
|
|
618
|
+
|
|
619
|
+
return resp.data;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Выйти из системы
|
|
624
|
+
*/
|
|
625
|
+
logout(): Promise<object> {
|
|
626
|
+
return this.request(this.SERVER + '/auth/logout', '{}', 'POST');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Восстановление пароля
|
|
631
|
+
* Запрос на восстановление пароля
|
|
632
|
+
* @param {*} email
|
|
633
|
+
* @returns
|
|
634
|
+
*/
|
|
635
|
+
async ResetPasswordRequest(email: string): Promise<any> {
|
|
636
|
+
const resp = await this.request(this.SERVER + '/auth/resetpasswordrequest', JSON.stringify({ email: email }), 'POST', 'json', false);
|
|
637
|
+
if (resp.errors || !resp.data) return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
638
|
+
return resp.data;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Восстановление пароля
|
|
643
|
+
* Проверка токена восстановления
|
|
644
|
+
* @param {*} token токен подтверждения
|
|
645
|
+
* @returns
|
|
646
|
+
*/
|
|
647
|
+
async ResetPasswordTokenCheck(token: string): Promise<any> {
|
|
648
|
+
const resp = await this.request(this.SERVER + '/auth/resettokencheck', JSON.stringify({ token: token }), 'POST', 'json', false);
|
|
649
|
+
if (resp.errors || !resp.data) return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
650
|
+
return resp.data;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Восстановление пароля
|
|
655
|
+
* Сохранение нового пароля
|
|
656
|
+
* @param {*} token
|
|
657
|
+
* @param {*} password
|
|
658
|
+
* @returns
|
|
659
|
+
*/
|
|
660
|
+
async ResetPasswordSaveNewPassword(token: string, password: string): Promise<any> {
|
|
661
|
+
const resp = await this.request(this.SERVER + '/auth/resetconfirm', JSON.stringify({ token: token, password: password }), 'POST', 'json', false);
|
|
662
|
+
if (resp.errors || !resp.data) return Promise.reject(resp.errors ?? new Error("No data received"));
|
|
663
|
+
return resp.data;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Подготовить объект под загрузку: загрузить данные из элементов Input [type=file] / Clipboard / DataTransfer [Drag&Drop/Clipboard]
|
|
668
|
+
* @param {object} values
|
|
669
|
+
*/
|
|
670
|
+
async prepare(values: { [key: string]: any }, asFormData = false): Promise<any> {
|
|
671
|
+
|
|
672
|
+
// В SSR этот метод не будет обрабатывать файлы, т.к. DOM и File API недоступны.
|
|
673
|
+
if (typeof window === 'undefined') {
|
|
674
|
+
return values;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Если в один из параметров передан FileList или input[type=file], т.е. нужно загрузить файлы
|
|
678
|
+
const formData = new FormData();
|
|
679
|
+
|
|
680
|
+
for (let val in values) {
|
|
681
|
+
// Пустые значения нам не нужны
|
|
682
|
+
if (values[val] === undefined || values[val] === null) continue;
|
|
683
|
+
|
|
684
|
+
// Любой вариант захода делаем массивом
|
|
685
|
+
let valuesArr: any[] = Array.isArray(values[val]) ? values[val] : [values[val]];
|
|
686
|
+
|
|
687
|
+
// Идём по каждому значению в массиве
|
|
688
|
+
for (let valueKey in valuesArr) {
|
|
689
|
+
let value = valuesArr[valueKey];
|
|
690
|
+
// Преобразуем
|
|
691
|
+
let isRef = false;
|
|
692
|
+
if (value instanceof Object
|
|
693
|
+
&& value.hasOwnProperty('_value')
|
|
694
|
+
&& (
|
|
695
|
+
value._value instanceof Event ||
|
|
696
|
+
value._value instanceof HTMLInputElement ||
|
|
697
|
+
value._value instanceof ClipboardEvent ||
|
|
698
|
+
value._value instanceof DataTransfer ||
|
|
699
|
+
value._value instanceof FileList
|
|
700
|
+
)
|
|
701
|
+
) { value = value.value; isRef = true }
|
|
702
|
+
|
|
703
|
+
if (value instanceof Event && value.target instanceof HTMLInputElement && value.target.type === 'file') value = value.target.files;
|
|
704
|
+
if (value instanceof HTMLInputElement && value.type === 'file') value = value.files;
|
|
705
|
+
if (value instanceof ClipboardEvent) value = value.clipboardData?.files;
|
|
706
|
+
if (value instanceof DataTransfer) value = value.files;
|
|
707
|
+
|
|
708
|
+
if (value instanceof FileList) {
|
|
709
|
+
const newValues: any[] = [];
|
|
710
|
+
const files = Array.from(value);
|
|
711
|
+
for (let index = 0; index < files.length; index++) {
|
|
712
|
+
formData.append(val + "[]", files[index]);
|
|
713
|
+
newValues.push({
|
|
714
|
+
'name': files[index].name,
|
|
715
|
+
'data': asFormData ? null : await this.readFileAsync(files[index]),
|
|
716
|
+
'number': index,
|
|
717
|
+
'id': this.generateID(32)
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (Array.isArray(values[val])) {
|
|
722
|
+
values[val].splice(parseInt(valueKey), 1, ...newValues);
|
|
723
|
+
} else {
|
|
724
|
+
values[val] = newValues;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
formData.append("json", JSON.stringify(values));
|
|
730
|
+
return asFormData ? formData : values;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Прочесть файл асинхронно
|
|
736
|
+
* @param {File} file
|
|
737
|
+
* @param {'data' | 'text'} readAs
|
|
738
|
+
* @returns {Promise<string>}
|
|
739
|
+
*/
|
|
740
|
+
readFileAsync(file: File, readAs: 'data' | 'text' = 'data'): Promise<string | ArrayBuffer | null> {
|
|
741
|
+
return new Promise((resolve, reject) => {
|
|
742
|
+
let reader = new FileReader();
|
|
743
|
+
reader.onloadend = () => { resolve(reader.result); };
|
|
744
|
+
reader.onerror = reject;
|
|
745
|
+
|
|
746
|
+
if (readAs === 'data') reader.readAsDataURL(file);
|
|
747
|
+
if (readAs === 'text') reader.readAsText(file);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Заполнить существующий объект пришедшими из БД данными
|
|
753
|
+
* сохраняя при этом оригинальные классы и функции
|
|
754
|
+
* @param {*} object
|
|
755
|
+
* @param {*} values
|
|
756
|
+
*/
|
|
757
|
+
fillObject<T extends object>(object: T, values: any): T {
|
|
758
|
+
for (let prop in object) {
|
|
759
|
+
if (!values.hasOwnProperty(prop)) continue;
|
|
760
|
+
if (typeof values[prop] === 'object' && typeof (object as any)[prop] === 'object' && values[prop] !== null && (object as any)[prop] !== null) {
|
|
761
|
+
this.fillObject((object as any)[prop], values[prop]);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
(object as any)[prop] = values[prop];
|
|
765
|
+
}
|
|
766
|
+
return object;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
generateID(length: number): string {
|
|
770
|
+
let result = '';
|
|
771
|
+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
772
|
+
const charactersLength = characters.length;
|
|
773
|
+
let counter = 0;
|
|
774
|
+
while (counter < length) {
|
|
775
|
+
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
|
776
|
+
counter += 1;
|
|
777
|
+
}
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
*
|
|
783
|
+
* @param {string} data
|
|
784
|
+
* @param {string} key
|
|
785
|
+
* @returns
|
|
786
|
+
*/
|
|
787
|
+
async hmac_sha256(message: BodyInit | null | undefined, secret_key: string): Promise<string> {
|
|
788
|
+
|
|
789
|
+
if (message === null || message === undefined) message = "";
|
|
790
|
+
if (typeof message !== 'string') message = JSON.stringify(message);
|
|
791
|
+
|
|
792
|
+
const enc = new TextEncoder();
|
|
793
|
+
const encodedKey = enc.encode(secret_key);
|
|
794
|
+
const encodedMessage = enc.encode(message);
|
|
795
|
+
|
|
796
|
+
const data = encodedMessage;
|
|
797
|
+
const key = encodedKey;
|
|
798
|
+
|
|
799
|
+
if (typeof window === 'undefined') {
|
|
800
|
+
const hash = createHmac('sha256', key).update(data).digest('hex');
|
|
801
|
+
return Promise.resolve(hash);
|
|
802
|
+
} else {
|
|
803
|
+
|
|
804
|
+
if (typeof window.crypto?.subtle === 'undefined') throw new Error("Can`t create hash");
|
|
805
|
+
|
|
806
|
+
return window.crypto.subtle
|
|
807
|
+
.importKey(
|
|
808
|
+
'raw',
|
|
809
|
+
key,
|
|
810
|
+
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
|
811
|
+
false,
|
|
812
|
+
['sign', 'verify']
|
|
813
|
+
)
|
|
814
|
+
.then(key => window.crypto.subtle.sign('HMAC', key, data))
|
|
815
|
+
.then(signature => {
|
|
816
|
+
const signatureArray = new Uint8Array(signature);
|
|
817
|
+
return Array.from(signatureArray)
|
|
818
|
+
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
819
|
+
.join('');
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Экземпляр-синглтон класса FLAMEREST.
|
|
828
|
+
* Используйте его для всех запросов в приложении.
|
|
829
|
+
* `import REST from './REST.ts';`
|
|
830
|
+
*/
|
|
831
|
+
const REST = new FLAMEREST();
|
|
832
|
+
|
|
833
|
+
if (typeof window !== 'undefined') {
|
|
834
|
+
(window as any).REST = REST;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export default REST;
|
package/docs/docs.html
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Документация по компоненту FlameRestTable</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.6; color: #333; max-width: 900px; margin: 0 auto; padding: 20px; background-color: #f9f9f9; }
|
|
9
|
+
h1, h2, h3 { color: #2c3e50; border-bottom: 2px solid #eaecef; padding-bottom: 0.3em; }
|
|
10
|
+
h1 { font-size: 2.5em; }
|
|
11
|
+
h2 { font-size: 2em; }
|
|
12
|
+
h3 { font-size: 1.5em; }
|
|
13
|
+
code { background-color: #e8e8e8; padding: 0.2em 0.4em; margin: 0; font-size: 85%; border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
|
|
14
|
+
pre { background-color: #f6f8fa; border: 1px solid #ddd; border-radius: 3px; padding: 16px; overflow: auto; }
|
|
15
|
+
pre code { padding: 0; margin: 0; background-color: transparent; border: none; }
|
|
16
|
+
.prop-name { font-weight: bold; font-family: monospace; }
|
|
17
|
+
.prop-type { font-style: italic; color: #e96900; }
|
|
18
|
+
.prop-default { color: #999; }
|
|
19
|
+
.prop-desc { margin-top: 5px; margin-left: 20px;}
|
|
20
|
+
.prop-block { margin-bottom: 20px; border-left: 3px solid #ccc; padding-left: 15px; }
|
|
21
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 1em;}
|
|
22
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
23
|
+
th { background-color: #f2f2f2; }
|
|
24
|
+
.required { color: #d04437; font-weight: bold; }
|
|
25
|
+
.note { background-color: #fff9e6; border-left: 4px solid #ffc107; padding: 10px 15px; margin: 1em 0; }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
|
|
30
|
+
<h1>Документация по компоненту FlameRestTable</h1>
|
|
31
|
+
|
|
32
|
+
<p>Этот документ описывает, как использовать Vue-компонент <code>FlameRestTable</code> для создания мощных и настраиваемых таблиц с поддержкой REST API на базе библиотеки <code>flamerest</code>.</p>
|
|
33
|
+
|
|
34
|
+
<h2>Содержание</h2>
|
|
35
|
+
<ul>
|
|
36
|
+
<li><a href="#setup">Установка и настройка</a></li>
|
|
37
|
+
<li><a href="#basic-usage">Базовое использование</a></li>
|
|
38
|
+
<li><a href="#props">Props компонента</a></li>
|
|
39
|
+
<li><a href="#table-opts">Конфигурация (TableOpts)</a></li>
|
|
40
|
+
<li><a href="#column-opts">Конфигурация колонок (IColumn)</a></li>
|
|
41
|
+
<li><a href="#slots">Слоты</a></li>
|
|
42
|
+
<li><a href="#methods">Методы</a></li>
|
|
43
|
+
<li><a href="#example">Полный пример</a></li>
|
|
44
|
+
</ul>
|
|
45
|
+
|
|
46
|
+
<h2 id="setup">Установка и настройка</h2>
|
|
47
|
+
<p>Для использования компонента, сперва установите его из npm:</p>
|
|
48
|
+
<pre><code>npm install flameresttable flamerest</code></pre>
|
|
49
|
+
<p>Затем импортируйте компонент <code>FlameRestTable</code> и класс опций <code>TableOpts</code> в вашем Vue-компоненте.</p>
|
|
50
|
+
<pre><code class="language-vue"><template>
|
|
51
|
+
<div>
|
|
52
|
+
<FlameRestTable
|
|
53
|
+
:model="UserModel"
|
|
54
|
+
:opts="tableOpts"
|
|
55
|
+
ref="flameTable"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<script setup>
|
|
61
|
+
import { ref } from 'vue';
|
|
62
|
+
import { FlameRestTable, TableOpts } from 'flameresttable';
|
|
63
|
+
import UserModel from './models/User'; // Пример пути до вашей REST-модели, она должна быть унаследована от REST.Model из flamerest
|
|
64
|
+
|
|
65
|
+
const flameTable = ref(null); // Ref для доступа к экземпляру таблицы
|
|
66
|
+
|
|
67
|
+
const tableOpts = new TableOpts();
|
|
68
|
+
// ... настройка tableOpts
|
|
69
|
+
</script></code></pre>
|
|
70
|
+
|
|
71
|
+
<h2 id="basic-usage">Базовое использование</h2>
|
|
72
|
+
<p>Для отображения таблицы достаточно передать модель данных и, при необходимости, объект с опциями.</p>
|
|
73
|
+
<pre><code class="language-javascript">// UserModel.ts - Пример модели
|
|
74
|
+
// Модель должна быть унаследована от REST.Model из библиотеки `flamerest`.
|
|
75
|
+
import REST from 'flamerest';
|
|
76
|
+
|
|
77
|
+
export default class User extends REST.Model {
|
|
78
|
+
static resourceName = 'users';
|
|
79
|
+
static primaryKeys = ['id'];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ParentComponent.vue
|
|
83
|
+
import { FlameRestTable, TableOpts } from 'flameresttable';
|
|
84
|
+
import User from './models/User';
|
|
85
|
+
|
|
86
|
+
const tableOpts = new TableOpts();
|
|
87
|
+
tableOpts.set('id', { title: 'ID' });
|
|
88
|
+
tableOpts.set('username', { title: 'Имя пользователя' });
|
|
89
|
+
tableOpts.set('email', { title: 'Email' });
|
|
90
|
+
</code></pre>
|
|
91
|
+
<p>В шаблоне:</p>
|
|
92
|
+
<pre><code class="language-vue"><FlameRestTable :model="User" :opts="tableOpts" /></code></pre>
|
|
93
|
+
<p>Этого достаточно, чтобы отрендерить таблицу с данными из модели <code>User</code>, которая автоматически сделает запрос на бэкенд.</p>
|
|
94
|
+
|
|
95
|
+
<h2 id="props">Props компонента</h2>
|
|
96
|
+
<div class="prop-block">
|
|
97
|
+
<div class="prop-name">model <span class="required">*</span></div>
|
|
98
|
+
<div class="prop-type">Function (Class)</div>
|
|
99
|
+
<div class="prop-desc">Класс модели данных, который будет использоваться для запросов к REST API. Модель должна быть унаследована от <code>REST.Model</code> из пакета <code>flamerest</code>.</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="prop-block">
|
|
102
|
+
<div class="prop-name">opts</div>
|
|
103
|
+
<div class="prop-type">TableOpts</div>
|
|
104
|
+
<div class="prop-default">new TableOpts()</div>
|
|
105
|
+
<div class="prop-desc">Объект с настройками таблицы. Создается как экземпляр класса <code>TableOpts</code>, импортированного из <code>flameresttable</code>.</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="prop-block">
|
|
108
|
+
<div class="prop-name">rows</div>
|
|
109
|
+
<div class="prop-type">Array</div>
|
|
110
|
+
<div class="prop-default">[]</div>
|
|
111
|
+
<div class="prop-desc">Используется для первоначальной инициализации, но в основном компонент сам загружает данные через модель.</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<h2 id="table-opts">Конфигурация (TableOpts)</h2>
|
|
115
|
+
<p>Класс <code>TableOpts</code> предоставляет широкие возможности для настройки поведения и внешнего вида таблицы.</p>
|
|
116
|
+
|
|
117
|
+
<h3>Основные опции</h3>
|
|
118
|
+
<ul>
|
|
119
|
+
<li><span class="prop-name">autoload</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Загружать ли данные автоматически при монтировании компонента.</li>
|
|
120
|
+
<li><span class="prop-name">rowSelectors</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Показывать ли чекбоксы для выбора строк.</li>
|
|
121
|
+
<li><span class="prop-name">displayMode</span>: <span class="prop-type">'table' | 'custom'</span> (default: <code>'table'</code>) - Режим отображения. <code>'table'</code> для стандартной таблицы, <code>'custom'</code> для кастомного рендеринга через слот <code>CustomRow</code>.</li>
|
|
122
|
+
<li><span class="prop-name">onRowClickOpenSlot</span>: <span class="prop-type">'popup' | 'rowspace' | 'none'</span> (default: <code>'rowspace'</code>) - Что делать при клике на строку. <code>'rowspace'</code> открывает под-пространство (слот <code>RowSubSlot</code>), <code>'popup'</code> открывает модальное окно редактирования, <code>'none'</code> ничего не делает.</li>
|
|
123
|
+
<li><span class="prop-name">lang</span>: <span class="prop-type">'ru' | 'en'</span> (default: <code>'ru'</code>) - Язык для встроенных текстовых элементов.</li>
|
|
124
|
+
<li><span class="prop-name">LoadParams</span>: <span class="prop-type">ITableLoadParams</span> (default: <code>{}</code>) - Стандартные параметры для запроса данных (например, <code>{ expand: ['profile'] }</code>).</li>
|
|
125
|
+
</ul>
|
|
126
|
+
|
|
127
|
+
<h3>Кнопки и действия</h3>
|
|
128
|
+
<ul>
|
|
129
|
+
<li><span class="prop-name">Add</span>: <span class="prop-type">object</span> - Настройки кнопки "Добавить".
|
|
130
|
+
<ul>
|
|
131
|
+
<li><code>can</code>: <span class="prop-type">boolean</span> (default: <code>true</code>)</li>
|
|
132
|
+
<li><code>buttonTitle</code>: <span class="prop-type">string</span> (default: <code>'Добавить'</code>)</li>
|
|
133
|
+
</ul>
|
|
134
|
+
</li>
|
|
135
|
+
<li><span class="prop-name">Edit</span>: <span class="prop-type">object</span> - Настройки кнопки "Изменить".
|
|
136
|
+
<ul><li><code>can</code>: <span class="prop-type">boolean</span> (default: <code>true</code>)</li></ul>
|
|
137
|
+
</li>
|
|
138
|
+
<li><span class="prop-name">Remove</span>: <span class="prop-type">object</span> - Настройки кнопки "Удалить".
|
|
139
|
+
<ul><li><code>can</code>: <span class="prop-type">boolean</span> (default: <code>true</code>)</li></ul>
|
|
140
|
+
</li>
|
|
141
|
+
<li><span class="prop-name">Export</span>: <span class="prop-type">object</span> - Настройки экспорта.
|
|
142
|
+
<ul>
|
|
143
|
+
<li><code>isShow</code>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Показать выпадающий список с действиями экспорта.</li>
|
|
144
|
+
<li><code>CSV.can</code>: <span class="prop-type">boolean</span> (default: <code>false</code>) - Показать отдельную кнопку экспорта в CSV.</li>
|
|
145
|
+
</ul>
|
|
146
|
+
</li>
|
|
147
|
+
</ul>
|
|
148
|
+
|
|
149
|
+
<h3>Пагинация</h3>
|
|
150
|
+
<ul>
|
|
151
|
+
<li><span class="prop-name">Pagination.type</span>: <span class="prop-type">'scrollable' | 'pages'</span> (default: <code>'pages'</code>) - Тип пагинации. <code>'pages'</code> - классическая, <code>'scrollable'</code> - бесконечная прокрутка.</li>
|
|
152
|
+
</ul>
|
|
153
|
+
|
|
154
|
+
<h3>Обработчики событий (Callbacks)</h3>
|
|
155
|
+
<ul>
|
|
156
|
+
<li><span class="prop-name">onBeforeUpdate</span>: <span class="prop-type">(filters: object, isExport: boolean) => boolean</span> - Функция, вызываемая перед каждым запросом данных. Если вернет <code>false</code>, запрос будет отменен.</li>
|
|
157
|
+
<li><span class="prop-name">customDownloadHandler</span>: <span class="prop-type">(data: Blob, filename: string, mime: string) => void</span> - Кастомный обработчик для скачивания файлов экспорта.</li>
|
|
158
|
+
<li><span class="prop-name">Popup.load</span>: <span class="prop-type">async (row, table) => void</span> - Вызывается при открытии попапа редактирования, для дополнительной загрузки данных.</li>
|
|
159
|
+
<li><span class="prop-name">Popup.beforeEdit</span>: <span class="prop-type">async (row, table) => boolean</span> - Вызывается перед сохранением изменений. Если вернет <code>false</code>, сохранение будет отменено.</li>
|
|
160
|
+
<li><span class="prop-name">Popup.beforeAdd</span>: <span class="prop-type">async (row, table) => boolean</span> - Аналогично для добавления новой записи.</li>
|
|
161
|
+
</ul>
|
|
162
|
+
|
|
163
|
+
<h3>Методы для конфигурации колонок</h3>
|
|
164
|
+
<ul>
|
|
165
|
+
<li><span class="prop-name">set(name, opts)</span>: Устанавливает опции для колонки.</li>
|
|
166
|
+
<li><span class="prop-name">delete(name | names)</span>: Скрывает колонку(и) из всех представлений (таблица, фильтры, попап).</li>
|
|
167
|
+
<li><span class="prop-name">addVirtual(name, opts)</span>: Добавляет "виртуальную" колонку, которая не существует в модели, но может использоваться для кастомных фильтров или отображения.</li>
|
|
168
|
+
</ul>
|
|
169
|
+
|
|
170
|
+
<h2 id="column-opts">Конфигурация колонок (IColumn)</h2>
|
|
171
|
+
<p>Опции для каждой колонки задаются через метод <code>opts.set('columnName', { ... })</code>. Каждая колонка может иметь множество настроек, сгруппированных по областям применения.</p>
|
|
172
|
+
|
|
173
|
+
<h3>Общие</h3>
|
|
174
|
+
<ul>
|
|
175
|
+
<li><span class="prop-name">title</span>: <span class="prop-type">string</span> - Глобальный заголовок для колонки.</li>
|
|
176
|
+
</ul>
|
|
177
|
+
|
|
178
|
+
<h3><code>Table</code> - Отображение в таблице</h3>
|
|
179
|
+
<ul>
|
|
180
|
+
<li><span class="prop-name">isShow</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Показывать ли колонку в таблице.</li>
|
|
181
|
+
<li><span class="prop-name">title</span>: <span class="prop-type">string</span> - Заголовок колонки в таблице (переопределяет общий <code>title</code>).</li>
|
|
182
|
+
<li><span class="prop-name">value</span>: <span class="prop-type">(row, column) => string</span> - Функция для форматирования значения в ячейке.</li>
|
|
183
|
+
<li><span class="prop-name">isRawValue</span>: <span class="prop-type">boolean</span> (default: <code>false</code>) - Если <code>true</code>, результат функции <code>value</code> будет вставлен как HTML (осторожно, XSS!).</li>
|
|
184
|
+
<li><span class="prop-name">click</span>: <span class="prop-type">(row, column) => void</span> - Обработчик клика по ячейке.</li>
|
|
185
|
+
<li><span class="prop-name">classes</span> / <span class="prop-name">classesHeader</span>: <span class="prop-type">string</span> - CSS классы для ячеек и заголовка.</li>
|
|
186
|
+
<li><span class="prop-name">width</span>: <span class="prop-type">number</span> - Ширина колонки в пикселях.</li>
|
|
187
|
+
</ul>
|
|
188
|
+
|
|
189
|
+
<h3><code>Filter</code> - Фильтрация</h3>
|
|
190
|
+
<ul>
|
|
191
|
+
<li><span class="prop-name">isShow</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Показывать ли фильтр для этой колонки.</li>
|
|
192
|
+
<li><span class="prop-name">type</span>: <span class="prop-type">"text" | "fixed" | "fulltext" | "number" | "date" | "daterange" | "selector"</span> - Тип фильтра.</li>
|
|
193
|
+
<li><span class="prop-name">selector.multiselect</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Для типа <code>selector</code>, разрешить ли множественный выбор.</li>
|
|
194
|
+
<li><span class="prop-name">selector.mode</span>: <span class="prop-type">'vertical' | 'horizontal'</span> (default: <code>'vertical'</code>) - Режим отображения селектора.</li>
|
|
195
|
+
</ul>
|
|
196
|
+
|
|
197
|
+
<h3><code>Popup</code> - Попап добавления/редактирования</h3>
|
|
198
|
+
<ul>
|
|
199
|
+
<li><span class="prop-name">isShow</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Показывать ли поле в попапе.</li>
|
|
200
|
+
<li><span class="prop-name">isEnabled</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Доступно ли поле для редактирования.</li>
|
|
201
|
+
<li><span class="prop-name">title</span> / <span class="prop-name">desc</span> / <span class="prop-name">placeholder</span>: <span class="prop-type">string</span> - Заголовки и подсказки.</li>
|
|
202
|
+
<li><span class="prop-name">popupType</span>: <span class="prop-type">"string" | "text" | "button" | "date" | "selector" | "image" | "file"</span> - Тип поля в форме.</li>
|
|
203
|
+
<li><span class="prop-name">isSendFromAdd</span> / <span class="prop-name">isSendFromEdit</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Отправлять ли значение поля при создании/редактировании.</li>
|
|
204
|
+
</ul>
|
|
205
|
+
|
|
206
|
+
<h3><code>Selector</code> - Для связанных данных</h3>
|
|
207
|
+
<p>Эта секция используется для всех селекторов: в фильтрах, в попапах и для замены ID на читаемые значения в таблице.</p>
|
|
208
|
+
<ul>
|
|
209
|
+
<li><span class="prop-name">model</span>: <span class="prop-type">Class</span> - Модель, из которой будут автоматически загружаться значения для селектора.</li>
|
|
210
|
+
<li><span class="prop-name">loader</span>: <span class="prop-type">(rows?) => ITableSelectorItem[]</span> - Альтернатива `model`, функция для ручной загрузки значений.</li>
|
|
211
|
+
<li><span class="prop-name">values</span>: <span class="prop-type">ITableSelectorItem[]</span> - Жестко заданный список значений.</li>
|
|
212
|
+
<li><span class="prop-name">preload</span>: <span class="prop-type">boolean</span> (default: <code>true</code>) - Предзагружать ли значения селектора при инициализации таблицы.</li>
|
|
213
|
+
</ul>
|
|
214
|
+
|
|
215
|
+
<h2 id="slots">Слоты</h2>
|
|
216
|
+
<p>Компонент предоставляет несколько слотов для кастомизации.</p>
|
|
217
|
+
<table border="1" cellpadding="5" cellspacing="0">
|
|
218
|
+
<thead>
|
|
219
|
+
<tr>
|
|
220
|
+
<th>Имя слота</th>
|
|
221
|
+
<th>Параметры</th>
|
|
222
|
+
<th>Описание</th>
|
|
223
|
+
</tr>
|
|
224
|
+
</thead>
|
|
225
|
+
<tbody>
|
|
226
|
+
<tr>
|
|
227
|
+
<td><code>defaultButtons</code></td>
|
|
228
|
+
<td>-</td>
|
|
229
|
+
<td>Для добавления кнопок слева от стандартных (Добавить, Экспорт) на панели задач.</td>
|
|
230
|
+
</tr>
|
|
231
|
+
<tr>
|
|
232
|
+
<td><code>otherButtons</code></td>
|
|
233
|
+
<td>-</td>
|
|
234
|
+
<td>Для добавления кнопок на панели задач после стандартных.</td>
|
|
235
|
+
</tr>
|
|
236
|
+
<tr>
|
|
237
|
+
<td><code>TableHeaders</code></td>
|
|
238
|
+
<td><code>columns</code></td>
|
|
239
|
+
<td>Позволяет полностью переопределить заголовок таблицы.</td>
|
|
240
|
+
</tr>
|
|
241
|
+
<tr>
|
|
242
|
+
<td><code>Row</code></td>
|
|
243
|
+
<td><code>row</code></td>
|
|
244
|
+
<td>Позволяет полностью переопределить рендеринг строки таблицы.</td>
|
|
245
|
+
</tr>
|
|
246
|
+
<tr>
|
|
247
|
+
<td><code>RowSubSlot</code></td>
|
|
248
|
+
<td><code>row</code></td>
|
|
249
|
+
<td>Содержимое, которое отображается в под-пространстве при клике на строку (если <code>onRowClickOpenSlot: 'rowspace'</code>).</td>
|
|
250
|
+
</tr>
|
|
251
|
+
<tr>
|
|
252
|
+
<td><code>CustomRow</code></td>
|
|
253
|
+
<td><code>row, edit, remove, table</code></td>
|
|
254
|
+
<td>Используется для рендеринга каждой "строки", когда <code>displayMode: 'custom'</code>. Предоставляет функции для редактирования и удаления.</td>
|
|
255
|
+
</tr>
|
|
256
|
+
<tr>
|
|
257
|
+
<td><code>header</code></td>
|
|
258
|
+
<td>-</td>
|
|
259
|
+
<td>Для кастомизации заголовка модального окна добавления/редактирования.</td>
|
|
260
|
+
</tr>
|
|
261
|
+
</tbody>
|
|
262
|
+
</table>
|
|
263
|
+
|
|
264
|
+
<h2 id="methods">Методы</h2>
|
|
265
|
+
<p>Для вызова методов необходимо получить доступ к экземпляру компонента через <code>ref</code>.</p>
|
|
266
|
+
<pre><code class="language-vue"><FlameRestTable ref="flameTable" ... />
|
|
267
|
+
|
|
268
|
+
<script setup>
|
|
269
|
+
import { ref } from 'vue';
|
|
270
|
+
const flameTable = ref(null);
|
|
271
|
+
|
|
272
|
+
function someAction() {
|
|
273
|
+
// Экземпляр FlameTable доступен через flameTable.value.Table
|
|
274
|
+
flameTable.value.Table.update(); // Обновить данные в таблице
|
|
275
|
+
}
|
|
276
|
+
</script></code></pre>
|
|
277
|
+
|
|
278
|
+
<p>Основные доступные методы в <code>flameTable.value.Table</code>:</p>
|
|
279
|
+
<ul>
|
|
280
|
+
<li><span class="prop-name">update(params?, exportFilename?, from?)</span> - Запросить данные с сервера и обновить таблицу.</li>
|
|
281
|
+
<li><span class="prop-name">add()</span> - Программно открыть окно добавления новой записи.</li>
|
|
282
|
+
<li><span class="prop-name">save()</span> - Программно сохранить изменения в открытом окне редактирования.</li>
|
|
283
|
+
<li><span class="prop-name">remove(row)</span> - Удалить указанную строку.</li>
|
|
284
|
+
<li><span class="prop-name">getSelectedRows()</span> - Получить массив выбранных строк.</li>
|
|
285
|
+
<li><span class="prop-name">exportToXLS(onlySelected?)</span> - Экспортировать данные в XLSX.</li>
|
|
286
|
+
<li><span class="prop-name">exportToCSV(filename?, arr?)</span> - Экспортировать данные в CSV.</li>
|
|
287
|
+
</ul>
|
|
288
|
+
|
|
289
|
+
<h2 id="example">Полный пример</h2>
|
|
290
|
+
<p>Пример с настройкой нескольких колонок, фильтров, селекторов и использованием слота.</p>
|
|
291
|
+
|
|
292
|
+
<pre><code class="language-javascript">// component.vue
|
|
293
|
+
import { FlameRestTable, TableOpts } from 'flameresttable';
|
|
294
|
+
import User from '@/models/User';
|
|
295
|
+
import Role from '@/models/Role'; // Предположим, есть модель ролей
|
|
296
|
+
|
|
297
|
+
const tableOpts = new TableOpts();
|
|
298
|
+
|
|
299
|
+
// Скрываем ненужные колонки
|
|
300
|
+
tableOpts.delete(['auth_key', 'password_hash', 'password_reset_token']);
|
|
301
|
+
|
|
302
|
+
// Настраиваем колонку ID
|
|
303
|
+
tableOpts.set('id', {
|
|
304
|
+
title: 'ID',
|
|
305
|
+
Table: { width: 80 },
|
|
306
|
+
Filter: { type: 'number' }
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Настраиваем имя
|
|
310
|
+
tableOpts.set('username', {
|
|
311
|
+
title: 'Пользователь',
|
|
312
|
+
Table: {
|
|
313
|
+
isRawValue: true,
|
|
314
|
+
value: (row) => `<b>${row.username}</b>` // Пример с HTML
|
|
315
|
+
},
|
|
316
|
+
Filter: { type: 'text' }
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Настраиваем email
|
|
320
|
+
tableOpts.set('email', {
|
|
321
|
+
title: 'Email',
|
|
322
|
+
Filter: { isShow: false } // Скрываем фильтр для email
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Настраиваем статус
|
|
326
|
+
tableOpts.set('status', {
|
|
327
|
+
title: 'Статус',
|
|
328
|
+
Filter: {
|
|
329
|
+
type: 'selector',
|
|
330
|
+
selector: { multiselect: false } // одиночный выбор
|
|
331
|
+
},
|
|
332
|
+
Selector: {
|
|
333
|
+
values: [ // Жестко заданные значения
|
|
334
|
+
{ id: 10, title: 'Активен' },
|
|
335
|
+
{ id: 9, title: 'Неактивен' },
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Настраиваем связанную колонку ролей
|
|
341
|
+
tableOpts.set('role_id', {
|
|
342
|
+
title: 'Роль',
|
|
343
|
+
Filter: { type: 'selector' },
|
|
344
|
+
Selector: {
|
|
345
|
+
model: Role // Автозагрузка из модели Role
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Добавляем виртуальную колонку для фильтра по дате
|
|
350
|
+
tableOpts.addVirtual('date_range', {
|
|
351
|
+
title: 'Период регистрации',
|
|
352
|
+
Filter: {
|
|
353
|
+
type: 'daterange'
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
</code></pre>
|
|
357
|
+
|
|
358
|
+
<pre><code class="language-vue"><!-- template -->
|
|
359
|
+
<FlameRestTable :model="User" :opts="tableOpts">
|
|
360
|
+
<template #otherButtons>
|
|
361
|
+
<button class="custom-button" @click="showSelected">Показать выбранных</button>
|
|
362
|
+
</template>
|
|
363
|
+
</FlameRestTable>
|
|
364
|
+
</code></pre>
|
|
365
|
+
|
|
366
|
+
</body>
|
|
367
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flamerest",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.120",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "REST.js",
|
|
6
6
|
"typings": "./REST.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"
|
|
8
|
+
"publishx": "git push && npm --no-git-tag-version version patch && npm publish"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|