flamerest 1.0.119 → 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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "label": "publish REST JS",
6
+ "type": "shell",
7
+ "command": "npm",
8
+ "args": ["run", "publishx"]
9
+ }
10
+ ]
11
+ }
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">&lt;template&gt;
51
+ &lt;div&gt;
52
+ &lt;FlameRestTable
53
+ :model="UserModel"
54
+ :opts="tableOpts"
55
+ ref="flameTable"
56
+ /&gt;
57
+ &lt;/div&gt;
58
+ &lt;/template&gt;
59
+
60
+ &lt;script setup&gt;
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
+ &lt;/script&gt;</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">&lt;FlameRestTable :model="User" :opts="tableOpts" /&gt;</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">&lt;FlameRestTable ref="flameTable" ... /&gt;
267
+
268
+ &lt;script setup&gt;
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
+ &lt;/script&gt;</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) => `&lt;b&gt;${row.username}&lt;/b&gt;` // Пример с 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">&lt;!-- template --&gt;
359
+ &lt;FlameRestTable :model="User" :opts="tableOpts"&gt;
360
+ &lt;template #otherButtons&gt;
361
+ &lt;button class="custom-button" @click="showSelected"&gt;Показать выбранных&lt;/button&gt;
362
+ &lt;/template&gt;
363
+ &lt;/FlameRestTable&gt;
364
+ </code></pre>
365
+
366
+ </body>
367
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flamerest",
3
- "version": "1.0.119",
3
+ "version": "1.0.120",
4
4
  "description": "",
5
5
  "main": "REST.js",
6
6
  "typings": "./REST.d.ts",
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true
9
+ },
10
+ "include": [
11
+ "REST.ts"
12
+ ]
13
+ }