@andrey4emk/npm-app-front-b24 0.3.0 → 0.4.0
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/ConfigLogs.vue +9 -3
- package/README.md +47 -28
- package/Subscriptions.vue +486 -192
- package/package.json +25 -9
- package/types/ConfigLogs.vue.d.ts +33 -0
- package/types/Subscriptions.vue.d.ts +167 -0
- package/index.d.ts +0 -39
package/Subscriptions.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="Subscriptions subscriptions">
|
|
3
|
-
<h1>Страница подписок</h1>
|
|
3
|
+
<h1 class="subscriptions__title">Страница подписок</h1>
|
|
4
4
|
|
|
5
5
|
<!-- Без клиента Bitrix24 компонент не работает, но и не падает -->
|
|
6
6
|
<p v-if="!b24Client" class="subscriptions__empty">Клиент Bitrix24 не инициализирован</p>
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
<div v-if="tabs.tab1">
|
|
16
16
|
<div>
|
|
17
17
|
<button @click="loadOfflineEntitiesList" :disabled="loading">Обновить</button>
|
|
18
|
-
<!-- Тумблер
|
|
18
|
+
<!-- Тумблер автообновления. Имена switch, slider, tooltip, tooltipParent оставлены
|
|
19
|
+
навсегда как точки подключения для стилей потребителей: своих правил у пакета
|
|
20
|
+
на них нет, их вешают b24_change_entity, b24_desktop и b24_conector -->
|
|
19
21
|
<label class="subscriptions__switch subscriptions__tooltip-parent switch tooltipParent">
|
|
20
22
|
<input type="checkbox" v-model="autoRefresh" @change="autoRefreshEntities" />
|
|
21
23
|
<span class="subscriptions__slider slider"></span>
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
<span>Всего измененных сущностей {{ entities.total }}</span>
|
|
35
37
|
</div>
|
|
36
38
|
|
|
37
|
-
<table class="main" v-if="entities.list.length">
|
|
39
|
+
<table class="main subscriptions__table" v-if="entities.list.length">
|
|
38
40
|
<thead>
|
|
39
41
|
<tr>
|
|
40
42
|
<th>#</th>
|
|
@@ -71,6 +73,7 @@
|
|
|
71
73
|
{{ entity.EVENT_DATA }}
|
|
72
74
|
</td>
|
|
73
75
|
<td>{{ entity.EVENT_ADDITIONAL?.user_id }}</td>
|
|
76
|
+
<!-- tooltipParent / tooltip — только точки подключения для стилей потребителя -->
|
|
74
77
|
<td class="subscriptions__tooltip-parent tooltipParent">
|
|
75
78
|
<span>Подробней</span>
|
|
76
79
|
<span class="subscriptions__tooltip tooltip"> {{ entity }}</span>
|
|
@@ -88,8 +91,8 @@
|
|
|
88
91
|
<div v-else-if="message" class="subscriptions__message subscriptions__message--success">{{ message }}</div>
|
|
89
92
|
|
|
90
93
|
<div>
|
|
91
|
-
<h3>Подписаны на события:</h3>
|
|
92
|
-
<table class="main" v-if="subscribedEvents.length">
|
|
94
|
+
<h3 class="subscriptions__subtitle">Подписаны на события:</h3>
|
|
95
|
+
<table class="main subscriptions__table" v-if="subscribedEvents.length">
|
|
93
96
|
<thead>
|
|
94
97
|
<tr>
|
|
95
98
|
<th>Событие</th>
|
|
@@ -120,8 +123,8 @@
|
|
|
120
123
|
</div>
|
|
121
124
|
|
|
122
125
|
<div>
|
|
123
|
-
<h3>На какие события можно подписаться:</h3>
|
|
124
|
-
<table class="main" v-if="events.length">
|
|
126
|
+
<h3 class="subscriptions__subtitle">На какие события можно подписаться:</h3>
|
|
127
|
+
<table class="main subscriptions__table" v-if="events.length">
|
|
125
128
|
<thead>
|
|
126
129
|
<tr>
|
|
127
130
|
<th>Событие</th>
|
|
@@ -131,7 +134,7 @@
|
|
|
131
134
|
<tbody>
|
|
132
135
|
<tr v-for="event in events" :key="event">
|
|
133
136
|
<td>{{ event }}</td>
|
|
134
|
-
<td><button @click="subscribeToEvents(event)" :disabled="busy || isSubscribed(event)" :class="{ 'disabled
|
|
137
|
+
<td><button @click="subscribeToEvents(event)" :disabled="busy || isSubscribed(event)" :class="{ 'subscriptions__button--disabled': isSubscribed(event) }">Подписаться</button></td>
|
|
135
138
|
</tr>
|
|
136
139
|
</tbody>
|
|
137
140
|
</table>
|
|
@@ -152,30 +155,81 @@ const BATCH_CHUNK_SIZE = 50;
|
|
|
152
155
|
// Потолок итераций цикла очистки очереди — страховка от вечного цикла
|
|
153
156
|
const MAX_CLEAR_ITERATIONS = 100;
|
|
154
157
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
+
// Настройки формата dd.MM HH:mm:ss. Указан только hourCycle: при hour12: false
|
|
159
|
+
// часть движков отдаёт 24 вместо 00 для полуночи
|
|
160
|
+
const DATE_FORMAT_OPTIONS = {
|
|
158
161
|
day: "2-digit",
|
|
159
162
|
month: "2-digit",
|
|
160
163
|
hour: "2-digit",
|
|
161
164
|
minute: "2-digit",
|
|
162
165
|
second: "2-digit",
|
|
163
166
|
hourCycle: "h23",
|
|
164
|
-
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Кэш форматтеров по зоне: Intl.DateTimeFormat дорого конструировать на каждую строку
|
|
170
|
+
// таблицы. Ключ "" — зона браузера. Кэш модульный, поэтому общий для всех экземпляров
|
|
171
|
+
const DATE_FORMATTER_CACHE = new Map();
|
|
172
|
+
|
|
173
|
+
function createDateFormatter(timeZone) {
|
|
174
|
+
const options = timeZone ? { ...DATE_FORMAT_OPTIONS, timeZone: timeZone } : DATE_FORMAT_OPTIONS;
|
|
175
|
+
return new Intl.DateTimeFormat("ru-RU", options);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Форматтер для зоны IANA. Неизвестная зона роняет конструктор Intl.DateTimeFormat,
|
|
180
|
+
* поэтому отказ ловится и подменяется зоной браузера: строка таблицы важнее точной зоны.
|
|
181
|
+
* Возвращает null, если Intl недоступен вовсе.
|
|
182
|
+
*/
|
|
183
|
+
function getDateFormatter(timeZone) {
|
|
184
|
+
// trim: зона с пробелами по краям роняла Intl и плодила лишний ключ кэша
|
|
185
|
+
const key = String(timeZone || "").trim();
|
|
186
|
+
if (DATE_FORMATTER_CACHE.has(key)) return DATE_FORMATTER_CACHE.get(key);
|
|
187
|
+
|
|
188
|
+
let formatter = null;
|
|
189
|
+
if (key) {
|
|
190
|
+
try {
|
|
191
|
+
formatter = createDateFormatter(key);
|
|
192
|
+
} catch (zoneError) {
|
|
193
|
+
console.error(`Неизвестная временная зона "${key}", даты показываются в зоне браузера:`, zoneError);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!formatter) {
|
|
197
|
+
// Откат на зону браузера. Отдельный ключ "" в кэше, поэтому лишний объект не создаётся
|
|
198
|
+
if (DATE_FORMATTER_CACHE.has("")) {
|
|
199
|
+
formatter = DATE_FORMATTER_CACHE.get("");
|
|
200
|
+
} else {
|
|
201
|
+
try {
|
|
202
|
+
formatter = createDateFormatter("");
|
|
203
|
+
} catch (formatterError) {
|
|
204
|
+
console.error("Не удалось создать формат даты:", formatterError);
|
|
205
|
+
formatter = null;
|
|
206
|
+
}
|
|
207
|
+
DATE_FORMATTER_CACHE.set("", formatter);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
DATE_FORMATTER_CACHE.set(key, formatter);
|
|
211
|
+
return formatter;
|
|
212
|
+
}
|
|
165
213
|
|
|
166
214
|
/**
|
|
167
215
|
* Приводит метку времени к виду dd.MM HH:mm:ss.
|
|
168
216
|
* Склейка через formatToParts: обычный format() в ru-RU ставит запятую между датой и временем.
|
|
169
217
|
*/
|
|
170
|
-
function formatTimestamp(value) {
|
|
218
|
+
function formatTimestamp(value, formatter) {
|
|
171
219
|
if (!value) return "";
|
|
172
220
|
const date = new Date(value);
|
|
173
221
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
parts
|
|
222
|
+
if (!formatter) return String(value);
|
|
223
|
+
try {
|
|
224
|
+
const parts = {};
|
|
225
|
+
for (const part of formatter.formatToParts(date)) {
|
|
226
|
+
parts[part.type] = part.value;
|
|
227
|
+
}
|
|
228
|
+
return `${parts.day}.${parts.month} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
229
|
+
} catch (formatError) {
|
|
230
|
+
console.error("Не удалось отформатировать дату:", formatError);
|
|
231
|
+
return String(value);
|
|
177
232
|
}
|
|
178
|
-
return `${parts.day}.${parts.month} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
179
233
|
}
|
|
180
234
|
|
|
181
235
|
/**
|
|
@@ -193,11 +247,15 @@ export default {
|
|
|
193
247
|
name: "Subscriptions",
|
|
194
248
|
props: {
|
|
195
249
|
// Клиент Bitrix24 (B24Frame). Приоритетный источник
|
|
196
|
-
b24: { type: Object,
|
|
250
|
+
b24: { type: /** @type {import("vue").PropType<object | null>} */ (Object), required: false },
|
|
197
251
|
// Значение auth_connector для методов работы с событиями
|
|
198
252
|
authConnector: { type: String, default: "" },
|
|
199
253
|
// Переопределение адреса портала: принимается и https://portal.bitrix24.ru, и голый portal.bitrix24.ru
|
|
200
254
|
portalDomain: { type: String, default: "" },
|
|
255
|
+
// Часовой пояс для дат таблицы, строка IANA вида "Europe/Moscow".
|
|
256
|
+
// Пустая строка — зона браузера. Неизвестная зона не роняет рендер:
|
|
257
|
+
// компонент откатывается на зону браузера и пишет причину в консоль
|
|
258
|
+
timeZone: { type: String, default: "" },
|
|
201
259
|
},
|
|
202
260
|
inject: {
|
|
203
261
|
// Тот же ключ, под которым потребители кладут клиент через app.provide
|
|
@@ -222,6 +280,9 @@ export default {
|
|
|
222
280
|
message: "",
|
|
223
281
|
refreshTimerId: null,
|
|
224
282
|
isUnmounted: false,
|
|
283
|
+
// Признак того, что текущий текст в error поставлен неудачной загрузкой списка.
|
|
284
|
+
// Только такую ошибку удачное автообновление имеет право погасить
|
|
285
|
+
loadFailed: false,
|
|
225
286
|
// Какая кнопка ждёт второго клика: "" | "all" | "empty"
|
|
226
287
|
confirmAction: "",
|
|
227
288
|
confirmTimerId: null,
|
|
@@ -266,6 +327,12 @@ export default {
|
|
|
266
327
|
confirmEmptyLabel() {
|
|
267
328
|
return this.confirmAction === "empty" ? "Точно удалить?" : "Удалить все пустые";
|
|
268
329
|
},
|
|
330
|
+
// Форматтер дат под текущую зону. Computed, а не вызов в методе:
|
|
331
|
+
// так зависимость от prop timeZone видна реактивности явно,
|
|
332
|
+
// а поиск в кэше делается один раз на смену зоны, а не на каждую ячейку
|
|
333
|
+
dateFormatter() {
|
|
334
|
+
return getDateFormatter(this.timeZone);
|
|
335
|
+
},
|
|
269
336
|
},
|
|
270
337
|
methods: {
|
|
271
338
|
selectTab(name) {
|
|
@@ -274,7 +341,7 @@ export default {
|
|
|
274
341
|
}
|
|
275
342
|
},
|
|
276
343
|
formatDate(timestamp) {
|
|
277
|
-
return formatTimestamp(timestamp);
|
|
344
|
+
return formatTimestamp(timestamp, this.dateFormatter);
|
|
278
345
|
},
|
|
279
346
|
pause(ms) {
|
|
280
347
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -285,14 +352,31 @@ export default {
|
|
|
285
352
|
clearFeedback() {
|
|
286
353
|
this.error = "";
|
|
287
354
|
this.message = "";
|
|
355
|
+
this.loadFailed = false;
|
|
288
356
|
},
|
|
289
357
|
showError(text) {
|
|
290
358
|
this.error = String(text);
|
|
291
359
|
this.message = "";
|
|
360
|
+
// Владение плашкой переходит к вызвавшей операции; ветки загрузки
|
|
361
|
+
// ставят loadFailed уже после вызова showError
|
|
362
|
+
this.loadFailed = false;
|
|
292
363
|
},
|
|
293
364
|
showMessage(text) {
|
|
294
365
|
this.message = String(text);
|
|
295
366
|
this.error = "";
|
|
367
|
+
this.loadFailed = false;
|
|
368
|
+
},
|
|
369
|
+
/**
|
|
370
|
+
* Клиент Bitrix24 или null. При отсутствии клиента сам показывает ошибку —
|
|
371
|
+
* вызывающему остаётся только выйти
|
|
372
|
+
*/
|
|
373
|
+
ensureClient() {
|
|
374
|
+
const b24 = this.b24Client;
|
|
375
|
+
if (!b24) {
|
|
376
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return b24;
|
|
296
380
|
},
|
|
297
381
|
// Текст исключения
|
|
298
382
|
errorText(err) {
|
|
@@ -317,6 +401,22 @@ export default {
|
|
|
317
401
|
const text = texts.filter((item) => item).join("; ");
|
|
318
402
|
return text || "портал не вернул текст ошибки";
|
|
319
403
|
},
|
|
404
|
+
// Тексты ошибок по ключам команд batch: { clear_3: "...", clear_7: "..." }
|
|
405
|
+
resultErrorsByKey(result) {
|
|
406
|
+
try {
|
|
407
|
+
if (result && typeof result.getErrorMessagesByKey === "function") {
|
|
408
|
+
const byKey = result.getErrorMessagesByKey() || {};
|
|
409
|
+
const normalized = {};
|
|
410
|
+
for (const [key, messages] of Object.entries(byKey)) {
|
|
411
|
+
normalized[key] = [].concat(messages).filter((item) => item).join(", ");
|
|
412
|
+
}
|
|
413
|
+
return normalized;
|
|
414
|
+
}
|
|
415
|
+
} catch (readError) {
|
|
416
|
+
console.error("Не удалось прочитать ошибки batch по ключам:", readError);
|
|
417
|
+
}
|
|
418
|
+
return {};
|
|
419
|
+
},
|
|
320
420
|
|
|
321
421
|
// --- Подтверждение вторым кликом вместо confirm() ---
|
|
322
422
|
|
|
@@ -367,11 +467,8 @@ export default {
|
|
|
367
467
|
// --- Работа с событиями Bitrix24 ---
|
|
368
468
|
|
|
369
469
|
async eventsList() {
|
|
370
|
-
const b24 = this.
|
|
371
|
-
if (!b24)
|
|
372
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
470
|
+
const b24 = this.ensureClient();
|
|
471
|
+
if (!b24) return;
|
|
375
472
|
try {
|
|
376
473
|
const result = await b24.callMethod("events", {});
|
|
377
474
|
// Сравнение с false намеренно явное и повторяется во всех вызовах ниже:
|
|
@@ -390,11 +487,8 @@ export default {
|
|
|
390
487
|
},
|
|
391
488
|
|
|
392
489
|
async subscribeToEvents(event) {
|
|
393
|
-
const b24 = this.
|
|
394
|
-
if (!b24)
|
|
395
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
490
|
+
const b24 = this.ensureClient();
|
|
491
|
+
if (!b24) return;
|
|
398
492
|
this.clearFeedback();
|
|
399
493
|
this.busy = true;
|
|
400
494
|
try {
|
|
@@ -419,15 +513,15 @@ export default {
|
|
|
419
513
|
this.showError(`Ошибка при подписке на событие ${event}: ${this.errorText(callError)}`);
|
|
420
514
|
} finally {
|
|
421
515
|
this.busy = false;
|
|
516
|
+
// Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
|
|
517
|
+
// цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
|
|
518
|
+
this.startAutoRefresh();
|
|
422
519
|
}
|
|
423
520
|
},
|
|
424
521
|
|
|
425
522
|
async getSubscribedEvents() {
|
|
426
|
-
const b24 = this.
|
|
427
|
-
if (!b24)
|
|
428
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
523
|
+
const b24 = this.ensureClient();
|
|
524
|
+
if (!b24) return;
|
|
431
525
|
try {
|
|
432
526
|
const result = await b24.callMethod("event.get", {});
|
|
433
527
|
if (result.isSuccess === false) {
|
|
@@ -442,11 +536,8 @@ export default {
|
|
|
442
536
|
},
|
|
443
537
|
|
|
444
538
|
async unbindSubscribedEvents(event) {
|
|
445
|
-
const b24 = this.
|
|
446
|
-
if (!b24)
|
|
447
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
539
|
+
const b24 = this.ensureClient();
|
|
540
|
+
if (!b24) return;
|
|
450
541
|
this.clearFeedback();
|
|
451
542
|
this.busy = true;
|
|
452
543
|
try {
|
|
@@ -467,13 +558,18 @@ export default {
|
|
|
467
558
|
this.showError(`Ошибка при отписке от события ${event.event}: ${this.errorText(callError)}`);
|
|
468
559
|
} finally {
|
|
469
560
|
this.busy = false;
|
|
561
|
+
// Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
|
|
562
|
+
// цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
|
|
563
|
+
this.startAutoRefresh();
|
|
470
564
|
}
|
|
471
565
|
},
|
|
472
566
|
|
|
473
567
|
async loadOfflineEntitiesList() {
|
|
474
|
-
const b24 = this.
|
|
568
|
+
const b24 = this.ensureClient();
|
|
569
|
+
// Порядок обязателен: showError внутри ensureClient сбрасывает loadFailed,
|
|
570
|
+
// поэтому флаг ставится после проверки
|
|
475
571
|
if (!b24) {
|
|
476
|
-
this.
|
|
572
|
+
this.loadFailed = true;
|
|
477
573
|
return;
|
|
478
574
|
}
|
|
479
575
|
this.loading = true;
|
|
@@ -486,14 +582,22 @@ export default {
|
|
|
486
582
|
if (result.isSuccess === false) {
|
|
487
583
|
// Старый список полезнее пустого — entities.list не обнуляем
|
|
488
584
|
this.showError(`Не удалось получить список сущностей: ${this.resultErrors(result)}`);
|
|
585
|
+
this.loadFailed = true;
|
|
489
586
|
return;
|
|
490
587
|
}
|
|
491
588
|
const rows = result.getData() || [];
|
|
492
589
|
this.entities.list = rows;
|
|
493
590
|
this.entities.total = rows.length;
|
|
591
|
+
// Гасим только свою ошибку: message не трогаем, иначе удачное автообновление
|
|
592
|
+
// сотрёт «Запись N удалена из очереди», поставленное секунду назад
|
|
593
|
+
if (this.loadFailed) {
|
|
594
|
+
this.error = "";
|
|
595
|
+
this.loadFailed = false;
|
|
596
|
+
}
|
|
494
597
|
} catch (callError) {
|
|
495
598
|
console.error("Ошибка при получении списка оффлайн-сущностей:", callError);
|
|
496
599
|
this.showError(`Ошибка при получении списка сущностей: ${this.errorText(callError)}`);
|
|
600
|
+
this.loadFailed = true;
|
|
497
601
|
} finally {
|
|
498
602
|
this.loading = false;
|
|
499
603
|
}
|
|
@@ -503,11 +607,8 @@ export default {
|
|
|
503
607
|
* Снимает одну запись из оффлайн-очереди. Возвращает признак успеха.
|
|
504
608
|
*/
|
|
505
609
|
async deleteOfflineEntities(entity) {
|
|
506
|
-
const b24 = this.
|
|
507
|
-
if (!b24)
|
|
508
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
509
|
-
return false;
|
|
510
|
-
}
|
|
610
|
+
const b24 = this.ensureClient();
|
|
611
|
+
if (!b24) return false;
|
|
511
612
|
// event.offline.clear работает только по process_id: для свободной записи вызов бессмысленный
|
|
512
613
|
if (!entity?.PROCESS_ID) {
|
|
513
614
|
this.showError("У записи пустой PROCESS_ID — снимается кнопкой «Удалить все пустые»");
|
|
@@ -536,16 +637,127 @@ export default {
|
|
|
536
637
|
return false;
|
|
537
638
|
} finally {
|
|
538
639
|
this.busy = false;
|
|
640
|
+
// Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
|
|
641
|
+
// цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
|
|
642
|
+
this.startAutoRefresh();
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Читает страницу очереди. Возвращает { rows, error }: непустой error означает,
|
|
648
|
+
* что проход надо оборвать
|
|
649
|
+
*/
|
|
650
|
+
async readOfflineQueuePage(b24) {
|
|
651
|
+
// callListMethod проходит очередь целиком: страница из 50 записей с пустым
|
|
652
|
+
// PROCESS_ID иначе закрывает собой зарезервированные пачки и цикл встаёт
|
|
653
|
+
const pageResult = await b24.callListMethod("event.offline.list", {
|
|
654
|
+
start: 0,
|
|
655
|
+
auth_connector: this.authConnector,
|
|
656
|
+
});
|
|
657
|
+
if (pageResult.isSuccess === false) {
|
|
658
|
+
return { rows: [], error: this.resultErrors(pageResult) };
|
|
659
|
+
}
|
|
660
|
+
return { rows: pageResult.getData() || [], error: "" };
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Уникальные PROCESS_ID страницы и те из них, что ещё не сняты.
|
|
665
|
+
* Чистая функция от аргументов, поэтому её удобно звать и из отчёта
|
|
666
|
+
*/
|
|
667
|
+
collectProcessIds(rows, clearedProcessIds) {
|
|
668
|
+
// Дедупликация обязательна: очередь обычно принадлежит нескольким пачкам
|
|
669
|
+
const processIds = [...new Set(rows.map((row) => row.PROCESS_ID).filter((id) => id))];
|
|
670
|
+
// Пачки, снятые на прошлых итерациях, портал может отдавать повторно
|
|
671
|
+
const freshProcessIds = processIds.filter((processId) => !clearedProcessIds.has(processId));
|
|
672
|
+
return { processIds, freshProcessIds };
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Команды batch для одной пачки PROCESS_ID
|
|
677
|
+
*/
|
|
678
|
+
buildClearCommands(chunk) {
|
|
679
|
+
const commands = {};
|
|
680
|
+
chunk.forEach((processId, index) => {
|
|
681
|
+
commands[`clear_${index}`] = {
|
|
682
|
+
method: "event.offline.clear",
|
|
683
|
+
params: {
|
|
684
|
+
process_id: processId,
|
|
685
|
+
auth_connector: this.authConnector,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
return commands;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Отправляет один batch и пополняет множество снятых пачек.
|
|
694
|
+
* Возвращает текст неудачи или "" — пачка снята целиком
|
|
695
|
+
*/
|
|
696
|
+
async sendClearChunk(b24, chunk, clearedProcessIds) {
|
|
697
|
+
const commands = this.buildClearCommands(chunk);
|
|
698
|
+
// isHaltOnError: false — одна протухшая пачка не должна валить остальные
|
|
699
|
+
const batchResult = await b24.callBatch(commands, false);
|
|
700
|
+
|
|
701
|
+
// SDK роняет isSuccess всего результата от одной упавшей подкоманды,
|
|
702
|
+
// а в данные кладёт только удачные. Поэтому снятой считается пачка,
|
|
703
|
+
// чей ключ есть в getData(), а не вся пачка по общему флагу.
|
|
704
|
+
// Признаком успеха служит наличие ключа команды в данных batch,
|
|
705
|
+
// значение под ключом не проверяется
|
|
706
|
+
const batchData = typeof batchResult?.getData === "function" ? batchResult.getData() : null;
|
|
707
|
+
const hasKeyedData = !!batchData && typeof batchData === "object" && !Array.isArray(batchData);
|
|
708
|
+
const errorsByKey = hasKeyedData ? this.resultErrorsByKey(batchResult) : {};
|
|
709
|
+
// Причина отказа всего конверта: при мягком отказе SDK 2.x кладёт ошибки
|
|
710
|
+
// под base-error и пустую карту данных, а в SDK 1.x метода
|
|
711
|
+
// getErrorMessagesByKey нет вовсе — по ключу команды тогда пусто
|
|
712
|
+
const fallbackReason = this.resultErrors(batchResult);
|
|
713
|
+
const notCleared = [];
|
|
714
|
+
|
|
715
|
+
chunk.forEach((processId, index) => {
|
|
716
|
+
const key = `clear_${index}`;
|
|
717
|
+
if (!hasKeyedData) {
|
|
718
|
+
// Шим-клиент потребителя без пооперационных данных:
|
|
719
|
+
// судим по флагу всего batch, как до правки
|
|
720
|
+
if (batchResult?.isSuccess === false) notCleared.push({ processId, key });
|
|
721
|
+
else clearedProcessIds.add(processId);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (Object.prototype.hasOwnProperty.call(batchData, key)) {
|
|
725
|
+
clearedProcessIds.add(processId);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
notCleared.push({ processId, key });
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
if (!notCleared.length) return "";
|
|
732
|
+
// В текст идут первые три пачки: при отказе всех пятидесяти
|
|
733
|
+
// сообщение иначе не помещается на экран
|
|
734
|
+
const details = notCleared
|
|
735
|
+
.slice(0, 3)
|
|
736
|
+
.map(({ processId, key }) => `${processId}: ${errorsByKey[key] || fallbackReason}`);
|
|
737
|
+
const tail = notCleared.length > 3 ? `, и ещё ${notCleared.length - 3}` : "";
|
|
738
|
+
return `не снято пачек ${notCleared.length} (${details.join("; ")}${tail})`;
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Итоговый отчёт прохода. Возвращает { kind: "error" | "message", text }
|
|
743
|
+
*/
|
|
744
|
+
buildClearReport({ failures, reachedLimit, stoppedOnRepeat, stoppedOnEmpty, clearedCount }) {
|
|
745
|
+
if (failures.length) {
|
|
746
|
+
return { kind: "error", text: `Очищено пачек: ${clearedCount}, проход остановлен. Ошибки: ${failures.join("; ")}` };
|
|
747
|
+
} else if (reachedLimit) {
|
|
748
|
+
return { kind: "error", text: `Достигнут потолок в ${MAX_CLEAR_ITERATIONS} итераций, очередь очищена не полностью. Очищено пачек: ${clearedCount}` };
|
|
749
|
+
} else if (stoppedOnRepeat) {
|
|
750
|
+
return { kind: "error", text: `Очищено пачек: ${clearedCount}. Остановка: ${stoppedOnRepeat}` };
|
|
751
|
+
} else if (stoppedOnEmpty) {
|
|
752
|
+
return { kind: "message", text: `Очищено пачек: ${clearedCount}. ${stoppedOnEmpty}` };
|
|
539
753
|
}
|
|
754
|
+
return { kind: "message", text: `Очищено пачек: ${clearedCount}` };
|
|
540
755
|
},
|
|
541
756
|
|
|
542
757
|
async deleteOfflineEntitiesAll() {
|
|
543
758
|
if (!this.requestConfirm("all")) return;
|
|
544
|
-
const b24 = this.
|
|
545
|
-
if (!b24)
|
|
546
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
759
|
+
const b24 = this.ensureClient();
|
|
760
|
+
if (!b24) return;
|
|
549
761
|
this.clearFeedback();
|
|
550
762
|
this.busy = true;
|
|
551
763
|
// Фоновое обновление конкурирует с удалением и мигает таблицей
|
|
@@ -553,7 +765,8 @@ export default {
|
|
|
553
765
|
this.stopAutoRefresh();
|
|
554
766
|
|
|
555
767
|
const failures = [];
|
|
556
|
-
// Пачки,
|
|
768
|
+
// Пачки, снятие которых портал подтвердил ключом в данных batch:
|
|
769
|
+
// размер множества — честный счётчик для отчёта
|
|
557
770
|
const clearedProcessIds = new Set();
|
|
558
771
|
let reachedLimit = false;
|
|
559
772
|
let stoppedOnEmpty = "";
|
|
@@ -561,58 +774,35 @@ export default {
|
|
|
561
774
|
let iteration = 0;
|
|
562
775
|
try {
|
|
563
776
|
for (; iteration < MAX_CLEAR_ITERATIONS; iteration++) {
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
auth_connector: this.authConnector,
|
|
569
|
-
});
|
|
570
|
-
if (pageResult.isSuccess === false) {
|
|
571
|
-
failures.push(this.resultErrors(pageResult));
|
|
777
|
+
// 1. Очередная страница очереди
|
|
778
|
+
const page = await this.readOfflineQueuePage(b24);
|
|
779
|
+
if (page.error) {
|
|
780
|
+
failures.push(page.error);
|
|
572
781
|
break;
|
|
573
782
|
}
|
|
574
|
-
|
|
575
|
-
if (!rows.length) break;
|
|
783
|
+
if (!page.rows.length) break;
|
|
576
784
|
|
|
577
|
-
//
|
|
578
|
-
const processIds =
|
|
785
|
+
// 2. Что на странице вообще можно снять
|
|
786
|
+
const { processIds, freshProcessIds } = this.collectProcessIds(page.rows, clearedProcessIds);
|
|
579
787
|
if (!processIds.length) {
|
|
580
788
|
// Свободные записи этим методом не снимаются — иначе цикл крутится до потолка итераций
|
|
581
|
-
stoppedOnEmpty = `Остались записи с пустым PROCESS_ID (${rows.length}) — снимаются кнопкой «Удалить все пустые»`;
|
|
789
|
+
stoppedOnEmpty = `Остались записи с пустым PROCESS_ID (${page.rows.length}) — снимаются кнопкой «Удалить все пустые»`;
|
|
582
790
|
break;
|
|
583
791
|
}
|
|
584
|
-
|
|
585
|
-
// Пачки, снятые на прошлых итерациях, портал может отдавать повторно
|
|
586
|
-
const freshProcessIds = processIds.filter((processId) => !clearedProcessIds.has(processId));
|
|
587
792
|
if (!freshProcessIds.length) {
|
|
588
|
-
stoppedOnRepeat =
|
|
793
|
+
stoppedOnRepeat =
|
|
794
|
+
"портал снова отдал уже снятые пачки. Команды clear отработали без ошибок, скорее всего очередь обновляется с задержкой — обновите список через минуту. Если записи останутся, значит clear их не снял";
|
|
589
795
|
break;
|
|
590
796
|
}
|
|
591
797
|
|
|
592
|
-
// Резка по 50: в один batch больше команд платформа не принимает
|
|
798
|
+
// 3. Резка по 50: в один batch больше команд платформа не принимает
|
|
593
799
|
for (const chunk of chunkArray(freshProcessIds, BATCH_CHUNK_SIZE)) {
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
commands[`clear_${index}`] = {
|
|
597
|
-
method: "event.offline.clear",
|
|
598
|
-
params: {
|
|
599
|
-
process_id: processId,
|
|
600
|
-
auth_connector: this.authConnector,
|
|
601
|
-
},
|
|
602
|
-
};
|
|
603
|
-
});
|
|
604
|
-
// isHaltOnError: false — одна протухшая пачка не должна валить остальные
|
|
605
|
-
const batchResult = await b24.callBatch(commands, false);
|
|
606
|
-
if (batchResult.isSuccess === false) {
|
|
607
|
-
failures.push(this.resultErrors(batchResult));
|
|
608
|
-
} else {
|
|
609
|
-
// Снятой пачка считается только после подтверждённого batch
|
|
610
|
-
for (const processId of chunk) clearedProcessIds.add(processId);
|
|
611
|
-
}
|
|
800
|
+
const failure = await this.sendClearChunk(b24, chunk, clearedProcessIds);
|
|
801
|
+
if (failure) failures.push(failure);
|
|
612
802
|
}
|
|
613
|
-
// Если пачка не удаляется, следующая итерация увидит те же PROCESS_ID
|
|
803
|
+
// 4. Если пачка не удаляется, следующая итерация увидит те же PROCESS_ID
|
|
614
804
|
if (failures.length) break;
|
|
615
|
-
// Портал отдаёт очередь с задержкой — перечитывать сразу бессмысленно
|
|
805
|
+
// 5. Портал отдаёт очередь с задержкой — перечитывать сразу бессмысленно
|
|
616
806
|
await this.pause(1000);
|
|
617
807
|
}
|
|
618
808
|
reachedLimit = iteration >= MAX_CLEAR_ITERATIONS;
|
|
@@ -624,17 +814,15 @@ export default {
|
|
|
624
814
|
if (wasAutoRefresh) this.startAutoRefresh();
|
|
625
815
|
}
|
|
626
816
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
this.showMessage(`Очищено пачек: ${clearedProcessIds.size}`);
|
|
637
|
-
}
|
|
817
|
+
const report = this.buildClearReport({
|
|
818
|
+
failures: failures,
|
|
819
|
+
reachedLimit: reachedLimit,
|
|
820
|
+
stoppedOnRepeat: stoppedOnRepeat,
|
|
821
|
+
stoppedOnEmpty: stoppedOnEmpty,
|
|
822
|
+
clearedCount: clearedProcessIds.size,
|
|
823
|
+
});
|
|
824
|
+
if (report.kind === "error") this.showError(report.text);
|
|
825
|
+
else this.showMessage(report.text);
|
|
638
826
|
|
|
639
827
|
await this.pause(500);
|
|
640
828
|
// Размонтирование могло случиться, пока шла очистка
|
|
@@ -642,23 +830,25 @@ export default {
|
|
|
642
830
|
await this.loadOfflineEntitiesList();
|
|
643
831
|
},
|
|
644
832
|
|
|
645
|
-
// Сколько записей в текущем списке без PROCESS_ID
|
|
646
|
-
countEmptyProcessId() {
|
|
647
|
-
return this.entities.list.filter((row) => !row?.PROCESS_ID).length;
|
|
648
|
-
},
|
|
649
|
-
|
|
650
833
|
async deleteOfflineEntitiesAllEmpty() {
|
|
651
834
|
if (!this.requestConfirm("empty")) return;
|
|
652
|
-
const b24 = this.
|
|
653
|
-
if (!b24)
|
|
654
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
835
|
+
const b24 = this.ensureClient();
|
|
836
|
+
if (!b24) return;
|
|
657
837
|
this.clearFeedback();
|
|
658
838
|
this.busy = true;
|
|
839
|
+
// Фоновое обновление конкурирует с удалением: симметрично deleteOfflineEntitiesAll
|
|
840
|
+
const wasAutoRefresh = this.autoRefresh;
|
|
841
|
+
this.stopAutoRefresh();
|
|
659
842
|
try {
|
|
660
|
-
//
|
|
661
|
-
|
|
843
|
+
// Множество ID свободных записей до вызова. Разница по длинам списков врала:
|
|
844
|
+
// события, пришедшие во время операции, маскировали снятые вплоть до минуса
|
|
845
|
+
const emptyIdsBefore = new Set(
|
|
846
|
+
this.entities.list
|
|
847
|
+
.filter((row) => !row?.PROCESS_ID)
|
|
848
|
+
// ID портал отдаёт то строкой, то числом — сравниваем строками
|
|
849
|
+
.map((row) => (row?.ID === undefined || row?.ID === null ? "" : String(row.ID)))
|
|
850
|
+
.filter((id) => id !== "")
|
|
851
|
+
);
|
|
662
852
|
// Снимаем записи с пустым PROCESS_ID — по process_id их не удалить
|
|
663
853
|
const result = await b24.callMethod("event.offline.clear", {
|
|
664
854
|
process_id: "",
|
|
@@ -670,19 +860,36 @@ export default {
|
|
|
670
860
|
}
|
|
671
861
|
await this.pause(500);
|
|
672
862
|
await this.loadOfflineEntitiesList();
|
|
673
|
-
// Ошибку перезагрузки не затираем: без свежего списка
|
|
863
|
+
// Ошибку перезагрузки не затираем: без свежего списка считать не по чему
|
|
674
864
|
if (this.error) return;
|
|
675
|
-
|
|
865
|
+
|
|
866
|
+
if (!emptyIdsBefore.size) {
|
|
867
|
+
// Иначе «Портал не снял ни одной» выглядит как отказ, хотя снимать было нечего
|
|
868
|
+
this.showMessage("В списке не было записей с пустым PROCESS_ID — снимать было нечего");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
// Сверяем со всем свежим списком, а не только со свободными записями:
|
|
872
|
+
// запись, которой портал за это время выдал PROCESS_ID, из очереди не ушла
|
|
873
|
+
const idsAfter = new Set(
|
|
874
|
+
this.entities.list.map((row) => (row?.ID === undefined || row?.ID === null ? "" : String(row.ID)))
|
|
875
|
+
);
|
|
876
|
+
let removedCount = 0;
|
|
877
|
+
for (const id of emptyIdsBefore) {
|
|
878
|
+
if (!idsAfter.has(id)) removedCount++;
|
|
879
|
+
}
|
|
676
880
|
if (removedCount > 0) {
|
|
677
|
-
|
|
881
|
+
// Формулировка нейтральна к авторству: часть записей мог снять
|
|
882
|
+
// серверный коллектор потребителя за паузу перед перечиткой
|
|
883
|
+
this.showMessage(`Ушло из очереди записей: ${removedCount} из ${emptyIdsBefore.size}`);
|
|
678
884
|
} else {
|
|
679
|
-
this.showMessage("
|
|
885
|
+
this.showMessage("Из очереди не ушла ни одна запись с пустым PROCESS_ID");
|
|
680
886
|
}
|
|
681
887
|
} catch (callError) {
|
|
682
888
|
console.error("Ошибка при удалении сущностей с пустым PROCESS_ID:", callError);
|
|
683
889
|
this.showError(`Ошибка при удалении записей с пустым PROCESS_ID: ${this.errorText(callError)}`);
|
|
684
890
|
} finally {
|
|
685
891
|
this.busy = false;
|
|
892
|
+
if (wasAutoRefresh) this.startAutoRefresh();
|
|
686
893
|
}
|
|
687
894
|
},
|
|
688
895
|
|
|
@@ -696,6 +903,9 @@ export default {
|
|
|
696
903
|
// Защита от двойного таймера
|
|
697
904
|
this.stopAutoRefresh();
|
|
698
905
|
if (!this.autoRefresh || this.isUnmounted) return;
|
|
906
|
+
// На скрытой вкладке цепочку не заводим: 4 запроса каждые 3 секунды съедают
|
|
907
|
+
// общий лимит REST портала впустую. Перезапустит handleVisibilityChange
|
|
908
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
699
909
|
this.refreshTimerId = setTimeout(async () => {
|
|
700
910
|
this.refreshTimerId = null;
|
|
701
911
|
if (this.isUnmounted) return;
|
|
@@ -722,15 +932,111 @@ export default {
|
|
|
722
932
|
}
|
|
723
933
|
},
|
|
724
934
|
|
|
935
|
+
// Скрытая вкладка не должна опрашивать портал. При возврате — одна загрузка,
|
|
936
|
+
// потому что за время скрытия список устарел, и только потом цепочка
|
|
937
|
+
async handleVisibilityChange() {
|
|
938
|
+
if (typeof document === "undefined") return;
|
|
939
|
+
if (document.visibilityState === "hidden") {
|
|
940
|
+
this.stopAutoRefresh();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (this.isUnmounted || !this.autoRefresh) return;
|
|
944
|
+
// Операция в работе сама гасит автообновление и сама заводит его заново
|
|
945
|
+
// в своём finally — фоновые запросы поверх массового удаления не нужны
|
|
946
|
+
if (this.busy) return;
|
|
947
|
+
await this.loadOfflineEntitiesList();
|
|
948
|
+
// Пока шла загрузка, вкладку могли снова скрыть, компонент размонтировать,
|
|
949
|
+
// а тумблер выключить
|
|
950
|
+
if (this.isUnmounted) return;
|
|
951
|
+
this.startAutoRefresh();
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Перезаписывает смарт-процесс его же заголовком.
|
|
956
|
+
* true — обновлено, false — отказ (текст ошибки уже показан)
|
|
957
|
+
*/
|
|
958
|
+
async updateSmartProcessEntity(b24, entityTypeId, entityId) {
|
|
959
|
+
const getResult = await b24.callMethod("crm.item.get", {
|
|
960
|
+
entityTypeId: entityTypeId,
|
|
961
|
+
id: entityId,
|
|
962
|
+
});
|
|
963
|
+
const item = getResult.getData()?.result?.item;
|
|
964
|
+
// Портал отдаёт идентификаторы то строкой, то числом
|
|
965
|
+
if (getResult.isSuccess === false || !item || String(item.id) !== String(entityId)) {
|
|
966
|
+
const reason = getResult.isSuccess === false ? this.resultErrors(getResult) : "портал вернул другую сущность или пустой ответ";
|
|
967
|
+
this.showError(`Не удалось получить смарт-процесс ${entityId}: ${reason}`);
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
const updateResult = await b24.callMethod("crm.item.update", {
|
|
971
|
+
entityTypeId: entityTypeId,
|
|
972
|
+
id: entityId,
|
|
973
|
+
fields: {
|
|
974
|
+
title: item.title,
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
const updatedId = updateResult.getData()?.result?.item?.id;
|
|
978
|
+
if (updateResult.isSuccess === false || String(updatedId) !== String(entityId)) {
|
|
979
|
+
const reason = updateResult.isSuccess === false ? this.resultErrors(updateResult) : "портал вернул другой идентификатор";
|
|
980
|
+
this.showError(`Не удалось обновить смарт-процесс ${entityId}: ${reason}`);
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
return true;
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Перезаписывает сделку её же заголовком.
|
|
988
|
+
* true — обновлено, false — отказ (текст ошибки уже показан)
|
|
989
|
+
*/
|
|
990
|
+
async updateDealEntity(b24, entityId) {
|
|
991
|
+
const getResult = await b24.callMethod("crm.deal.get", {
|
|
992
|
+
id: entityId,
|
|
993
|
+
});
|
|
994
|
+
const deal = getResult.getData()?.result;
|
|
995
|
+
if (getResult.isSuccess === false || !deal || String(deal.ID) !== String(entityId)) {
|
|
996
|
+
const reason = getResult.isSuccess === false ? this.resultErrors(getResult) : "портал вернул другую сделку или пустой ответ";
|
|
997
|
+
this.showError(`Не удалось получить сделку ${entityId}: ${reason}`);
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
const updateResult = await b24.callMethod("crm.deal.update", {
|
|
1001
|
+
id: entityId,
|
|
1002
|
+
fields: {
|
|
1003
|
+
TITLE: deal.TITLE,
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
// crm.deal.update возвращает { result: true }
|
|
1007
|
+
if (updateResult.getData()?.result !== true) {
|
|
1008
|
+
const reason = updateResult.isSuccess === false ? this.resultErrors(updateResult) : "портал не подтвердил обновление";
|
|
1009
|
+
this.showError(`Не удалось обновить сделку ${entityId}: ${reason}`);
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
return true;
|
|
1013
|
+
},
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Снимает обновлённую сущность из очереди и печатает итог операции целиком
|
|
1017
|
+
*/
|
|
1018
|
+
async finishEntityUpdate(eventEntity, entityId, failed) {
|
|
1019
|
+
if (!failed) {
|
|
1020
|
+
const removed = await this.deleteOfflineEntities(eventEntity);
|
|
1021
|
+
if (removed) {
|
|
1022
|
+
this.showMessage(`Сущность ${entityId} обновлена и удалена из очереди`);
|
|
1023
|
+
} else {
|
|
1024
|
+
// Оба факта в одной строке: иначе на экране остаётся только претензия
|
|
1025
|
+
// к записи очереди, и кажется, что сущность не обновилась
|
|
1026
|
+
const reason = this.error || "портал не подтвердил снятие записи";
|
|
1027
|
+
this.showError(`Сущность ${entityId} обновлена, но запись не снята из очереди: ${reason}`);
|
|
1028
|
+
}
|
|
1029
|
+
} else if (!this.error) {
|
|
1030
|
+
this.showError("Сущность не обновлена, из очереди не удалена");
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
|
|
725
1034
|
/**
|
|
726
1035
|
* Перезаписывает сущность теми же данными и снимает её из оффлайн-очереди.
|
|
727
1036
|
*/
|
|
728
1037
|
async entityUpdate(eventEntity) {
|
|
729
|
-
const b24 = this.
|
|
730
|
-
if (!b24)
|
|
731
|
-
this.showError("Клиент Bitrix24 не инициализирован");
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
1038
|
+
const b24 = this.ensureClient();
|
|
1039
|
+
if (!b24) return;
|
|
734
1040
|
const fields = eventEntity?.EVENT_DATA?.FIELDS || {};
|
|
735
1041
|
const entityId = fields.ID;
|
|
736
1042
|
if (!entityId) {
|
|
@@ -746,56 +1052,9 @@ export default {
|
|
|
746
1052
|
this.busy = true;
|
|
747
1053
|
try {
|
|
748
1054
|
if (entityTypeId) {
|
|
749
|
-
|
|
750
|
-
const getResult = await b24.callMethod("crm.item.get", {
|
|
751
|
-
entityTypeId: entityTypeId,
|
|
752
|
-
id: entityId,
|
|
753
|
-
});
|
|
754
|
-
const item = getResult.getData()?.result?.item;
|
|
755
|
-
// Портал отдаёт идентификаторы то строкой, то числом
|
|
756
|
-
if (getResult.isSuccess === false || !item || String(item.id) !== String(entityId)) {
|
|
757
|
-
failed = true;
|
|
758
|
-
const reason = getResult.isSuccess === false ? this.resultErrors(getResult) : "портал вернул другую сущность или пустой ответ";
|
|
759
|
-
this.showError(`Не удалось получить смарт-процесс ${entityId}: ${reason}`);
|
|
760
|
-
} else {
|
|
761
|
-
const updateResult = await b24.callMethod("crm.item.update", {
|
|
762
|
-
entityTypeId: entityTypeId,
|
|
763
|
-
id: entityId,
|
|
764
|
-
fields: {
|
|
765
|
-
title: item.title,
|
|
766
|
-
},
|
|
767
|
-
});
|
|
768
|
-
const updatedId = updateResult.getData()?.result?.item?.id;
|
|
769
|
-
if (updateResult.isSuccess === false || String(updatedId) !== String(entityId)) {
|
|
770
|
-
failed = true;
|
|
771
|
-
const reason = updateResult.isSuccess === false ? this.resultErrors(updateResult) : "портал вернул другой идентификатор";
|
|
772
|
-
this.showError(`Не удалось обновить смарт-процесс ${entityId}: ${reason}`);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
1055
|
+
failed = !(await this.updateSmartProcessEntity(b24, entityTypeId, entityId));
|
|
775
1056
|
} else if (eventEntity?.EVENT_NAME === "ONCRMDEALUPDATE") {
|
|
776
|
-
|
|
777
|
-
const getResult = await b24.callMethod("crm.deal.get", {
|
|
778
|
-
id: entityId,
|
|
779
|
-
});
|
|
780
|
-
const deal = getResult.getData()?.result;
|
|
781
|
-
if (getResult.isSuccess === false || !deal || String(deal.ID) !== String(entityId)) {
|
|
782
|
-
failed = true;
|
|
783
|
-
const reason = getResult.isSuccess === false ? this.resultErrors(getResult) : "портал вернул другую сделку или пустой ответ";
|
|
784
|
-
this.showError(`Не удалось получить сделку ${entityId}: ${reason}`);
|
|
785
|
-
} else {
|
|
786
|
-
const updateResult = await b24.callMethod("crm.deal.update", {
|
|
787
|
-
id: entityId,
|
|
788
|
-
fields: {
|
|
789
|
-
TITLE: deal.TITLE,
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
// crm.deal.update возвращает { result: true }
|
|
793
|
-
if (updateResult.getData()?.result !== true) {
|
|
794
|
-
failed = true;
|
|
795
|
-
const reason = updateResult.isSuccess === false ? this.resultErrors(updateResult) : "портал не подтвердил обновление";
|
|
796
|
-
this.showError(`Не удалось обновить сделку ${entityId}: ${reason}`);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
1057
|
+
failed = !(await this.updateDealEntity(b24, entityId));
|
|
799
1058
|
} else {
|
|
800
1059
|
// Ни ENTITY_TYPE_ID, ни имя события не говорят, что это за сущность —
|
|
801
1060
|
// вслепую дёргать crm.deal.get нельзя: снимется чужая запись с зелёным успехом
|
|
@@ -808,21 +1067,14 @@ export default {
|
|
|
808
1067
|
this.showError(`Ошибка при обновлении сущности ${entityId}: ${this.errorText(callError)}`);
|
|
809
1068
|
} finally {
|
|
810
1069
|
this.busy = false;
|
|
1070
|
+
// Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
|
|
1071
|
+
// цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
|
|
1072
|
+
this.startAutoRefresh();
|
|
811
1073
|
}
|
|
812
1074
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
this.showMessage(`Сущность ${entityId} обновлена и удалена из очереди`);
|
|
817
|
-
} else {
|
|
818
|
-
// Оба факта в одной строке: иначе на экране остаётся только претензия
|
|
819
|
-
// к записи очереди, и кажется, что сущность не обновилась
|
|
820
|
-
const reason = this.error || "портал не подтвердил снятие записи";
|
|
821
|
-
this.showError(`Сущность ${entityId} обновлена, но запись не снята из очереди: ${reason}`);
|
|
822
|
-
}
|
|
823
|
-
} else if (!this.error) {
|
|
824
|
-
this.showError("Сущность не обновлена, из очереди не удалена");
|
|
825
|
-
}
|
|
1075
|
+
// Хвост стоит после try/catch/finally намеренно: deleteOfflineEntities сам ставит
|
|
1076
|
+
// busy, и перенос внутрь try снял бы флаг раньше времени
|
|
1077
|
+
await this.finishEntityUpdate(eventEntity, entityId, failed);
|
|
826
1078
|
},
|
|
827
1079
|
},
|
|
828
1080
|
mounted() {
|
|
@@ -831,11 +1083,19 @@ export default {
|
|
|
831
1083
|
this.eventsList();
|
|
832
1084
|
this.loadOfflineEntitiesList();
|
|
833
1085
|
this.autoRefreshEntities();
|
|
1086
|
+
// В Vue 3 методы Options API привязаны к экземпляру один раз, ссылка стабильна,
|
|
1087
|
+
// поэтому removeEventListener снимет именно этот слушатель
|
|
1088
|
+
if (typeof document !== "undefined") {
|
|
1089
|
+
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
1090
|
+
}
|
|
834
1091
|
},
|
|
835
1092
|
beforeUnmount() {
|
|
836
1093
|
this.isUnmounted = true;
|
|
837
1094
|
this.stopAutoRefresh();
|
|
838
1095
|
this.resetConfirm();
|
|
1096
|
+
if (typeof document !== "undefined") {
|
|
1097
|
+
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
1098
|
+
}
|
|
839
1099
|
},
|
|
840
1100
|
};
|
|
841
1101
|
</script>
|
|
@@ -852,6 +1112,8 @@ export default {
|
|
|
852
1112
|
--subscriptions-error-ink: #c62828;
|
|
853
1113
|
--subscriptions-success-bg: #e8f5e9;
|
|
854
1114
|
--subscriptions-success-ink: #2e7d32;
|
|
1115
|
+
--subscriptions-table-line: #eee;
|
|
1116
|
+
--subscriptions-table-head-ink: #555;
|
|
855
1117
|
}
|
|
856
1118
|
|
|
857
1119
|
.subscriptions__tabs {
|
|
@@ -940,16 +1202,17 @@ input:checked + .subscriptions__slider::before {
|
|
|
940
1202
|
transform: translateX(20px);
|
|
941
1203
|
}
|
|
942
1204
|
|
|
943
|
-
/* Подсказки.
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1205
|
+
/* Подсказки. Имена .tooltip и .tooltipParent остаются на элементах навсегда как точки
|
|
1206
|
+
подключения для стилей потребителей (b24_change_entity, b24_desktop, b24_conector),
|
|
1207
|
+
но собственных правил пакет на них больше не вешает: правило веса 0,1,0 из пакета
|
|
1208
|
+
конфликтовало с одноимённым классом дизайн-системы b24_desktop и перекрашивало
|
|
1209
|
+
подсказки всего приложения. Оформление подсказок компонента — только .subscriptions__* */
|
|
1210
|
+
.subscriptions__tooltip-parent {
|
|
947
1211
|
position: relative; /* Чтобы подсказка позиционировалась относительно родителя */
|
|
948
1212
|
cursor: pointer;
|
|
949
1213
|
}
|
|
950
1214
|
|
|
951
|
-
.subscriptions__tooltip
|
|
952
|
-
.tooltip {
|
|
1215
|
+
.subscriptions__tooltip {
|
|
953
1216
|
visibility: hidden;
|
|
954
1217
|
background-color: var(--subscriptions-tooltip-bg, #555);
|
|
955
1218
|
color: var(--subscriptions-tooltip-ink, #fff);
|
|
@@ -968,9 +1231,40 @@ input:checked + .subscriptions__slider::before {
|
|
|
968
1231
|
word-wrap: break-word; /* Перенос слов при необходимости */
|
|
969
1232
|
}
|
|
970
1233
|
|
|
971
|
-
.subscriptions__tooltip-parent:hover .subscriptions__tooltip
|
|
972
|
-
.tooltipParent:hover .tooltip {
|
|
1234
|
+
.subscriptions__tooltip-parent:hover .subscriptions__tooltip {
|
|
973
1235
|
visibility: visible;
|
|
974
1236
|
opacity: 1;
|
|
975
1237
|
}
|
|
1238
|
+
|
|
1239
|
+
/* Таблицы. Нейтральный минимум, чтобы компонент был читаем без стилей потребителя:
|
|
1240
|
+
границы строк, отступы ячеек, серые заголовки — по образцу .config-logs__table.
|
|
1241
|
+
Вес занижен намеренно: .subscriptions__table это 0,1,0 против table.main
|
|
1242
|
+
потребителей 0,1,1, а :where() у ячеек снимает вес класса и оставляет 0,1,0
|
|
1243
|
+
против .main td у b24_desktop и b24_change_entity 0,1,1. Их оформление сильнее
|
|
1244
|
+
и продолжает побеждать; у b24_conector правила лежат в слое legacy и проигрывают
|
|
1245
|
+
любому неслоистому правилу — там компонент и получает собственный облик */
|
|
1246
|
+
.subscriptions__table {
|
|
1247
|
+
width: 100%;
|
|
1248
|
+
border-collapse: collapse;
|
|
1249
|
+
margin-bottom: 16px;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
.subscriptions__table :where(th, td) {
|
|
1253
|
+
padding: 8px 12px;
|
|
1254
|
+
text-align: left;
|
|
1255
|
+
border-bottom: 1px solid var(--subscriptions-table-line, #eee);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
.subscriptions__table :where(th) {
|
|
1259
|
+
font-weight: 600;
|
|
1260
|
+
font-size: 13px;
|
|
1261
|
+
color: var(--subscriptions-table-head-ink, #555);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* Кнопка «Подписаться» для уже подписанного события. Дублирует вид :disabled,
|
|
1265
|
+
потому что состояние приходит и туда: :disabled="busy || isSubscribed(event)" */
|
|
1266
|
+
.subscriptions__button--disabled {
|
|
1267
|
+
opacity: 0.5;
|
|
1268
|
+
cursor: default;
|
|
1269
|
+
}
|
|
976
1270
|
</style>
|