@andrey4emk/npm-app-front-b24 0.2.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/Subscriptions.vue CHANGED
@@ -1,142 +1,272 @@
1
1
  <template>
2
- <div class="Subscriptions">
3
- <h1>Страница подписок</h1>
4
- <button @click="selectTab('tab1')">Сущности</button>
5
- <button @click="selectTab('tab2')">События</button>
6
- <br /><br /><br />
7
-
8
- <!-- Вкладка 1 -->
9
- <div v-if="tabs.tab1">
10
- <div>
11
- <button @click="loadOfflineEntitiesList" :disabled="loading">Обновить</button>
12
- <!-- Чекбокс для запуска автообновления сущностей. При наведении выходит сообщение "автообновление каждые 3 секунды" -->
13
- <label class="switch tooltipParent" style="top: 12px">
14
- <input type="checkbox" v-model="autoRefresh" @change="autoRefreshEntities" />
15
- <span class="slider"></span>
16
- <span class="tooltip">Автообновление каждые 3 секунды</span>
17
- </label>
18
- </div>
2
+ <div class="Subscriptions subscriptions">
3
+ <h1 class="subscriptions__title">Страница подписок</h1>
19
4
 
20
- <div v-if="entities.list.length > 0">
21
- <p><button @click="deleteOfflineEntitiesAll">Удалить все</button><button @click="deleteOfflineEntitiesAllEmpty">Удалить все пустые</button> Всего измененных сущностей {{ entities.total }}</p>
22
- <table class="main" v-if="entities.list.length">
23
- <thead>
24
- <tr>
25
- <th>#</th>
26
- <th>Удалить</th>
27
- <th>Обновить</th>
28
- <th>Дата</th>
29
- <th>PROCESS_ID</th>
30
- <th>EVENT_NAME</th>
31
- <th>Ссылка</th>
32
- <th>EVENT_DATA</th>
33
- <th>who_update</th>
34
- <th>entity</th>
35
- </tr>
36
- </thead>
37
- <tbody>
38
- <tr v-for="(entity, index) in entities.list" :key="entity.PROCESS_ID + '-' + entity.ID">
39
- <td>{{ index + 1 }}</td>
40
- <!-- Удалить -->
41
- <td>
42
- <button @click="deleteOfflineEntities(entity)">Удалить</button>
43
- </td>
44
- <!-- Изменить -->
45
- <td>
46
- <button v-if="entity.EVENT_DATA.FIELDS?.ID && (entity.EVENT_NAME == 'ONCRMDEALUPDATE' || entity.EVENT_NAME.includes('ONCRMDYNAMICITEMUPDATE_'))" @click="entityUpdate(entity)">Изменить</button>
47
- </td>
48
- <td>{{ formatDate(entity.TIMESTAMP_X) }}</td>
49
- <td>{{ entity.PROCESS_ID }}</td>
50
- <td>{{ entity.EVENT_NAME }}</td>
51
- <td>
52
- <a v-if="entity.EVENT_DATA.FIELDS?.ID && entity.EVENT_NAME == 'ONCRMDEALUPDATE'" :href="'https://repacheb.bitrix24.ru/crm/deal/details/' + entity.EVENT_DATA.FIELDS?.ID + '/'" target="_blank" rel="noopener noreferrer"> {{ entity.EVENT_DATA.FIELDS?.ID }} </a>
53
- </td>
54
- <td>
55
- {{ entity.EVENT_DATA }}
56
- </td>
57
- <td>{{ entity.EVENT_ADDITIONAL.user_id }}</td>
58
- <td class="tooltipParent" style="">
59
- <span>Подробней</span>
60
- <span class="tooltip"> {{ entity }}</span>
61
- </td>
62
- </tr>
63
- </tbody>
64
- </table>
65
- </div>
66
- <div v-else>Список сущностей пуст.</div>
67
- </div>
68
-
69
- <!-- Вкладка 2 -->
70
- <div v-if="tabs.tab2">
71
- <div>
72
- <h3>Подписаны на события:</h3>
73
- <table class="main" v-if="subscribedEvents.length">
74
- <thead>
75
- <tr>
76
- <th>Событие</th>
77
- <th>connector_id</th>
78
- <th>offline</th>
79
- <th>Отписаться</th>
80
- </tr>
81
- </thead>
82
- <tbody>
83
- <tr v-for="event in subscribedEvents" :key="event.id">
84
- <td>
85
- {{ event.event }}
86
- </td>
87
- <td>
88
- {{ event.connector_id }}
89
- </td>
90
- <td>
91
- {{ event.offline }}
92
- </td>
93
- <td>
94
- <button @click="unbindSubscribedEvents(event)">Отписаться от событий</button>
95
- </td>
96
- </tr>
97
- </tbody>
98
- </table>
99
- <p v-else>Подписок на события нет.</p>
5
+ <!-- Без клиента Bitrix24 компонент не работает, но и не падает -->
6
+ <p v-if="!b24Client" class="subscriptions__empty">Клиент Bitrix24 не инициализирован</p>
7
+
8
+ <template v-else>
9
+ <div class="subscriptions__tabs">
10
+ <button @click="selectTab('tab1')" :class="{ 'subscriptions__tab--active': tabs.tab1 }">Сущности</button>
11
+ <button @click="selectTab('tab2')" :class="{ 'subscriptions__tab--active': tabs.tab2 }">События</button>
100
12
  </div>
101
13
 
