@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/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-button': isSubscribed(event) }">Подписаться</button></td>
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
- // Формат даты создаётся один раз: Intl.DateTimeFormat дорого конструировать на каждую строку таблицы.
156
- // Указан только hourCycle: при hour12: false часть движков отдаёт 24 вместо 00 для полуночи.
157
- const DATE_FORMATTER = new Intl.DateTimeFormat("ru-RU", {
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
- const parts = {};
175
- for (const part of DATE_FORMATTER.formatToParts(date)) {
176
- parts[part.type] = part.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);
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, default: null },
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.b24Client;
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.b24Client;
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.b24Client;
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.b24Client;
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.b24Client;
568
+ const b24 = this.ensureClient();
569
+ // Порядок обязателен: showError внутри ensureClient сбрасывает loadFailed,
570
+ // поэтому флаг ставится после проверки
475
571
  if (!b24) {
476
- this.showError("Клиент Bitrix24 не инициализирован");
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.b24Client;
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.b24Client;
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
- // Пачки, снятые подтверждённым batch: размер множества — честный счётчик для отчёта
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
- // callListMethod проходит очередь целиком: страница из 50 записей с пустым
565
- // PROCESS_ID иначе закрывает собой зарезервированные пачки и цикл встаёт
566
- const pageResult = await b24.callListMethod("event.offline.list", {
567
- start: 0,
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
- const rows = pageResult.getData() || [];
575
- if (!rows.length) break;
783
+ if (!page.rows.length) break;
576
784
 
577
- // Дедупликация обязательна: очередь обычно принадлежит нескольким пачкам
578
- const processIds = [...new Set(rows.map((row) => row.PROCESS_ID).filter((id) => id))];
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 commands = {};
595
- chunk.forEach((processId, index) => {
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
- if (failures.length) {
628
- this.showError(`Очищено пачек: ${clearedProcessIds.size}. Ошибки: ${failures.join("; ")}`);
629
- } else if (reachedLimit) {
630
- this.showError(`Достигнут потолок в ${MAX_CLEAR_ITERATIONS} итераций, очередь очищена не полностью. Очищено пачек: ${clearedProcessIds.size}`);
631
- } else if (stoppedOnRepeat) {
632
- this.showError(`Очищено пачек: ${clearedProcessIds.size}. Остановка: ${stoppedOnRepeat}`);
633
- } else if (stoppedOnEmpty) {
634
- this.showMessage(`Очищено пачек: ${clearedProcessIds.size}. ${stoppedOnEmpty}`);
635
- } else {
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.b24Client;
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
- const emptyBefore = this.countEmptyProcessId();
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
- const removedCount = emptyBefore - this.countEmptyProcessId();
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
- this.showMessage(`Снято записей: ${removedCount}`);
881
+ // Формулировка нейтральна к авторству: часть записей мог снять
882
+ // серверный коллектор потребителя за паузу перед перечиткой
883
+ this.showMessage(`Ушло из очереди записей: ${removedCount} из ${emptyIdsBefore.size}`);
678
884
  } else {
679
- this.showMessage("Портал не снял ни одной записи с пустым PROCESS_ID");
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.b24Client;
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
- if (!failed) {
814
- const removed = await this.deleteOfflineEntities(eventEntity);
815
- if (removed) {
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
- /* Подсказки. Старые имена .tooltipParent и .tooltip — алиасы для мостов совместимости
944
- у потребителей (b24_change_entity, b24_desktop), снять в следующей minor после их обновления */
945
- .subscriptions__tooltip-parent,
946
- .tooltipParent {
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>