@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/ConfigLogs.vue +94 -23
- package/README.md +111 -8
- package/Subscriptions.vue +1146 -300
- package/package.json +26 -8
- package/types/ConfigLogs.vue.d.ts +33 -0
- package/types/Subscriptions.vue.d.ts +167 -0
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
+
const b24 = this.ensureClient();
|
|
471
|
+
if (!b24) return;
|
|
161
472
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
auth_connector: this.
|
|
501
|
+
handler: handler,
|
|
502
|
+
auth_connector: this.authConnector,
|
|
178
503
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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 (
|
|
207
|
-
console.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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
657
|
+
if (pageResult.isSuccess === false) {
|
|
658
|
+
return { rows: [], error: this.resultErrors(pageResult) };
|
|
659
|
+
}
|
|
660
|
+
return { rows: pageResult.getData() || [], error: "" };
|
|
241
661
|
},
|
|
242
662
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 (!
|
|
284
|
-
|
|
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
|
-
|
|
303
|
-
setTimeout(async () => {
|
|
304
|
-
await this.loadOfflineEntitiesList();
|
|
305
|
-
this.autoRefreshEntities();
|
|
306
|
-
}, 3000);
|
|
929
|
+
this.startAutoRefresh();
|
|
307
930
|
} else {
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
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 (
|
|
374
|
-
|
|
375
|
-
error
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|