102
- <div>
103
- <h3>На какие события можно подписаться:</h3>
104
- <table class="main" v-if="events.length">
105
- <thead>
106
- <tr>
107
- <th>Событие</th>
108
- <th>Подписаться</th>
109
- </tr>
110
- </thead>
111
- <tbody>
112
- <tr v-for="event in events" :key="event">
113
- <td>{{ event }}</td>
114
- <!-- <td><button @click="subscribeToEvents(event)">Подписаться</button></td> -->
115
- <td><button @click="subscribeToEvents(event)" :disabled="isSubscribed(event)" :class="{ 'disabled-button': isSubscribed(event) }">Подписаться</button></td>
116
- </tr>
117
- </tbody>
118
- </table>
119
- <p v-else>Список событий пуст.</p>
14
+ <!-- Вкладка 1 -->
15
+ <div v-if="tabs.tab1">
16
+ <div>
17
+ <button @click="loadOfflineEntitiesList" :disabled="loading">Обновить</button>
18
+ <!-- Тумблер автообновления. Имена switch, slider, tooltip, tooltipParent оставлены
19
+ навсегда как точки подключения для стилей потребителей: своих правил у пакета
20
+ на них нет, их вешают b24_change_entity, b24_desktop и b24_conector -->
21
+ <label class="subscriptions__switch subscriptions__tooltip-parent switch tooltipParent">
22
+ <input type="checkbox" v-model="autoRefresh" @change="autoRefreshEntities" />
23
+ <span class="subscriptions__slider slider"></span>
24
+ <span class="subscriptions__tooltip tooltip">Автообновление каждые 3 секунды</span>
25
+ </label>
26
+ </div>
27
+
28
+ <!-- Обратная связь показывается и при пустом списке: иначе ошибка загрузки не видна -->
29
+ <div v-if="error" class="subscriptions__message subscriptions__message--error">{{ error }}</div>
30
+ <div v-else-if="message" class="subscriptions__message subscriptions__message--success">{{ message }}</div>
31
+
32
+ <div v-if="entities.list.length > 0">
33
+ <div class="subscriptions__actions">
34
+ <button @click="deleteOfflineEntitiesAll" :disabled="busy">{{ confirmAllLabel }}</button>
35
+ <button @click="deleteOfflineEntitiesAllEmpty" :disabled="busy">{{ confirmEmptyLabel }}</button>
36
+ <span>Всего измененных сущностей {{ entities.total }}</span>
37
+ </div>
38
+
39
+ <table class="main subscriptions__table" v-if="entities.list.length">
40
+ <thead>
41
+ <tr>
42
+ <th>#</th>
43
+ <th>Удалить</th>
44
+ <th>Обновить</th>
45
+ <th>Дата</th>
46
+ <th>PROCESS_ID</th>
47
+ <th>EVENT_NAME</th>
48
+ <th>Ссылка</th>
49
+ <th>EVENT_DATA</th>
50
+ <th>who_update</th>
51
+ <th>entity</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ <tr v-for="(entity, index) in entities.list" :key="entity.PROCESS_ID + '-' + entity.ID">
56
+ <td>{{ index + 1 }}</td>
57
+ <!-- Удалить -->
58
+ <td>
59
+ <button @click="deleteOfflineEntities(entity)" :disabled="busy">Удалить</button>
60
+ </td>
61
+ <!-- Изменить -->
62
+ <td>
63
+ <button v-if="entity.EVENT_DATA?.FIELDS?.ID && (entity.EVENT_NAME === 'ONCRMDEALUPDATE' || String(entity.EVENT_NAME || '').includes('ONCRMDYNAMICITEMUPDATE_'))" @click="entityUpdate(entity)" :disabled="busy">Изменить</button>
64
+ </td>
65
+ <td>{{ formatDate(entity.TIMESTAMP_X) }}</td>
66
+ <td>{{ entity.PROCESS_ID }}</td>
67
+ <td>{{ entity.EVENT_NAME }}</td>
68
+ <td>
69
+ <a v-if="entityLink(entity)" :href="entityLink(entity)" target="_blank" rel="noopener noreferrer">{{ entity.EVENT_DATA?.FIELDS?.ID }}</a>
70
+ <span v-else>{{ entity.EVENT_DATA?.FIELDS?.ID }}</span>
71
+ </td>
72
+ <td>
73
+ {{ entity.EVENT_DATA }}
74
+ </td>
75
+ <td>{{ entity.EVENT_ADDITIONAL?.user_id }}</td>
76
+ <!-- tooltipParent / tooltip — только точки подключения для стилей потребителя -->
77
+ <td class="subscriptions__tooltip-parent tooltipParent">
78
+ <span>Подробней</span>
79
+ <span class="subscriptions__tooltip tooltip"> {{ entity }}</span>
80
+ </td>
81
+ </tr>
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ <div v-else>Список сущностей пуст.</div>
120
86
  </div>
121
- </div>
122
87
 
123
- <!-- Вкладка 3 -->
124
- <div v-if="tabs.tab3"></div>
88
+ <!-- Вкладка 2 -->
89
+ <div v-if="tabs.tab2">
90
+ <div v-if="error" class="subscriptions__message subscriptions__message--error">{{ error }}</div>
91
+ <div v-else-if="message" class="subscriptions__message subscriptions__message--success">{{ message }}</div>
92
+
93
+ <div>
94
+ <h3 class="subscriptions__subtitle">Подписаны на события:</h3>
95
+ <table class="main subscriptions__table" v-if="subscribedEvents.length">
96
+ <thead>
97
+ <tr>
98
+ <th>Событие</th>
99
+ <th>connector_id</th>
100
+ <th>offline</th>
101
+ <th>Отписаться</th>
102
+ </tr>
103
+ </thead>
104
+ <tbody>
105
+ <!-- event.get отдаёт { event, connector_id, offline } без id — ключ собираем из пары событие + коннектор -->
106
+ <tr v-for="event in subscribedEvents" :key="event.event + '-' + event.connector_id">
107
+ <td>
108
+ {{ event.event }}
109
+ </td>
110
+ <td>
111
+ {{ event.connector_id }}
112
+ </td>
113
+ <td>
114
+ {{ event.offline }}
115
+ </td>
116
+ <td>
117
+ <button @click="unbindSubscribedEvents(event)" :disabled="busy">Отписаться от событий</button>
118
+ </td>
119
+ </tr>
120
+ </tbody>
121
+ </table>
122
+ <p v-else>Подписок на события нет.</p>
123
+ </div>
124
+
125
+ <div>
126
+ <h3 class="subscriptions__subtitle">На какие события можно подписаться:</h3>
127
+ <table class="main subscriptions__table" v-if="events.length">
128
+ <thead>
129
+ <tr>
130
+ <th>Событие</th>
131
+ <th>Подписаться</th>
132
+ </tr>
133
+ </thead>
134
+ <tbody>
135
+ <tr v-for="event in events" :key="event">
136
+ <td>{{ event }}</td>
137
+ <td><button @click="subscribeToEvents(event)" :disabled="busy || isSubscribed(event)" :class="{ 'subscriptions__button--disabled': isSubscribed(event) }">Подписаться</button></td>
138
+ </tr>
139
+ </tbody>
140
+ </table>
141
+ <p v-else>Список событий пуст.</p>
142
+ </div>
143
+ </div>
144
+ </template>
125
145
  </div>
126
146
  </template>
127
147
 
128
148
  <script>
