@andrey4emk/npm-app-front-b24 0.2.0 → 0.3.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 +87 -22
- package/README.md +91 -7
- package/Subscriptions.vue +831 -279
- package/index.d.ts +39 -0
- package/package.json +6 -4
package/Subscriptions.vue
CHANGED
|
@@ -1,142 +1,214 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="Subscriptions">
|
|
2
|
+
<div class="Subscriptions subscriptions">
|
|
3
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>
|
|
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
|
+
<!-- Тумблер автообновления сущностей. При наведении показывается подсказка -->
|
|
19
|
+
<label class="subscriptions__switch subscriptions__tooltip-parent switch tooltipParent">
|
|
20
|
+
<input type="checkbox" v-model="autoRefresh" @change="autoRefreshEntities" />
|
|
21
|
+
<span class="subscriptions__slider slider"></span>
|
|
22
|
+
<span class="subscriptions__tooltip tooltip">Автообновление каждые 3 секунды</span>
|
|
23
|
+
</label>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Обратная связь показывается и при пустом списке: иначе ошибка загрузки не видна -->
|
|
27
|
+
<div v-if="error" class="subscriptions__message subscriptions__message--error">{{ error }}</div>
|
|
28
|
+
<div v-else-if="message" class="subscriptions__message subscriptions__message--success">{{ message }}</div>
|
|
29
|
+
|
|
30
|
+
<div v-if="entities.list.length > 0">
|
|
31
|
+
<div class="subscriptions__actions">
|
|
32
|
+
<button @click="deleteOfflineEntitiesAll" :disabled="busy">{{ confirmAllLabel }}</button>
|
|
33
|
+
<button @click="deleteOfflineEntitiesAllEmpty" :disabled="busy">{{ confirmEmptyLabel }}</button>
|
|
34
|
+
<span>Всего измененных сущностей {{ entities.total }}</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<table class="main" v-if="entities.list.length">
|
|
38
|
+
<thead>
|
|
39
|
+
<tr>
|
|
40
|
+
<th>#</th>
|
|
41
|
+
<th>Удалить</th>
|
|
42
|
+
<th>Обновить</th>
|
|
43
|
+
<th>Дата</th>
|
|
44
|
+
<th>PROCESS_ID</th>
|
|
45
|
+
<th>EVENT_NAME</th>
|
|
46
|
+
<th>Ссылка</th>
|
|
47
|
+
<th>EVENT_DATA</th>
|
|
48
|
+
<th>who_update</th>
|
|
49
|
+
<th>entity</th>
|
|
50
|
+
</tr>
|
|
51
|
+
</thead>
|
|
52
|
+
<tbody>
|
|
53
|
+
<tr v-for="(entity, index) in entities.list" :key="entity.PROCESS_ID + '-' + entity.ID">
|
|
54
|
+
<td>{{ index + 1 }}</td>
|
|
55
|
+
<!-- Удалить -->
|
|
56
|
+
<td>
|
|
57
|
+
<button @click="deleteOfflineEntities(entity)" :disabled="busy">Удалить</button>
|
|
58
|
+
</td>
|
|
59
|
+
<!-- Изменить -->
|
|
60
|
+
<td>
|
|
61
|
+
<button v-if="entity.EVENT_DATA?.FIELDS?.ID && (entity.EVENT_NAME === 'ONCRMDEALUPDATE' || String(entity.EVENT_NAME || '').includes('ONCRMDYNAMICITEMUPDATE_'))" @click="entityUpdate(entity)" :disabled="busy">Изменить</button>
|
|
62
|
+
</td>
|
|
63
|
+
<td>{{ formatDate(entity.TIMESTAMP_X) }}</td>
|
|
64
|
+
<td>{{ entity.PROCESS_ID }}</td>
|
|
65
|
+
<td>{{ entity.EVENT_NAME }}</td>
|
|
66
|
+
<td>
|
|
67
|
+
<a v-if="entityLink(entity)" :href="entityLink(entity)" target="_blank" rel="noopener noreferrer">{{ entity.EVENT_DATA?.FIELDS?.ID }}</a>
|
|
68
|
+
<span v-else>{{ entity.EVENT_DATA?.FIELDS?.ID }}</span>
|
|
69
|
+
</td>
|
|
70
|
+
<td>
|
|
71
|
+
{{ entity.EVENT_DATA }}
|
|
72
|
+
</td>
|
|
73
|
+
<td>{{ entity.EVENT_ADDITIONAL?.user_id }}</td>
|
|
74
|
+
<td class="subscriptions__tooltip-parent tooltipParent">
|
|
75
|
+
<span>Подробней</span>
|
|
76
|
+
<span class="subscriptions__tooltip tooltip"> {{ entity }}</span>
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
</div>
|
|
82
|
+
<div v-else>Список сущностей пуст.</div>
|
|
120
83
|
</div>
|
|
121
|
-
</div>
|
|
122
84
|
|
|
123
|
-
|
|
124
|
-
|
|
85
|
+
<!-- Вкладка 2 -->
|
|
86
|
+
<div v-if="tabs.tab2">
|
|
87
|
+
<div v-if="error" class="subscriptions__message subscriptions__message--error">{{ error }}</div>
|
|
88
|
+
<div v-else-if="message" class="subscriptions__message subscriptions__message--success">{{ message }}</div>
|
|
89
|
+
|
|
90
|
+
<div>
|
|
91
|
+
<h3>Подписаны на события:</h3>
|
|
92
|
+
<table class="main" v-if="subscribedEvents.length">
|
|
93
|
+
<thead>
|
|
94
|
+
<tr>
|
|
95
|
+
<th>Событие</th>
|
|
96
|
+
<th>connector_id</th>
|
|
97
|
+
<th>offline</th>
|
|
98
|
+
<th>Отписаться</th>
|
|
99
|
+
</tr>
|
|
100
|
+
</thead>
|
|
101
|
+
<tbody>
|
|
102
|
+
<!-- event.get отдаёт { event, connector_id, offline } без id — ключ собираем из пары событие + коннектор -->
|
|
103
|
+
<tr v-for="event in subscribedEvents" :key="event.event + '-' + event.connector_id">
|
|
104
|
+
<td>
|
|
105
|
+
{{ event.event }}
|
|
106
|
+
</td>
|
|
107
|
+
<td>
|
|
108
|
+
{{ event.connector_id }}
|
|
109
|
+
</td>
|
|
110
|
+
<td>
|
|
111
|
+
{{ event.offline }}
|
|
112
|
+
</td>
|
|
113
|
+
<td>
|
|
114
|
+
<button @click="unbindSubscribedEvents(event)" :disabled="busy">Отписаться от событий</button>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
<p v-else>Подписок на события нет.</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div>
|
|
123
|
+
<h3>На какие события можно подписаться:</h3>
|
|
124
|
+
<table class="main" v-if="events.length">
|
|
125
|
+
<thead>
|
|
126
|
+
<tr>
|
|
127
|
+
<th>Событие</th>
|
|
128
|
+
<th>Подписаться</th>
|
|
129
|
+
</tr>
|
|
130
|
+
</thead>
|
|
131
|
+
<tbody>
|
|
132
|
+
<tr v-for="event in events" :key="event">
|
|
133
|
+
<td>{{ event }}</td>
|
|
134
|
+
<td><button @click="subscribeToEvents(event)" :disabled="busy || isSubscribed(event)" :class="{ 'disabled-button': isSubscribed(event) }">Подписаться</button></td>
|
|
135
|
+
</tr>
|
|
136
|
+
</tbody>
|
|
137
|
+
</table>
|
|
138
|
+
<p v-else>Список событий пуст.</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|
|
125
142
|
</div>
|
|
126
143
|
</template>
|
|
127
144
|
|
|
128
145
|
<script>
|
|
129
|
-
|
|
146
|
+
// Интервал автообновления списка сущностей, мс
|
|
147
|
+
const AUTO_REFRESH_MS = 3000;
|
|
148
|
+
// Сколько кнопка удерживает состояние «Точно удалить?», мс
|
|
149
|
+
const CONFIRM_TIMEOUT_MS = 4000;
|
|
150
|
+
// Потолок команд в одном batch — ограничение платформы Bitrix24
|
|
151
|
+
const BATCH_CHUNK_SIZE = 50;
|
|
152
|
+
// Потолок итераций цикла очистки очереди — страховка от вечного цикла
|
|
153
|
+
const MAX_CLEAR_ITERATIONS = 100;
|
|
154
|
+
|
|
155
|
+
// Формат даты создаётся один раз: Intl.DateTimeFormat дорого конструировать на каждую строку таблицы.
|
|
156
|
+
// Указан только hourCycle: при hour12: false часть движков отдаёт 24 вместо 00 для полуночи.
|
|
157
|
+
const DATE_FORMATTER = new Intl.DateTimeFormat("ru-RU", {
|
|
158
|
+
day: "2-digit",
|
|
159
|
+
month: "2-digit",
|
|
160
|
+
hour: "2-digit",
|
|
161
|
+
minute: "2-digit",
|
|
162
|
+
second: "2-digit",
|
|
163
|
+
hourCycle: "h23",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Приводит метку времени к виду dd.MM HH:mm:ss.
|
|
168
|
+
* Склейка через formatToParts: обычный format() в ru-RU ставит запятую между датой и временем.
|
|
169
|
+
*/
|
|
170
|
+
function formatTimestamp(value) {
|
|
171
|
+
if (!value) return "";
|
|
172
|
+
const date = new Date(value);
|
|
173
|
+
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;
|
|
177
|
+
}
|
|
178
|
+
return `${parts.day}.${parts.month} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Режет массив на куски заданного размера.
|
|
183
|
+
*/
|
|
184
|
+
function chunkArray(items, size) {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
for (let index = 0; index < items.length; index += size) {
|
|
187
|
+
chunks.push(items.slice(index, index + size));
|
|
188
|
+
}
|
|
189
|
+
return chunks;
|
|
190
|
+
}
|
|
130
191
|
|
|
131
192
|
export default {
|
|
132
193
|
name: "Subscriptions",
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
194
|
+
props: {
|
|
195
|
+
// Клиент Bitrix24 (B24Frame). Приоритетный источник
|
|
196
|
+
b24: { type: Object, default: null },
|
|
197
|
+
// Значение auth_connector для методов работы с событиями
|
|
198
|
+
authConnector: { type: String, default: "" },
|
|
199
|
+
// Переопределение адреса портала: принимается и https://portal.bitrix24.ru, и голый portal.bitrix24.ru
|
|
200
|
+
portalDomain: { type: String, default: "" },
|
|
201
|
+
},
|
|
202
|
+
inject: {
|
|
203
|
+
// Тот же ключ, под которым потребители кладут клиент через app.provide
|
|
204
|
+
injectedB24: { from: "$b24", default: null },
|
|
205
|
+
},
|
|
138
206
|
data() {
|
|
139
207
|
return {
|
|
208
|
+
tabs: {
|
|
209
|
+
tab1: true,
|
|
210
|
+
tab2: false,
|
|
211
|
+
},
|
|
140
212
|
events: [],
|
|
141
213
|
subscribedEvents: [],
|
|
142
214
|
entities: {
|
|
@@ -144,262 +216,743 @@ export default {
|
|
|
144
216
|
total: 0,
|
|
145
217
|
},
|
|
146
218
|
autoRefresh: true,
|
|
147
|
-
|
|
219
|
+
loading: false,
|
|
220
|
+
busy: false,
|
|
221
|
+
error: "",
|
|
222
|
+
message: "",
|
|
223
|
+
refreshTimerId: null,
|
|
224
|
+
isUnmounted: false,
|
|
225
|
+
// Какая кнопка ждёт второго клика: "" | "all" | "empty"
|
|
226
|
+
confirmAction: "",
|
|
227
|
+
confirmTimerId: null,
|
|
148
228
|
};
|
|
149
229
|
},
|
|
150
|
-
computed: {
|
|
230
|
+
computed: {
|
|
231
|
+
// Клиент Bitrix24: prop → inject → глобальный window.$b24
|
|
232
|
+
b24Client() {
|
|
233
|
+
if (this.b24) return this.b24;
|
|
234
|
+
if (this.injectedB24) return this.injectedB24;
|
|
235
|
+
if (typeof window !== "undefined" && window.$b24) return window.$b24;
|
|
236
|
+
return null;
|
|
237
|
+
},
|
|
238
|
+
// Адрес портала без завершающего слэша, "" — определить не удалось
|
|
239
|
+
portalOrigin() {
|
|
240
|
+
try {
|
|
241
|
+
if (this.portalDomain) {
|
|
242
|
+
const domain = String(this.portalDomain).replace(/\/+$/, "");
|
|
243
|
+
return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
|
|
244
|
+
}
|
|
245
|
+
const client = this.b24Client;
|
|
246
|
+
if (!client) return "";
|
|
247
|
+
if (typeof client.getTargetOrigin === "function") {
|
|
248
|
+
const origin = client.getTargetOrigin();
|
|
249
|
+
if (origin) return String(origin).replace(/\/+$/, "");
|
|
250
|
+
}
|
|
251
|
+
// getAuthData() отдаёт false, когда токен истёк
|
|
252
|
+
const authData = client.auth && typeof client.auth.getAuthData === "function" ? client.auth.getAuthData() : null;
|
|
253
|
+
if (authData && authData.domain) {
|
|
254
|
+
const domain = String(authData.domain).replace(/\/+$/, "");
|
|
255
|
+
return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
} catch (originError) {
|
|
259
|
+
console.error("Не удалось определить адрес портала:", originError);
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
confirmAllLabel() {
|
|
264
|
+
return this.confirmAction === "all" ? "Точно удалить?" : "Удалить все";
|
|
265
|
+
},
|
|
266
|
+
confirmEmptyLabel() {
|
|
267
|
+
return this.confirmAction === "empty" ? "Точно удалить?" : "Удалить все пустые";
|
|
268
|
+
},
|
|
269
|
+
},
|
|
151
270
|
methods: {
|
|
271
|
+
selectTab(name) {
|
|
272
|
+
for (const key of Object.keys(this.tabs)) {
|
|
273
|
+
this.tabs[key] = key === name;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
152
276
|
formatDate(timestamp) {
|
|
153
|
-
|
|
154
|
-
return DateTime.fromISO(timestamp).toFormat("dd.MM HH:mm:ss");
|
|
277
|
+
return formatTimestamp(timestamp);
|
|
155
278
|
},
|
|
156
279
|
pause(ms) {
|
|
157
280
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
158
281
|
},
|
|
282
|
+
|
|
283
|
+
// --- Обратная связь пользователю ---
|
|
284
|
+
|
|
285
|
+
clearFeedback() {
|
|
286
|
+
this.error = "";
|
|
287
|
+
this.message = "";
|
|
288
|
+
},
|
|
289
|
+
showError(text) {
|
|
290
|
+
this.error = String(text);
|
|
291
|
+
this.message = "";
|
|
292
|
+
},
|
|
293
|
+
showMessage(text) {
|
|
294
|
+
this.message = String(text);
|
|
295
|
+
this.error = "";
|
|
296
|
+
},
|
|
297
|
+
// Текст исключения
|
|
298
|
+
errorText(err) {
|
|
299
|
+
if (err instanceof Error) return err.message;
|
|
300
|
+
if (typeof err === "string") return err;
|
|
301
|
+
return String(err);
|
|
302
|
+
},
|
|
303
|
+
// Текст ошибок ответа SDK (Result / AjaxResult)
|
|
304
|
+
resultErrors(result) {
|
|
305
|
+
let texts = [];
|
|
306
|
+
try {
|
|
307
|
+
if (result && typeof result.getErrorMessagesByKey === "function") {
|
|
308
|
+
// SDK >= 2.0.0: видно, какая команда batch упала
|
|
309
|
+
const byKey = result.getErrorMessagesByKey() || {};
|
|
310
|
+
texts = Object.entries(byKey).map(([key, messages]) => `${key}: ${[].concat(messages).join(", ")}`);
|
|
311
|
+
} else if (result && typeof result.getErrorMessages === "function") {
|
|
312
|
+
texts = result.getErrorMessages() || [];
|
|
313
|
+
}
|
|
314
|
+
} catch (readError) {
|
|
315
|
+
console.error("Не удалось прочитать текст ошибки ответа:", readError);
|
|
316
|
+
}
|
|
317
|
+
const text = texts.filter((item) => item).join("; ");
|
|
318
|
+
return text || "портал не вернул текст ошибки";
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
// --- Подтверждение вторым кликом вместо confirm() ---
|
|
322
|
+
|
|
323
|
+
requestConfirm(action) {
|
|
324
|
+
if (this.confirmAction !== action) {
|
|
325
|
+
this.resetConfirm();
|
|
326
|
+
this.confirmAction = action;
|
|
327
|
+
this.confirmTimerId = setTimeout(() => this.resetConfirm(), CONFIRM_TIMEOUT_MS);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
this.resetConfirm();
|
|
331
|
+
return true;
|
|
332
|
+
},
|
|
333
|
+
resetConfirm() {
|
|
334
|
+
if (this.confirmTimerId !== null) {
|
|
335
|
+
clearTimeout(this.confirmTimerId);
|
|
336
|
+
this.confirmTimerId = null;
|
|
337
|
+
}
|
|
338
|
+
this.confirmAction = "";
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Определяет ENTITY_TYPE_ID смарт-процесса: сначала из FIELDS, затем из имени события.
|
|
343
|
+
* Общий разбор для ссылки и для обновления — иначе событие без ENTITY_TYPE_ID в FIELDS
|
|
344
|
+
* уходит в ветку сделки. Возвращает "" когда тип определить не удалось.
|
|
345
|
+
*/
|
|
346
|
+
resolveEntityTypeId(entity) {
|
|
347
|
+
const entityTypeId = entity?.EVENT_DATA?.FIELDS?.ENTITY_TYPE_ID;
|
|
348
|
+
if (Number(entityTypeId) > 0) return String(entityTypeId);
|
|
349
|
+
// В части событий FIELDS приходит без ENTITY_TYPE_ID — берём из имени события
|
|
350
|
+
const matched = String(entity?.EVENT_NAME || "").match(/^ONCRMDYNAMICITEMUPDATE_(\d+)$/);
|
|
351
|
+
if (matched && Number(matched[1]) > 0) return matched[1];
|
|
352
|
+
return "";
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
// Ссылка на сущность на текущем портале, "" — построить не удалось
|
|
356
|
+
entityLink(entity) {
|
|
357
|
+
const origin = this.portalOrigin;
|
|
358
|
+
if (!origin) return "";
|
|
359
|
+
const id = entity?.EVENT_DATA?.FIELDS?.ID;
|
|
360
|
+
if (!id) return "";
|
|
361
|
+
const entityTypeId = this.resolveEntityTypeId(entity);
|
|
362
|
+
if (entityTypeId) return `${origin}/crm/type/${entityTypeId}/details/${id}/`;
|
|
363
|
+
if (entity?.EVENT_NAME === "ONCRMDEALUPDATE") return `${origin}/crm/deal/details/${id}/`;
|
|
364
|
+
return "";
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
// --- Работа с событиями Bitrix24 ---
|
|
368
|
+
|
|
159
369
|
async eventsList() {
|
|
160
|
-
|
|
370
|
+
const b24 = this.b24Client;
|
|
371
|
+
if (!b24) {
|
|
372
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
161
375
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
376
|
+
const result = await b24.callMethod("events", {});
|
|
377
|
+
// Сравнение с false намеренно явное и повторяется во всех вызовах ниже:
|
|
378
|
+
// потребители подставляют шим-клиенты, у которых ответ без поля isSuccess.
|
|
379
|
+
// undefined тогда трактуется как отказ, хотя запрос прошёл.
|
|
380
|
+
// У настоящего AjaxResult геттер всегда булев, поэтому === false ловит те же отказы.
|
|
381
|
+
if (result.isSuccess === false) {
|
|
382
|
+
this.showError(`Не удалось получить список событий: ${this.resultErrors(result)}`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.events = result.getData()?.result || [];
|
|
386
|
+
} catch (callError) {
|
|
387
|
+
console.error("Ошибка при получении списка событий:", callError);
|
|
388
|
+
this.showError(`Ошибка при получении списка событий: ${this.errorText(callError)}`);
|
|
169
389
|
}
|
|
170
390
|
},
|
|
391
|
+
|
|
171
392
|
async subscribeToEvents(event) {
|
|
393
|
+
const b24 = this.b24Client;
|
|
394
|
+
if (!b24) {
|
|
395
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this.clearFeedback();
|
|
399
|
+
this.busy = true;
|
|
172
400
|
try {
|
|
173
|
-
|
|
401
|
+
// handler обязателен даже для оффлайн-событий: портал его не вызывает,
|
|
402
|
+
// но без параметра event.bind отвечает ошибкой
|
|
403
|
+
const handler = typeof window !== "undefined" && window.location ? window.location.origin : "";
|
|
404
|
+
const result = await b24.callMethod("event.bind", {
|
|
174
405
|
event: event,
|
|
175
406
|
event_type: "offline",
|
|
176
|
-
|
|
177
|
-
auth_connector: this.
|
|
407
|
+
handler: handler,
|
|
408
|
+
auth_connector: this.authConnector,
|
|
178
409
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
410
|
+
if (result.isSuccess === false) {
|
|
411
|
+
this.showError(`Не удалось подписаться на событие ${event}: ${this.resultErrors(result)}`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.showMessage(`Подписались на событие ${event}`);
|
|
415
|
+
await this.pause(1000);
|
|
416
|
+
await this.getSubscribedEvents();
|
|
417
|
+
} catch (callError) {
|
|
418
|
+
console.error("Ошибка при подписке на события:", callError);
|
|
419
|
+
this.showError(`Ошибка при подписке на событие ${event}: ${this.errorText(callError)}`);
|
|
420
|
+
} finally {
|
|
421
|
+
this.busy = false;
|
|
182
422
|
}
|
|
183
|
-
await this.pause(1000);
|
|
184
|
-
await this.getSubscribedEvents();
|
|
185
423
|
},
|
|
186
424
|
|
|
187
425
|
async getSubscribedEvents() {
|
|
426
|
+
const b24 = this.b24Client;
|
|
427
|
+
if (!b24) {
|
|
428
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
188
431
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
432
|
+
const result = await b24.callMethod("event.get", {});
|
|
433
|
+
if (result.isSuccess === false) {
|
|
434
|
+
this.showError(`Не удалось получить подписанные события: ${this.resultErrors(result)}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.subscribedEvents = result.getData()?.result || [];
|
|
438
|
+
} catch (callError) {
|
|
439
|
+
console.error("Ошибка при получении подписанных событий:", callError);
|
|
440
|
+
this.showError(`Ошибка при получении подписанных событий: ${this.errorText(callError)}`);
|
|
194
441
|
}
|
|
195
442
|
},
|
|
196
443
|
|
|
197
444
|
async unbindSubscribedEvents(event) {
|
|
445
|
+
const b24 = this.b24Client;
|
|
446
|
+
if (!b24) {
|
|
447
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.clearFeedback();
|
|
451
|
+
this.busy = true;
|
|
198
452
|
try {
|
|
199
|
-
await
|
|
453
|
+
const result = await b24.callMethod("event.unbind", {
|
|
200
454
|
event: event.event,
|
|
201
455
|
event_type: event.offline == 1 ? "offline" : "online",
|
|
202
456
|
auth_connector: event.connector_id,
|
|
203
457
|
});
|
|
458
|
+
if (result.isSuccess === false) {
|
|
459
|
+
this.showError(`Не удалось отписаться от события ${event.event}: ${this.resultErrors(result)}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.showMessage(`Отписались от события ${event.event}`);
|
|
204
463
|
await this.pause(1000);
|
|
205
464
|
await this.getSubscribedEvents();
|
|
206
|
-
} catch (
|
|
207
|
-
console.error("Ошибка при отписке от событий:",
|
|
465
|
+
} catch (callError) {
|
|
466
|
+
console.error("Ошибка при отписке от событий:", callError);
|
|
467
|
+
this.showError(`Ошибка при отписке от события ${event.event}: ${this.errorText(callError)}`);
|
|
468
|
+
} finally {
|
|
469
|
+
this.busy = false;
|
|
208
470
|
}
|
|
209
471
|
},
|
|
210
472
|
|
|
211
473
|
async loadOfflineEntitiesList() {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
474
|
+
const b24 = this.b24Client;
|
|
475
|
+
if (!b24) {
|
|
476
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
this.loading = true;
|
|
480
|
+
try {
|
|
481
|
+
// callListMethod отдаёт плоский массив, а не { result }
|
|
482
|
+
const result = await b24.callListMethod("event.offline.list", {
|
|
483
|
+
start: 0,
|
|
484
|
+
auth_connector: this.authConnector,
|
|
485
|
+
});
|
|
486
|
+
if (result.isSuccess === false) {
|
|
487
|
+
// Старый список полезнее пустого — entities.list не обнуляем
|
|
488
|
+
this.showError(`Не удалось получить список сущностей: ${this.resultErrors(result)}`);
|
|
489
|
+
return;
|
|
220
490
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
491
|
+
const rows = result.getData() || [];
|
|
492
|
+
this.entities.list = rows;
|
|
493
|
+
this.entities.total = rows.length;
|
|
494
|
+
} catch (callError) {
|
|
495
|
+
console.error("Ошибка при получении списка оффлайн-сущностей:", callError);
|
|
496
|
+
this.showError(`Ошибка при получении списка сущностей: ${this.errorText(callError)}`);
|
|
497
|
+
} finally {
|
|
498
|
+
this.loading = false;
|
|
499
|
+
}
|
|
230
500
|
},
|
|
231
501
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
502
|
+
/**
|
|
503
|
+
* Снимает одну запись из оффлайн-очереди. Возвращает признак успеха.
|
|
504
|
+
*/
|
|
505
|
+
async deleteOfflineEntities(entity) {
|
|
506
|
+
const b24 = this.b24Client;
|
|
507
|
+
if (!b24) {
|
|
508
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
// event.offline.clear работает только по process_id: для свободной записи вызов бессмысленный
|
|
512
|
+
if (!entity?.PROCESS_ID) {
|
|
513
|
+
this.showError("У записи пустой PROCESS_ID — снимается кнопкой «Удалить все пустые»");
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
this.clearFeedback();
|
|
517
|
+
this.busy = true;
|
|
518
|
+
try {
|
|
519
|
+
const result = await b24.callMethod("event.offline.clear", {
|
|
520
|
+
process_id: entity.PROCESS_ID,
|
|
521
|
+
id: [entity.ID],
|
|
522
|
+
auth_connector: this.authConnector,
|
|
523
|
+
});
|
|
524
|
+
if (result.isSuccess === false) {
|
|
525
|
+
this.showError(`Не удалось удалить запись из очереди: ${this.resultErrors(result)}`);
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
this.showMessage(`Запись ${entity.ID} удалена из очереди`);
|
|
529
|
+
// Портал отдаёт очередь с задержкой
|
|
530
|
+
await this.pause(1000);
|
|
531
|
+
await this.loadOfflineEntitiesList();
|
|
532
|
+
return true;
|
|
533
|
+
} catch (callError) {
|
|
534
|
+
console.error("Ошибка при удалении записи из оффлайн-очереди:", callError);
|
|
535
|
+
this.showError(`Ошибка при удалении записи из очереди: ${this.errorText(callError)}`);
|
|
536
|
+
return false;
|
|
537
|
+
} finally {
|
|
538
|
+
this.busy = false;
|
|
539
|
+
}
|
|
241
540
|
},
|
|
242
541
|
|
|
243
542
|
async deleteOfflineEntitiesAll() {
|
|
244
|
-
if (!
|
|
543
|
+
if (!this.requestConfirm("all")) return;
|
|
544
|
+
const b24 = this.b24Client;
|
|
545
|
+
if (!b24) {
|
|
546
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
245
547
|
return;
|
|
246
548
|
}
|
|
549
|
+
this.clearFeedback();
|
|
550
|
+
this.busy = true;
|
|
551
|
+
// Фоновое обновление конкурирует с удалением и мигает таблицей
|
|
552
|
+
const wasAutoRefresh = this.autoRefresh;
|
|
553
|
+
this.stopAutoRefresh();
|
|
247
554
|
|
|
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
|
-
|
|
555
|
+
const failures = [];
|
|
556
|
+
// Пачки, снятые подтверждённым batch: размер множества — честный счётчик для отчёта
|
|
557
|
+
const clearedProcessIds = new Set();
|
|
558
|
+
let reachedLimit = false;
|
|
559
|
+
let stoppedOnEmpty = "";
|
|
560
|
+
let stoppedOnRepeat = "";
|
|
561
|
+
let iteration = 0;
|
|
562
|
+
try {
|
|
563
|
+
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));
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
const rows = pageResult.getData() || [];
|
|
575
|
+
if (!rows.length) break;
|
|
576
|
+
|
|
577
|
+
// Дедупликация обязательна: очередь обычно принадлежит нескольким пачкам
|
|
578
|
+
const processIds = [...new Set(rows.map((row) => row.PROCESS_ID).filter((id) => id))];
|
|
579
|
+
if (!processIds.length) {
|
|
580
|
+
// Свободные записи этим методом не снимаются — иначе цикл крутится до потолка итераций
|
|
581
|
+
stoppedOnEmpty = `Остались записи с пустым PROCESS_ID (${rows.length}) — снимаются кнопкой «Удалить все пустые»`;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Пачки, снятые на прошлых итерациях, портал может отдавать повторно
|
|
586
|
+
const freshProcessIds = processIds.filter((processId) => !clearedProcessIds.has(processId));
|
|
587
|
+
if (!freshProcessIds.length) {
|
|
588
|
+
stoppedOnRepeat = "портал продолжает отдавать уже снятые пачки";
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Резка по 50: в один batch больше команд платформа не принимает
|
|
593
|
+
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
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Если пачка не удаляется, следующая итерация увидит те же PROCESS_ID
|
|
614
|
+
if (failures.length) break;
|
|
615
|
+
// Портал отдаёт очередь с задержкой — перечитывать сразу бессмысленно
|
|
616
|
+
await this.pause(1000);
|
|
617
|
+
}
|
|
618
|
+
reachedLimit = iteration >= MAX_CLEAR_ITERATIONS;
|
|
619
|
+
} catch (callError) {
|
|
620
|
+
console.error("Ошибка при массовом удалении сущностей:", callError);
|
|
621
|
+
failures.push(this.errorText(callError));
|
|
622
|
+
} finally {
|
|
623
|
+
this.busy = false;
|
|
624
|
+
if (wasAutoRefresh) this.startAutoRefresh();
|
|
625
|
+
}
|
|
626
|
+
|
|
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}`);
|
|
277
637
|
}
|
|
638
|
+
|
|
278
639
|
await this.pause(500);
|
|
640
|
+
// Размонтирование могло случиться, пока шла очистка
|
|
641
|
+
if (this.isUnmounted) return;
|
|
279
642
|
await this.loadOfflineEntitiesList();
|
|
280
643
|
},
|
|
281
644
|
|
|
645
|
+
// Сколько записей в текущем списке без PROCESS_ID
|
|
646
|
+
countEmptyProcessId() {
|
|
647
|
+
return this.entities.list.filter((row) => !row?.PROCESS_ID).length;
|
|
648
|
+
},
|
|
649
|
+
|
|
282
650
|
async deleteOfflineEntitiesAllEmpty() {
|
|
283
|
-
if (!
|
|
651
|
+
if (!this.requestConfirm("empty")) return;
|
|
652
|
+
const b24 = this.b24Client;
|
|
653
|
+
if (!b24) {
|
|
654
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
284
655
|
return;
|
|
285
656
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
657
|
+
this.clearFeedback();
|
|
658
|
+
this.busy = true;
|
|
659
|
+
try {
|
|
660
|
+
// Считаем свободные записи до вызова: портал отвечает успехом, даже когда ничего не снял
|
|
661
|
+
const emptyBefore = this.countEmptyProcessId();
|
|
662
|
+
// Снимаем записи с пустым PROCESS_ID — по process_id их не удалить
|
|
663
|
+
const result = await b24.callMethod("event.offline.clear", {
|
|
664
|
+
process_id: "",
|
|
665
|
+
auth_connector: this.authConnector,
|
|
666
|
+
});
|
|
667
|
+
if (result.isSuccess === false) {
|
|
668
|
+
this.showError(`Не удалось удалить записи с пустым PROCESS_ID: ${this.resultErrors(result)}`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
await this.pause(500);
|
|
672
|
+
await this.loadOfflineEntitiesList();
|
|
673
|
+
// Ошибку перезагрузки не затираем: без свежего списка разницу считать не по чему
|
|
674
|
+
if (this.error) return;
|
|
675
|
+
const removedCount = emptyBefore - this.countEmptyProcessId();
|
|
676
|
+
if (removedCount > 0) {
|
|
677
|
+
this.showMessage(`Снято записей: ${removedCount}`);
|
|
678
|
+
} else {
|
|
679
|
+
this.showMessage("Портал не снял ни одной записи с пустым PROCESS_ID");
|
|
680
|
+
}
|
|
681
|
+
} catch (callError) {
|
|
682
|
+
console.error("Ошибка при удалении сущностей с пустым PROCESS_ID:", callError);
|
|
683
|
+
this.showError(`Ошибка при удалении записей с пустым PROCESS_ID: ${this.errorText(callError)}`);
|
|
684
|
+
} finally {
|
|
685
|
+
this.busy = false;
|
|
686
|
+
}
|
|
291
687
|
},
|
|
292
688
|
|
|
293
689
|
isSubscribed(event) {
|
|
294
|
-
// console.log(event);
|
|
295
|
-
// console.log(this.subscribedEvents);
|
|
296
690
|
return this.subscribedEvents.some((subEvent) => subEvent.event == event);
|
|
297
691
|
},
|
|
298
692
|
|
|
693
|
+
// --- Автообновление ---
|
|
694
|
+
|
|
695
|
+
startAutoRefresh() {
|
|
696
|
+
// Защита от двойного таймера
|
|
697
|
+
this.stopAutoRefresh();
|
|
698
|
+
if (!this.autoRefresh || this.isUnmounted) return;
|
|
699
|
+
this.refreshTimerId = setTimeout(async () => {
|
|
700
|
+
this.refreshTimerId = null;
|
|
701
|
+
if (this.isUnmounted) return;
|
|
702
|
+
await this.loadOfflineEntitiesList();
|
|
703
|
+
// Размонтирование могло случиться, пока висел запрос
|
|
704
|
+
if (this.isUnmounted) return;
|
|
705
|
+
this.startAutoRefresh();
|
|
706
|
+
}, AUTO_REFRESH_MS);
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
stopAutoRefresh() {
|
|
710
|
+
if (this.refreshTimerId !== null) {
|
|
711
|
+
clearTimeout(this.refreshTimerId);
|
|
712
|
+
this.refreshTimerId = null;
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// Обработчик @change тумблера: выключение отменяет уже отложенный вызов
|
|
299
717
|
autoRefreshEntities() {
|
|
300
|
-
// console.log("Запущена функция автообновления сущностей autoRefreshEntities, проверяем состояние autoRefresh");
|
|
301
718
|
if (this.autoRefresh) {
|
|
302
|
-
|
|
303
|
-
setTimeout(async () => {
|
|
304
|
-
await this.loadOfflineEntitiesList();
|
|
305
|
-
this.autoRefreshEntities();
|
|
306
|
-
}, 3000);
|
|
719
|
+
this.startAutoRefresh();
|
|
307
720
|
} else {
|
|
308
|
-
|
|
721
|
+
this.stopAutoRefresh();
|
|
309
722
|
}
|
|
310
723
|
},
|
|
311
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Перезаписывает сущность теми же данными и снимает её из оффлайн-очереди.
|
|
727
|
+
*/
|
|
312
728
|
async entityUpdate(eventEntity) {
|
|
313
|
-
|
|
314
|
-
|
|
729
|
+
const b24 = this.b24Client;
|
|
730
|
+
if (!b24) {
|
|
731
|
+
this.showError("Клиент Bitrix24 не инициализирован");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const fields = eventEntity?.EVENT_DATA?.FIELDS || {};
|
|
735
|
+
const entityId = fields.ID;
|
|
736
|
+
if (!entityId) {
|
|
737
|
+
this.showError("У события нет ID сущности");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Тип берётся тем же разбором, что и для ссылки: FIELDS плюс имя события
|
|
742
|
+
const entityTypeId = this.resolveEntityTypeId(eventEntity);
|
|
743
|
+
|
|
744
|
+
let failed = false;
|
|
745
|
+
this.clearFeedback();
|
|
746
|
+
this.busy = true;
|
|
315
747
|
try {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
id: eventEntity.EVENT_DATA.FIELDS.ID,
|
|
748
|
+
if (entityTypeId) {
|
|
749
|
+
// Смарт-процесс
|
|
750
|
+
const getResult = await b24.callMethod("crm.item.get", {
|
|
751
|
+
entityTypeId: entityTypeId,
|
|
752
|
+
id: entityId,
|
|
322
753
|
});
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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,
|
|
330
764
|
fields: {
|
|
331
|
-
title:
|
|
765
|
+
title: item.title,
|
|
332
766
|
},
|
|
333
767
|
});
|
|
334
|
-
|
|
335
|
-
if (updateResult.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
console.error("Ошибка при обновлении сущности.");
|
|
340
|
-
error = true;
|
|
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}`);
|
|
341
773
|
}
|
|
342
|
-
} else {
|
|
343
|
-
error = true;
|
|
344
774
|
}
|
|
345
|
-
} else {
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
id:
|
|
775
|
+
} else if (eventEntity?.EVENT_NAME === "ONCRMDEALUPDATE") {
|
|
776
|
+
// Сделка
|
|
777
|
+
const getResult = await b24.callMethod("crm.deal.get", {
|
|
778
|
+
id: entityId,
|
|
349
779
|
});
|
|
350
|
-
deal =
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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,
|
|
357
788
|
fields: {
|
|
358
789
|
TITLE: deal.TITLE,
|
|
359
790
|
},
|
|
360
791
|
});
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
console.error("Ошибка при обновлении сущности.");
|
|
367
|
-
error = true;
|
|
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}`);
|
|
368
797
|
}
|
|
369
|
-
} else {
|
|
370
|
-
error = true;
|
|
371
798
|
}
|
|
799
|
+
} else {
|
|
800
|
+
// Ни ENTITY_TYPE_ID, ни имя события не говорят, что это за сущность —
|
|
801
|
+
// вслепую дёргать crm.deal.get нельзя: снимется чужая запись с зелёным успехом
|
|
802
|
+
failed = true;
|
|
803
|
+
this.showError(`Не удалось определить тип сущности для события ${eventEntity?.EVENT_NAME || "без имени"}`);
|
|
372
804
|
}
|
|
373
|
-
} catch (
|
|
374
|
-
|
|
375
|
-
error
|
|
805
|
+
} catch (callError) {
|
|
806
|
+
failed = true;
|
|
807
|
+
console.error("Ошибка при обновлении сущности:", callError);
|
|
808
|
+
this.showError(`Ошибка при обновлении сущности ${entityId}: ${this.errorText(callError)}`);
|
|
809
|
+
} finally {
|
|
810
|
+
this.busy = false;
|
|
376
811
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
await this.deleteOfflineEntities(eventEntity);
|
|
380
|
-
|
|
812
|
+
|
|
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("Сущность не обновлена, из очереди не удалена");
|
|
381
825
|
}
|
|
382
826
|
},
|
|
383
827
|
},
|
|
384
|
-
watch: {},
|
|
385
828
|
mounted() {
|
|
829
|
+
// Три загрузки идут параллельно — await здесь намеренно не ставится
|
|
386
830
|
this.getSubscribedEvents();
|
|
387
831
|
this.eventsList();
|
|
388
832
|
this.loadOfflineEntitiesList();
|
|
389
833
|
this.autoRefreshEntities();
|
|
390
834
|
},
|
|
835
|
+
beforeUnmount() {
|
|
836
|
+
this.isUnmounted = true;
|
|
837
|
+
this.stopAutoRefresh();
|
|
838
|
+
this.resetConfirm();
|
|
839
|
+
},
|
|
391
840
|
};
|
|
392
841
|
</script>
|
|
393
842
|
|
|
394
843
|
<style>
|
|
844
|
+
/* Цвета вынесены в кастомные свойства: потребитель перекрашивает компонент без борьбы за специфичность */
|
|
845
|
+
.subscriptions {
|
|
846
|
+
--subscriptions-switch-off: #ccc;
|
|
847
|
+
--subscriptions-switch-on: #4caf50;
|
|
848
|
+
--subscriptions-switch-knob: #fff;
|
|
849
|
+
--subscriptions-tooltip-bg: #555;
|
|
850
|
+
--subscriptions-tooltip-ink: #fff;
|
|
851
|
+
--subscriptions-error-bg: #ffebee;
|
|
852
|
+
--subscriptions-error-ink: #c62828;
|
|
853
|
+
--subscriptions-success-bg: #e8f5e9;
|
|
854
|
+
--subscriptions-success-ink: #2e7d32;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
.subscriptions__tabs {
|
|
858
|
+
margin-bottom: 24px;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
.subscriptions__tab--active {
|
|
862
|
+
font-weight: 600;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
.subscriptions__empty {
|
|
866
|
+
padding: 12px;
|
|
867
|
+
color: var(--subscriptions-error-ink, #c62828);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.subscriptions__actions {
|
|
871
|
+
display: flex;
|
|
872
|
+
align-items: center;
|
|
873
|
+
gap: 8px;
|
|
874
|
+
margin: 12px 0;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
.subscriptions__message {
|
|
878
|
+
margin: 12px 0;
|
|
879
|
+
padding: 8px 12px;
|
|
880
|
+
border-radius: 4px;
|
|
881
|
+
font-size: 14px;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
.subscriptions__message--success {
|
|
885
|
+
background: var(--subscriptions-success-bg, #e8f5e9);
|
|
886
|
+
color: var(--subscriptions-success-ink, #2e7d32);
|
|
887
|
+
border: 1px solid #a5d6a7;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.subscriptions__message--error {
|
|
891
|
+
background: var(--subscriptions-error-bg, #ffebee);
|
|
892
|
+
color: var(--subscriptions-error-ink, #c62828);
|
|
893
|
+
border: 1px solid #ef9a9a;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/* Тумблер автообновления */
|
|
897
|
+
.subscriptions__switch {
|
|
898
|
+
position: relative;
|
|
899
|
+
display: inline-block;
|
|
900
|
+
width: 44px;
|
|
901
|
+
height: 24px;
|
|
902
|
+
vertical-align: middle;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.subscriptions__switch input {
|
|
906
|
+
opacity: 0;
|
|
907
|
+
width: 0;
|
|
908
|
+
height: 0;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.subscriptions__slider {
|
|
912
|
+
position: absolute;
|
|
913
|
+
cursor: pointer;
|
|
914
|
+
top: 0;
|
|
915
|
+
left: 0;
|
|
916
|
+
right: 0;
|
|
917
|
+
bottom: 0;
|
|
918
|
+
background-color: var(--subscriptions-switch-off, #ccc);
|
|
919
|
+
transition: 0.3s;
|
|
920
|
+
border-radius: 24px;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.subscriptions__slider::before {
|
|
924
|
+
content: "";
|
|
925
|
+
position: absolute;
|
|
926
|
+
height: 18px;
|
|
927
|
+
width: 18px;
|
|
928
|
+
left: 3px;
|
|
929
|
+
bottom: 3px;
|
|
930
|
+
background-color: var(--subscriptions-switch-knob, #fff);
|
|
931
|
+
transition: 0.3s;
|
|
932
|
+
border-radius: 50%;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
input:checked + .subscriptions__slider {
|
|
936
|
+
background-color: var(--subscriptions-switch-on, #4caf50);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
input:checked + .subscriptions__slider::before {
|
|
940
|
+
transform: translateX(20px);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/* Подсказки. Старые имена .tooltipParent и .tooltip — алиасы для мостов совместимости
|
|
944
|
+
у потребителей (b24_change_entity, b24_desktop), снять в следующей minor после их обновления */
|
|
945
|
+
.subscriptions__tooltip-parent,
|
|
395
946
|
.tooltipParent {
|
|
396
947
|
position: relative; /* Чтобы подсказка позиционировалась относительно родителя */
|
|
397
948
|
cursor: pointer;
|
|
398
949
|
}
|
|
950
|
+
|
|
951
|
+
.subscriptions__tooltip,
|
|
399
952
|
.tooltip {
|
|
400
953
|
visibility: hidden;
|
|
401
|
-
background-color: #555;
|
|
402
|
-
color: #fff;
|
|
954
|
+
background-color: var(--subscriptions-tooltip-bg, #555);
|
|
955
|
+
color: var(--subscriptions-tooltip-ink, #fff);
|
|
403
956
|
text-align: center;
|
|
404
957
|
border-radius: 5px;
|
|
405
958
|
padding: 5px;
|
|
@@ -413,10 +966,9 @@ export default {
|
|
|
413
966
|
max-width: 400px;
|
|
414
967
|
min-width: 200px;
|
|
415
968
|
word-wrap: break-word; /* Перенос слов при необходимости */
|
|
416
|
-
/* white-space: nowrap; Запретить перенос слов */
|
|
417
969
|
}
|
|
418
970
|
|
|
419
|
-
|
|
971
|
+
.subscriptions__tooltip-parent:hover .subscriptions__tooltip,
|
|
420
972
|
.tooltipParent:hover .tooltip {
|
|
421
973
|
visibility: visible;
|
|
422
974
|
opacity: 1;
|