129
- import { DateTime } from "luxon";
149
+ // Интервал автообновления списка сущностей, мс
150
+ const AUTO_REFRESH_MS = 3000;
151
+ // Сколько кнопка удерживает состояние «Точно удалить?», мс
152
+ const CONFIRM_TIMEOUT_MS = 4000;
153
+ // Потолок команд в одном batch — ограничение платформы Bitrix24
154
+ const BATCH_CHUNK_SIZE = 50;
155
+ // Потолок итераций цикла очистки очереди — страховка от вечного цикла
156
+ const MAX_CLEAR_ITERATIONS = 100;
157
+
158
+ // Настройки формата dd.MM HH:mm:ss. Указан только hourCycle: при hour12: false
159
+ // часть движков отдаёт 24 вместо 00 для полуночи
160
+ const DATE_FORMAT_OPTIONS = {
161
+ day: "2-digit",
162
+ month: "2-digit",
163
+ hour: "2-digit",
164
+ minute: "2-digit",
165
+ second: "2-digit",
166
+ hourCycle: "h23",
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
+ }
213
+
214
+ /**
215
+ * Приводит метку времени к виду dd.MM HH:mm:ss.
216
+ * Склейка через formatToParts: обычный format() в ru-RU ставит запятую между датой и временем.
217
+ */
218
+ function formatTimestamp(value, formatter) {
219
+ if (!value) return "";
220
+ const date = new Date(value);
221
+ if (Number.isNaN(date.getTime())) return String(value);
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);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Режет массив на куски заданного размера.
237
+ */
238
+ function chunkArray(items, size) {
239
+ const chunks = [];
240
+ for (let index = 0; index < items.length; index += size) {
241
+ chunks.push(items.slice(index, index + size));
242
+ }
243
+ return chunks;
244
+ }
130
245
 
131
246
  export default {
132
247
  name: "Subscriptions",
133
- // props: {
134
- // b24: { type: Object, default: null },
135
- // initialAuthConnector: { type: String, default: "" },
136
- // autoStart: { type: Boolean, default: true },
137
- // },
248
+ props: {
249
+ // Клиент Bitrix24 (B24Frame). Приоритетный источник
250
+ b24: { type: /** @type {import("vue").PropType<object | null>} */ (Object), required: false },
251
+ // Значение auth_connector для методов работы с событиями
252
+ authConnector: { type: String, default: "" },
253
+ // Переопределение адреса портала: принимается и https://portal.bitrix24.ru, и голый portal.bitrix24.ru
254
+ portalDomain: { type: String, default: "" },
255
+ // Часовой пояс для дат таблицы, строка IANA вида "Europe/Moscow".
256
+ // Пустая строка — зона браузера. Неизвестная зона не роняет рендер:
257
+ // компонент откатывается на зону браузера и пишет причину в консоль
258
+ timeZone: { type: String, default: "" },
259
+ },
260
+ inject: {
261
+ // Тот же ключ, под которым потребители кладут клиент через app.provide
262
+ injectedB24: { from: "$b24", default: null },
263
+ },
138
264
  data() {
139
265
  return {
266
+ tabs: {
267
+ tab1: true,
268
+ tab2: false,
269
+ },
140
270
  events: [],
141
271
  subscribedEvents: [],
142
272
  entities: {
@@ -144,262 +274,948 @@ export default {
144
274
  total: 0,
145
275
  },
146
276
  autoRefresh: true,
147
- auth_connector: "",
277
+ loading: false,
278
+ busy: false,
279
+ error: "",
280
+ message: "",
281
+ refreshTimerId: null,
282
+ isUnmounted: false,
283
+ // Признак того, что текущий текст в error поставлен неудачной загрузкой списка.
284
+ // Только такую ошибку удачное автообновление имеет право погасить
285
+ loadFailed: false,
286
+ // Какая кнопка ждёт второго клика: "" | "all" | "empty"
287
+ confirmAction: "",
288
+ confirmTimerId: null,
148
289
  };
149
290
  },
150
- computed: {},
291
+ computed: {
292
+ // Клиент Bitrix24: prop → inject → глобальный window.$b24
293
+ b24Client() {
294
+ if (this.b24) return this.b24;
295
+ if (this.injectedB24) return this.injectedB24;
296
+ if (typeof window !== "undefined" && window.$b24) return window.$b24;
297
+ return null;
298
+ },
299
+ // Адрес портала без завершающего слэша, "" — определить не удалось
300
+ portalOrigin() {
301
+ try {
302
+ if (this.portalDomain) {
303
+ const domain = String(this.portalDomain).replace(/\/+$/, "");
304
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
305
+ }
306
+ const client = this.b24Client;
307
+ if (!client) return "";
308
+ if (typeof client.getTargetOrigin === "function") {
309
+ const origin = client.getTargetOrigin();
310
+ if (origin) return String(origin).replace(/\/+$/, "");
311
+ }
312
+ // getAuthData() отдаёт false, когда токен истёк
313
+ const authData = client.auth && typeof client.auth.getAuthData === "function" ? client.auth.getAuthData() : null;
314
+ if (authData && authData.domain) {
315
+ const domain = String(authData.domain).replace(/\/+$/, "");
316
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
317
+ }
318
+ return "";
319
+ } catch (originError) {
320
+ console.error("Не удалось определить адрес портала:", originError);
321
+ return "";
322
+ }
323
+ },
324
+ confirmAllLabel() {
325
+ return this.confirmAction === "all" ? "Точно удалить?" : "Удалить все";
326
+ },
327
+ confirmEmptyLabel() {
328
+ return this.confirmAction === "empty" ? "Точно удалить?" : "Удалить все пустые";
329
+ },
330
+ // Форматтер дат под текущую зону. Computed, а не вызов в методе:
331
+ // так зависимость от prop timeZone видна реактивности явно,
332
+ // а поиск в кэше делается один раз на смену зоны, а не на каждую ячейку
333
+ dateFormatter() {
334
+ return getDateFormatter(this.timeZone);
335
+ },
336
+ },
151
337
  methods: {
338
+ selectTab(name) {
339
+ for (const key of Object.keys(this.tabs)) {
340
+ this.tabs[key] = key === name;
341
+ }
342
+ },
152
343
  formatDate(timestamp) {
153
- if (!timestamp) return "";
154
- return DateTime.fromISO(timestamp).toFormat("dd.MM HH:mm:ss");
344
+ return formatTimestamp(timestamp, this.dateFormatter);
155
345
  },
156
346
  pause(ms) {
157
347
  return new Promise((resolve) => setTimeout(resolve, ms));
158
348
  },
349
+
350
+ // --- Обратная связь пользователю ---
351
+
352
+ clearFeedback() {
353
+ this.error = "";
354
+ this.message = "";
355
+ this.loadFailed = false;
356
+ },
357
+ showError(text) {
358
+ this.error = String(text);
359
+ this.message = "";
360
+ // Владение плашкой переходит к вызвавшей операции; ветки загрузки
361
+ // ставят loadFailed уже после вызова showError
362
+ this.loadFailed = false;
363
+ },
364
+ showMessage(text) {
365
+ this.message = String(text);
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;
380
+ },
381
+ // Текст исключения
382
+ errorText(err) {
383
+ if (err instanceof Error) return err.message;
384
+ if (typeof err === "string") return err;
385
+ return String(err);
386
+ },
387
+ // Текст ошибок ответа SDK (Result / AjaxResult)
388
+ resultErrors(result) {
389
+ let texts = [];
390
+ try {
391
+ if (result && typeof result.getErrorMessagesByKey === "function") {
392
+ // SDK >= 2.0.0: видно, какая команда batch упала
393
+ const byKey = result.getErrorMessagesByKey() || {};
394
+ texts = Object.entries(byKey).map(([key, messages]) => `${key}: ${[].concat(messages).join(", ")}`);
395
+ } else if (result && typeof result.getErrorMessages === "function") {
396
+ texts = result.getErrorMessages() || [];
397
+ }
398
+ } catch (readError) {
399
+ console.error("Не удалось прочитать текст ошибки ответа:", readError);
400
+ }
401
+ const text = texts.filter((item) => item).join("; ");
402
+ return text || "портал не вернул текст ошибки";
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
+ },
420
+
421
+ // --- Подтверждение вторым кликом вместо confirm() ---
422
+
423
+ requestConfirm(action) {
424
+ if (this.confirmAction !== action) {
425
+ this.resetConfirm();
426
+ this.confirmAction = action;
427
+ this.confirmTimerId = setTimeout(() => this.resetConfirm(), CONFIRM_TIMEOUT_MS);
428
+ return false;
429
+ }
430
+ this.resetConfirm();
431
+ return true;
432
+ },
433
+ resetConfirm() {
434
+ if (this.confirmTimerId !== null) {
435
+ clearTimeout(this.confirmTimerId);
436
+ this.confirmTimerId = null;
437
+ }
438
+ this.confirmAction = "";
439
+ },
440
+
441
+ /**
442
+ * Определяет ENTITY_TYPE_ID смарт-процесса: сначала из FIELDS, затем из имени события.
443
+ * Общий разбор для ссылки и для обновления — иначе событие без ENTITY_TYPE_ID в FIELDS
444
+ * уходит в ветку сделки. Возвращает "" когда тип определить не удалось.
445
+ */
446
+ resolveEntityTypeId(entity) {
447
+ const entityTypeId = entity?.EVENT_DATA?.FIELDS?.ENTITY_TYPE_ID;
448
+ if (Number(entityTypeId) > 0) return String(entityTypeId);
449
+ // В части событий FIELDS приходит без ENTITY_TYPE_ID — берём из имени события
450
+ const matched = String(entity?.EVENT_NAME || "").match(/^ONCRMDYNAMICITEMUPDATE_(\d+)$/);
451
+ if (matched && Number(matched[1]) > 0) return matched[1];
452
+ return "";
453
+ },
454
+
455
+ // Ссылка на сущность на текущем портале, "" — построить не удалось
456
+ entityLink(entity) {
457
+ const origin = this.portalOrigin;
458
+ if (!origin) return "";
459
+ const id = entity?.EVENT_DATA?.FIELDS?.ID;
460
+ if (!id) return "";
461
+ const entityTypeId = this.resolveEntityTypeId(entity);
462
+ if (entityTypeId) return `${origin}/crm/type/${entityTypeId}/details/${id}/`;
463
+ if (entity?.EVENT_NAME === "ONCRMDEALUPDATE") return `${origin}/crm/deal/details/${id}/`;
464
+ return "";
465
+ },
466
+
467
+ // --- Работа с событиями Bitrix24 ---
468
+
159
469
  async eventsList() {
160
- console.log("Запускаем eventsList");
470
+ const b24 = this.ensureClient();
471
+ if (!b24) return;
161
472
  try {
162
- let result = await $b24.callMethod("events", {
163
- // auth: access_token,
164
- // scope: "crm",
165
- });
166
- this.events = result.getData().result;
167
- } catch (error) {
168
- console.error("Ошибка при получении списка событий:", error);
473
+ const result = await b24.callMethod("events", {});
474
+ // Сравнение с false намеренно явное и повторяется во всех вызовах ниже:
475
+ // потребители подставляют шим-клиенты, у которых ответ без поля isSuccess.
476
+ // undefined тогда трактуется как отказ, хотя запрос прошёл.
477
+ // У настоящего AjaxResult геттер всегда булев, поэтому === false ловит те же отказы.
478
+ if (result.isSuccess === false) {
479
+ this.showError(`Не удалось получить список событий: ${this.resultErrors(result)}`);
480
+ return;
481
+ }
482
+ this.events = result.getData()?.result || [];
483
+ } catch (callError) {
484
+ console.error("Ошибка при получении списка событий:", callError);
485
+ this.showError(`Ошибка при получении списка событий: ${this.errorText(callError)}`);
169
486
  }
170
487
  },
488
+
171
489
  async subscribeToEvents(event) {
490
+ const b24 = this.ensureClient();
491
+ if (!b24) return;
492
+ this.clearFeedback();
493
+ this.busy = true;
172
494
  try {
173
- await $b24.callMethod("event.bind", {
495
+ // handler обязателен даже для оффлайн-событий: портал его не вызывает,
496
+ // но без параметра event.bind отвечает ошибкой
497
+ const handler = typeof window !== "undefined" && window.location ? window.location.origin : "";
498
+ const result = await b24.callMethod("event.bind", {
174
499
  event: event,
175
500
  event_type: "offline",
176
- // auth_type: "138",
177
- auth_connector: this.auth_connector,
501
+ handler: handler,
502
+ auth_connector: this.authConnector,
178
503
  });
179
- console.log(`Подписались на событие ${event}.`);
180
- } catch (error) {
181
- console.error("Ошибка при подписке на события:", error);
504
+ if (result.isSuccess === false) {
505
+ this.showError(`Не удалось подписаться на событие ${event}: ${this.resultErrors(result)}`);
506
+ return;
507
+ }
508
+ this.showMessage(`Подписались на событие ${event}`);
509
+ await this.pause(1000);
510
+ await this.getSubscribedEvents();
511
+ } catch (callError) {
512
+ console.error("Ошибка при подписке на события:", callError);
513
+ this.showError(`Ошибка при подписке на событие ${event}: ${this.errorText(callError)}`);
514
+ } finally {
515
+ this.busy = false;
516
+ // Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
517
+ // цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
518
+ this.startAutoRefresh();
182
519
  }
183
- await this.pause(1000);
184
- await this.getSubscribedEvents();
185
520
  },
186
521
 
187
522
  async getSubscribedEvents() {
523
+ const b24 = this.ensureClient();
524
+ if (!b24) return;
188
525
  try {
189
- let result = await $b24.callMethod("event.get", {});
190
- this.subscribedEvents = result.getData().result;
191
- console.log("Подписанные события:", this.subscribedEvents);
192
- } catch (error) {
193
- console.error("Ошибка при получении подписанных событий:", error);
526
+ const result = await b24.callMethod("event.get", {});
527
+ if (result.isSuccess === false) {
528
+ this.showError(`Не удалось получить подписанные события: ${this.resultErrors(result)}`);
529
+ return;
530
+ }
531
+ this.subscribedEvents = result.getData()?.result || [];
532
+ } catch (callError) {
533
+ console.error("Ошибка при получении подписанных событий:", callError);
534
+ this.showError(`Ошибка при получении подписанных событий: ${this.errorText(callError)}`);
194
535
  }
195
536
  },
196
537
 
197
538
  async unbindSubscribedEvents(event) {
539
+ const b24 = this.ensureClient();
540
+ if (!b24) return;
541
+ this.clearFeedback();
542
+ this.busy = true;
198
543
  try {
199
- await $b24.callMethod("event.unbind", {
544
+ const result = await b24.callMethod("event.unbind", {
200
545
  event: event.event,
201
546
  event_type: event.offline == 1 ? "offline" : "online",
202
547
  auth_connector: event.connector_id,
203
548
  });
549
+ if (result.isSuccess === false) {
550
+ this.showError(`Не удалось отписаться от события ${event.event}: ${this.resultErrors(result)}`);
551
+ return;
552
+ }
553
+ this.showMessage(`Отписались от события ${event.event}`);
204
554
  await this.pause(1000);
205
555
  await this.getSubscribedEvents();
206
- } catch (error) {
207
- console.error("Ошибка при отписке от событий:", error);
556
+ } catch (callError) {
557
+ console.error("Ошибка при отписке от событий:", callError);
558
+ this.showError(`Ошибка при отписке от события ${event.event}: ${this.errorText(callError)}`);
559
+ } finally {
560
+ this.busy = false;
561
+ // Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
562
+ // цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
563
+ this.startAutoRefresh();
208
564
  }
209
565
  },
210
566
 
211
567
  async loadOfflineEntitiesList() {
212
- // console.log(`Запустили loadOfflineEntitiesList`);
213
- let start = 0;
214
- let result = await $b24.callListMethod(
215
- "event.offline.list",
216
- {
217
- start: start,
218
- auth_connector: this.auth_connector,
219
- // clear: 0,
568
+ const b24 = this.ensureClient();
569
+ // Порядок обязателен: showError внутри ensureClient сбрасывает loadFailed,
570
+ // поэтому флаг ставится после проверки
571
+ if (!b24) {
572
+ this.loadFailed = true;
573
+ return;
574
+ }
575
+ this.loading = true;
576
+ try {
577
+ // callListMethod отдаёт плоский массив, а не { result }
578
+ const result = await b24.callListMethod("event.offline.list", {
579
+ start: 0,
580
+ auth_connector: this.authConnector,
581
+ });
582
+ if (result.isSuccess === false) {
583
+ // Старый список полезнее пустого — entities.list не обнуляем
584
+ this.showError(`Не удалось получить список сущностей: ${this.resultErrors(result)}`);
585
+ this.loadFailed = true;
586
+ return;
220
587
  }
221
- // (progress) => {
222
- // console.log(`Загрузка: ${progress.toFixed(0)}%`);
223
- // }
224
- );
225
-
226
- // console.log("Всего сущностей:", result.getData().length);
227
- // console.log("Список сущностей:", result.getData());
228
- this.entities.list = result.getData();
229
- this.entities.total = result.getData().length;
230
- },
231
-
232
- async deleteOfflineEntities(entities) {
233
- let result = await $b24.callMethod("event.offline.clear", {
234
- process_id: entities.PROCESS_ID,
235
- id: [entities.ID],
236
- auth_connector: this.auth_connector,
588
+ const rows = result.getData() || [];
589
+ this.entities.list = rows;
590
+ this.entities.total = rows.length;
591
+ // Гасим только свою ошибку: message не трогаем, иначе удачное автообновление
592
+ // сотрёт «Запись N удалена из очереди», поставленное секунду назад
593
+ if (this.loadFailed) {
594
+ this.error = "";
595
+ this.loadFailed = false;
596
+ }
597
+ } catch (callError) {
598
+ console.error("Ошибка при получении списка оффлайн-сущностей:", callError);
599
+ this.showError(`Ошибка при получении списка сущностей: ${this.errorText(callError)}`);
600
+ this.loadFailed = true;
601
+ } finally {
602
+ this.loading = false;
603
+ }
604
+ },
605
+
606
+ /**
607
+ * Снимает одну запись из оффлайн-очереди. Возвращает признак успеха.
608
+ */
609
+ async deleteOfflineEntities(entity) {
610
+ const b24 = this.ensureClient();
611
+ if (!b24) return false;
612
+ // event.offline.clear работает только по process_id: для свободной записи вызов бессмысленный
613
+ if (!entity?.PROCESS_ID) {
614
+ this.showError("У записи пустой PROCESS_ID — снимается кнопкой «Удалить все пустые»");
615
+ return false;
616
+ }
617
+ this.clearFeedback();
618
+ this.busy = true;
619
+ try {
620
+ const result = await b24.callMethod("event.offline.clear", {
621
+ process_id: entity.PROCESS_ID,
622
+ id: [entity.ID],
623
+ auth_connector: this.authConnector,
624
+ });
625
+ if (result.isSuccess === false) {
626
+ this.showError(`Не удалось удалить запись из очереди: ${this.resultErrors(result)}`);
627
+ return false;
628
+ }
629
+ this.showMessage(`Запись ${entity.ID} удалена из очереди`);
630
+ // Портал отдаёт очередь с задержкой
631
+ await this.pause(1000);
632
+ await this.loadOfflineEntitiesList();
633
+ return true;
634
+ } catch (callError) {
635
+ console.error("Ошибка при удалении записи из оффлайн-очереди:", callError);
636
+ this.showError(`Ошибка при удалении записи из очереди: ${this.errorText(callError)}`);
637
+ return false;
638
+ } finally {
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,
237
656
  });
238
- console.log("Удалили сущности из оффлан событий:", result.getData());
239
- await this.pause(1000);
240
- await this.loadOfflineEntitiesList();
657
+ if (pageResult.isSuccess === false) {
658
+ return { rows: [], error: this.resultErrors(pageResult) };
659
+ }
660
+ return { rows: pageResult.getData() || [], error: "" };
241
661
  },
242
662
 
243
- async deleteOfflineEntitiesAll() {
244
- if (!confirm("Вы уверены, что хотите удалить все НЕ обработанные сущности? Это действие необратимо!")) {
245
- return;
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}` };
246
753
  }
754
+ return { kind: "message", text: `Очищено пачек: ${clearedCount}` };
755
+ },
247
756
 
248
- let pageAll = this.entities.total / 50 + 1;
249
- console.log("Всего страниц для удаления:", pageAll);
250
- // Цикл по всем страницам для удаления всех сущностей
251
- for (let page = 1; page <= pageAll; page++) {
252
- // Получаем первые сущности
253
- let resAll = await $b24.callMethod("event.offline.list", {
254
- start: 0,
255
- });
256
- // Получаем массив PROCESS_ID
257
- let processIds = resAll
258
- .getData()
259
- .result.map((entity) => entity.PROCESS_ID)
260
- .filter((id) => id);
261
- // Добавляем в массив пустой PROCESS_ID для удаления не обработанных сущностей
262
- // processIds.push("");
263
- console.log("PROCESS_ID для удаления:", processIds);
264
- // Формируем batch запрос для удаления всех сущностей с указанными PROCESS_ID одним запросов
265
- let batchCommands = {};
266
- processIds.forEach((processId, index) => {
267
- batchCommands[`clear_${index}`] = {
268
- method: "event.offline.clear",
269
- params: {
270
- process_id: processId,
271
- },
272
- };
273
- });
274
- console.log("batchCommands:", batchCommands);
275
- let batchResult = await $b24.callBatch(batchCommands);
276
- console.log("Результат batch удаления сущностей:", batchResult.getData());
757
+ async deleteOfflineEntitiesAll() {
758
+ if (!this.requestConfirm("all")) return;
759
+ const b24 = this.ensureClient();
760
+ if (!b24) return;
761
+ this.clearFeedback();
762
+ this.busy = true;
763
+ // Фоновое обновление конкурирует с удалением и мигает таблицей
764
+ const wasAutoRefresh = this.autoRefresh;
765
+ this.stopAutoRefresh();
766
+
767
+ const failures = [];
768
+ // Пачки, снятие которых портал подтвердил ключом в данных batch:
769
+ // размер множества — честный счётчик для отчёта
770
+ const clearedProcessIds = new Set();
771
+ let reachedLimit = false;
772
+ let stoppedOnEmpty = "";
773
+ let stoppedOnRepeat = "";
774
+ let iteration = 0;
775
+ try {
776
+ for (; iteration < MAX_CLEAR_ITERATIONS; iteration++) {
777
+ // 1. Очередная страница очереди
778
+ const page = await this.readOfflineQueuePage(b24);
779
+ if (page.error) {
780
+ failures.push(page.error);
781
+ break;
782
+ }
783
+ if (!page.rows.length) break;
784
+
785
+ // 2. Что на странице вообще можно снять
786
+ const { processIds, freshProcessIds } = this.collectProcessIds(page.rows, clearedProcessIds);
787
+ if (!processIds.length) {
788
+ // Свободные записи этим методом не снимаются — иначе цикл крутится до потолка итераций
789
+ stoppedOnEmpty = `Остались записи с пустым PROCESS_ID (${page.rows.length}) — снимаются кнопкой «Удалить все пустые»`;
790
+ break;
791
+ }
792
+ if (!freshProcessIds.length) {
793
+ stoppedOnRepeat =
794
+ "портал снова отдал уже снятые пачки. Команды clear отработали без ошибок, скорее всего очередь обновляется с задержкой — обновите список через минуту. Если записи останутся, значит clear их не снял";
795
+ break;
796
+ }
797
+
798
+ // 3. Резка по 50: в один batch больше команд платформа не принимает
799
+ for (const chunk of chunkArray(freshProcessIds, BATCH_CHUNK_SIZE)) {
800
+ const failure = await this.sendClearChunk(b24, chunk, clearedProcessIds);
801
+ if (failure) failures.push(failure);
802
+ }
803
+ // 4. Если пачка не удаляется, следующая итерация увидит те же PROCESS_ID
804
+ if (failures.length) break;
805
+ // 5. Портал отдаёт очередь с задержкой — перечитывать сразу бессмысленно
806
+ await this.pause(1000);
807
+ }
808
+ reachedLimit = iteration >= MAX_CLEAR_ITERATIONS;
809
+ } catch (callError) {
810
+ console.error("Ошибка при массовом удалении сущностей:", callError);
811
+ failures.push(this.errorText(callError));
812
+ } finally {
813
+ this.busy = false;
814
+ if (wasAutoRefresh) this.startAutoRefresh();
277
815
  }
816
+
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);
826
+
278
827
  await this.pause(500);
828
+ // Размонтирование могло случиться, пока шла очистка
829
+ if (this.isUnmounted) return;
279
830
  await this.loadOfflineEntitiesList();
280
831
  },
281
832
 
282
833
  async deleteOfflineEntitiesAllEmpty() {
283
- if (!confirm("Вы уверены, что хотите удалить все пустые НЕ обработанные сущности? Это действие необратимо!")) {
284
- return;
834
+ if (!this.requestConfirm("empty")) return;
835
+ const b24 = this.ensureClient();
836
+ if (!b24) return;
837
+ this.clearFeedback();
838
+ this.busy = true;
839
+ // Фоновое обновление конкурирует с удалением: симметрично deleteOfflineEntitiesAll
840
+ const wasAutoRefresh = this.autoRefresh;
841
+ this.stopAutoRefresh();
842
+ try {
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
+ );
852
+ // Снимаем записи с пустым PROCESS_ID — по process_id их не удалить
853
+ const result = await b24.callMethod("event.offline.clear", {
854
+ process_id: "",
855
+ auth_connector: this.authConnector,
856
+ });
857
+ if (result.isSuccess === false) {
858
+ this.showError(`Не удалось удалить записи с пустым PROCESS_ID: ${this.resultErrors(result)}`);
859
+ return;
860
+ }
861
+ await this.pause(500);
862
+ await this.loadOfflineEntitiesList();
863
+ // Ошибку перезагрузки не затираем: без свежего списка считать не по чему
864
+ if (this.error) return;
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
+ }
880
+ if (removedCount > 0) {
881
+ // Формулировка нейтральна к авторству: часть записей мог снять
882
+ // серверный коллектор потребителя за паузу перед перечиткой
883
+ this.showMessage(`Ушло из очереди записей: ${removedCount} из ${emptyIdsBefore.size}`);
884
+ } else {
885
+ this.showMessage("Из очереди не ушла ни одна запись с пустым PROCESS_ID");
886
+ }
887
+ } catch (callError) {
888
+ console.error("Ошибка при удалении сущностей с пустым PROCESS_ID:", callError);
889
+ this.showError(`Ошибка при удалении записей с пустым PROCESS_ID: ${this.errorText(callError)}`);
890
+ } finally {
891
+ this.busy = false;
892
+ if (wasAutoRefresh) this.startAutoRefresh();
285
893
  }
286
- // Удаляем последний раз, чтобы удалить сущности с пустым PROCESS_ID
287
- let finalResult = await $b24.callMethod("event.offline.clear", {
288
- process_id: "",
289
- });
290
- console.log("Результат удаления сущностей с пустым PROCESS_ID:", finalResult.getData());
291
894
  },
292
895
 
293
896
  isSubscribed(event) {
294
- // console.log(event);
295
- // console.log(this.subscribedEvents);
296
897
  return this.subscribedEvents.some((subEvent) => subEvent.event == event);
297
898
  },
298
899
 
900
+ // --- Автообновление ---
901
+
902
+ startAutoRefresh() {
903
+ // Защита от двойного таймера
904
+ this.stopAutoRefresh();
905
+ if (!this.autoRefresh || this.isUnmounted) return;
906
+ // На скрытой вкладке цепочку не заводим: 4 запроса каждые 3 секунды съедают
907
+ // общий лимит REST портала впустую. Перезапустит handleVisibilityChange
908
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
909
+ this.refreshTimerId = setTimeout(async () => {
910
+ this.refreshTimerId = null;
911
+ if (this.isUnmounted) return;
912
+ await this.loadOfflineEntitiesList();
913
+ // Размонтирование могло случиться, пока висел запрос
914
+ if (this.isUnmounted) return;
915
+ this.startAutoRefresh();
916
+ }, AUTO_REFRESH_MS);
917
+ },
918
+
919
+ stopAutoRefresh() {
920
+ if (this.refreshTimerId !== null) {
921
+ clearTimeout(this.refreshTimerId);
922
+ this.refreshTimerId = null;
923
+ }
924
+ },
925
+
926
+ // Обработчик @change тумблера: выключение отменяет уже отложенный вызов
299
927
  autoRefreshEntities() {
300
- // console.log("Запущена функция автообновления сущностей autoRefreshEntities, проверяем состояние autoRefresh");
301
928
  if (this.autoRefresh) {
302
- // console.log("Автообновление включено. Обновляем список сущностей через 3 секунд.");
303
- setTimeout(async () => {
304
- await this.loadOfflineEntitiesList();
305
- this.autoRefreshEntities();
306
- }, 3000);
929
+ this.startAutoRefresh();
307
930
  } else {
308
- // console.log("Автообновление autoRefreshEntities выключено.");
931
+ this.stopAutoRefresh();
932
+ }
933
+ },
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("Сущность не обновлена, из очереди не удалена");
309
1031
  }
310
1032
  },
311
1033
 
1034
+ /**
1035
+ * Перезаписывает сущность теми же данными и снимает её из оффлайн-очереди.
1036
+ */
312
1037
  async entityUpdate(eventEntity) {
313
- let error = false;
314
- console.log("Запущена функция entityUpdate для изменения сущности с ID:", eventEntity.EVENT_DATA.FIELDS?.ID);
1038
+ const b24 = this.ensureClient();
1039
+ if (!b24) return;
1040
+ const fields = eventEntity?.EVENT_DATA?.FIELDS || {};
1041
+ const entityId = fields.ID;
1042
+ if (!entityId) {
1043
+ this.showError("У события нет ID сущности");
1044
+ return;
1045
+ }
1046
+
1047
+ // Тип берётся тем же разбором, что и для ссылки: FIELDS плюс имя события
1048
+ const entityTypeId = this.resolveEntityTypeId(eventEntity);
1049
+
1050
+ let failed = false;
1051
+ this.clearFeedback();
1052
+ this.busy = true;
315
1053
  try {
316
- // Если смарт процесс
317
- if (eventEntity.EVENT_DATA.FIELDS?.ENTITY_TYPE_ID && eventEntity.EVENT_DATA.FIELDS?.ENTITY_TYPE_ID > 0) {
318
- // Обработка смарт процесса
319
- let entity = await $b24.callMethod("crm.item.get", {
320
- entityTypeId: eventEntity.EVENT_DATA.FIELDS.ENTITY_TYPE_ID,
321
- id: eventEntity.EVENT_DATA.FIELDS.ID,
322
- });
323
- entity = entity.getData().result.item;
324
- console.log("Данные смарт процесса до изменения:", entity);
325
- if (entity.id == eventEntity.EVENT_DATA.FIELDS?.ID) {
326
- // обновляем смарт процесс через метод crm.item.update. Меняем в ней TITLE на то же значение что и было
327
- let updateResult = await $b24.callMethod("crm.item.update", {
328
- entityTypeId: eventEntity.EVENT_DATA.FIELDS.ENTITY_TYPE_ID,
329
- id: eventEntity.EVENT_DATA.FIELDS.ID,
330
- fields: {
331
- title: entity.title + "123",
332
- },
333
- });
334
- console.log("Результат обновления смарт процесса:", updateResult.getData());
335
- if (updateResult.id === entity.id) {
336
- console.log("Сущность успешно обновлена.");
337
- error = true;
338
- } else {
339
- console.error("Ошибка при обновлении сущности.");
340
- error = true;
341
- }
342
- } else {
343
- error = true;
344
- }
1054
+ if (entityTypeId) {
1055
+ failed = !(await this.updateSmartProcessEntity(b24, entityTypeId, entityId));
1056
+ } else if (eventEntity?.EVENT_NAME === "ONCRMDEALUPDATE") {
1057
+ failed = !(await this.updateDealEntity(b24, entityId));
345
1058
  } else {
346
- // Если сделка, то получаем значение сделки используя метод crm.deal.get
347
- let deal = await $b24.callMethod("crm.deal.get", {
348
- id: eventEntity.EVENT_DATA.FIELDS?.ID,
349
- });
350
- deal = deal.getData().result;
351
- console.log("Данные сделки до изменения:", deal);
352
-
353
- if (deal.ID == eventEntity.EVENT_DATA.FIELDS?.ID) {
354
- // обновляем сделку через метод crm.deal.update. Меняем в ней title на то же значение что и было
355
- let updateResult = await $b24.callMethod("crm.deal.update", {
356
- id: eventEntity.EVENT_DATA.FIELDS?.ID,
357
- fields: {
358
- TITLE: deal.TITLE,
359
- },
360
- });
361
- console.log("Результат обновления сделки:", updateResult.getData());
362
- updateResult = updateResult.getData().result;
363
- if (updateResult === true) {
364
- console.log("Сущность успешно обновлена.");
365
- } else {
366
- console.error("Ошибка при обновлении сущности.");
367
- error = true;
368
- }
369
- } else {
370
- error = true;
371
- }
1059
+ // Ни ENTITY_TYPE_ID, ни имя события не говорят, что это за сущность —
1060
+ // вслепую дёргать crm.deal.get нельзя: снимется чужая запись с зелёным успехом
1061
+ failed = true;
1062
+ this.showError(`Не удалось определить тип сущности для события ${eventEntity?.EVENT_NAME || "без имени"}`);
372
1063
  }
373
- } catch (error) {
374
- console.error("Ошибка при обновлении сущности:", error);
375
- error = true;
376
- }
377
- if (!error) {
378
- // Удаляем сущность из оффлайн очереди
379
- await this.deleteOfflineEntities(eventEntity);
380
- } else {
1064
+ } catch (callError) {
1065
+ failed = true;
1066
+ console.error("Ошибка при обновлении сущности:", callError);
1067
+ this.showError(`Ошибка при обновлении сущности ${entityId}: ${this.errorText(callError)}`);
1068
+ } finally {
1069
+ this.busy = false;
1070
+ // Пока операция шла, вкладку могли скрыть и снова открыть: перезапуск
1071
+ // цепочки идемпотентен и молчит при выключенном тумблере и скрытой вкладке
1072
+ this.startAutoRefresh();
381
1073
  }
1074
+
1075
+ // Хвост стоит после try/catch/finally намеренно: deleteOfflineEntities сам ставит
1076
+ // busy, и перенос внутрь try снял бы флаг раньше времени
1077
+ await this.finishEntityUpdate(eventEntity, entityId, failed);
382
1078
  },
383
1079
  },
384
- watch: {},
385
1080
  mounted() {
1081
+ // Три загрузки идут параллельно — await здесь намеренно не ставится
386
1082
  this.getSubscribedEvents();
387
1083
  this.eventsList();
388
1084
  this.loadOfflineEntitiesList();
389
1085
  this.autoRefreshEntities();
1086
+ // В Vue 3 методы Options API привязаны к экземпляру один раз, ссылка стабильна,
1087
+ // поэтому removeEventListener снимет именно этот слушатель
1088
+ if (typeof document !== "undefined") {
1089
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
1090
+ }
1091
+ },
1092
+ beforeUnmount() {
1093
+ this.isUnmounted = true;
1094
+ this.stopAutoRefresh();
1095
+ this.resetConfirm();
1096
+ if (typeof document !== "undefined") {
1097
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1098
+ }
390
1099
  },
391
1100
  };
392
1101
  </script>
393
1102
 
394
1103
  <style>
395
- .tooltipParent {
1104
+ /* Цвета вынесены в кастомные свойства: потребитель перекрашивает компонент без борьбы за специфичность */
1105
+ .subscriptions {
1106
+ --subscriptions-switch-off: #ccc;
1107
+ --subscriptions-switch-on: #4caf50;
1108
+ --subscriptions-switch-knob: #fff;
1109
+ --subscriptions-tooltip-bg: #555;
1110
+ --subscriptions-tooltip-ink: #fff;
1111
+ --subscriptions-error-bg: #ffebee;
1112
+ --subscriptions-error-ink: #c62828;
1113
+ --subscriptions-success-bg: #e8f5e9;
1114
+ --subscriptions-success-ink: #2e7d32;
1115
+ --subscriptions-table-line: #eee;
1116
+ --subscriptions-table-head-ink: #555;
1117
+ }
1118
+
1119
+ .subscriptions__tabs {
1120
+ margin-bottom: 24px;
1121
+ }
1122
+
1123
+ .subscriptions__tab--active {
1124
+ font-weight: 600;
1125
+ }
1126
+
1127
+ .subscriptions__empty {
1128
+ padding: 12px;
1129
+ color: var(--subscriptions-error-ink, #c62828);
1130
+ }
1131
+
1132
+ .subscriptions__actions {
1133
+ display: flex;
1134
+ align-items: center;
1135
+ gap: 8px;
1136
+ margin: 12px 0;
1137
+ }
1138
+
1139
+ .subscriptions__message {
1140
+ margin: 12px 0;
1141
+ padding: 8px 12px;
1142
+ border-radius: 4px;
1143
+ font-size: 14px;
1144
+ }
1145
+
1146
+ .subscriptions__message--success {
1147
+ background: var(--subscriptions-success-bg, #e8f5e9);
1148
+ color: var(--subscriptions-success-ink, #2e7d32);
1149
+ border: 1px solid #a5d6a7;
1150
+ }
1151
+
1152
+ .subscriptions__message--error {
1153
+ background: var(--subscriptions-error-bg, #ffebee);
1154
+ color: var(--subscriptions-error-ink, #c62828);
1155
+ border: 1px solid #ef9a9a;
1156
+ }
1157
+
1158
+ /* Тумблер автообновления */
1159
+ .subscriptions__switch {
1160
+ position: relative;
1161
+ display: inline-block;
1162
+ width: 44px;
1163
+ height: 24px;
1164
+ vertical-align: middle;
1165
+ }
1166
+
1167
+ .subscriptions__switch input {
1168
+ opacity: 0;
1169
+ width: 0;
1170
+ height: 0;
1171
+ }
1172
+
1173
+ .subscriptions__slider {
1174
+ position: absolute;
1175
+ cursor: pointer;
1176
+ top: 0;
1177
+ left: 0;
1178
+ right: 0;
1179
+ bottom: 0;
1180
+ background-color: var(--subscriptions-switch-off, #ccc);
1181
+ transition: 0.3s;
1182
+ border-radius: 24px;
1183
+ }
1184
+
1185
+ .subscriptions__slider::before {
1186
+ content: "";
1187
+ position: absolute;
1188
+ height: 18px;
1189
+ width: 18px;
1190
+ left: 3px;
1191
+ bottom: 3px;
1192
+ background-color: var(--subscriptions-switch-knob, #fff);
1193
+ transition: 0.3s;
1194
+ border-radius: 50%;
1195
+ }
1196
+
1197
+ input:checked + .subscriptions__slider {
1198
+ background-color: var(--subscriptions-switch-on, #4caf50);
1199
+ }
1200
+
1201
+ input:checked + .subscriptions__slider::before {
1202
+ transform: translateX(20px);
1203
+ }
1204
+
1205
+ /* Подсказки. Имена .tooltip и .tooltipParent остаются на элементах навсегда как точки
1206
+ подключения для стилей потребителей (b24_change_entity, b24_desktop, b24_conector),
1207
+ но собственных правил пакет на них больше не вешает: правило веса 0,1,0 из пакета
1208
+ конфликтовало с одноимённым классом дизайн-системы b24_desktop и перекрашивало
1209
+ подсказки всего приложения. Оформление подсказок компонента — только .subscriptions__* */
1210
+ .subscriptions__tooltip-parent {
396
1211
  position: relative; /* Чтобы подсказка позиционировалась относительно родителя */
397
1212
  cursor: pointer;
398
1213
  }
399
- .tooltip {
1214
+
1215
+ .subscriptions__tooltip {
400
1216
  visibility: hidden;
401
- background-color: #555;
402
- color: #fff;
1217
+ background-color: var(--subscriptions-tooltip-bg, #555);
1218
+ color: var(--subscriptions-tooltip-ink, #fff);
403
1219
  text-align: center;
404
1220
  border-radius: 5px;
405
1221
  padding: 5px;
@@ -413,12 +1229,42 @@ export default {
413
1229
  max-width: 400px;
414
1230
  min-width: 200px;
415
1231
  word-wrap: break-word; /* Перенос слов при необходимости */
416
- /* white-space: nowrap; Запретить перенос слов */
417
1232
  }
418
1233
 
419
- /* .switch:hover .tooltip, */
420
- .tooltipParent:hover .tooltip {
1234
+ .subscriptions__tooltip-parent:hover .subscriptions__tooltip {
421
1235
  visibility: visible;
422
1236
  opacity: 1;
423
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
+ }
424
1270
  </style>