This commit is contained in:
2026-02-15 16:59:54 +03:00
parent 0b95dd62e1
commit ea28427c3d
25 changed files with 7336 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
+619
View File
@@ -0,0 +1,619 @@
// background.js
console.log('✅ Medods N3.Health service worker started');
let isProcessing = false;
// Таблица статусов
const statuses = {
201: {
name: "Создан",
description: "Документ создан через API, готов к обработке",
category: "processing"
},
202: {
name: "Отправлен на подписание",
description: "Документ отправлен получателю",
category: "processing"
},
203: {
name: "Просмотрел",
description: "Документ просмотрен получателем",
category: "processing"
},
204: {
name: "Подписал",
description: "Документ подписан получателем",
category: "completed"
},
205: {
name: "Отказался",
description: "Получатель отказался от подписания",
category: "error"
},
206: {
name: "Срок для подписания истек",
description: "Срок действия ссылки истек до момента проставления подписи или отказа",
category: "error"
},
207: {
name: "Ожидание действий всех получателей",
description: "Ожидание действий от всех получателей",
category: "processing"
},
208: {
name: "Успешно. Подписан всеми — обработка",
description: "Все получатели подписали, документ обрабатывается",
category: "processing"
},
209: {
name: "Успешно. Подписан не всеми — обработка",
description: "Не все получатели подписали, документ обрабатывается",
category: "processing"
},
210: {
name: "Завершено. Подписано всеми",
description: "Все получатели подписали, документ сохранен",
category: "completed"
},
211: {
name: "Завершено. Подписано не всеми",
description: "Не все получатели подписали, документ сохранен",
category: "completed"
},
212: {
name: "Отменен для получателя",
description: "Подписание отменено для конкретного получателя",
category: "error"
},
213: {
name: "Завершено. Отменено для всех",
description: "Подписание отменено для всех получателей",
category: "completed"
},
498: {
name: "Завершено. Отклонено всеми",
description: "Все получатели отказались от подписания",
category: "error"
},
499: {
name: "Завершено. Истекло для всех",
description: "Срок ссылки истек для всех получателей",
category: "error"
},
500: {
name: "Ошибка обработки",
description: "Ошибка во время обработки документа",
category: "error"
},
501: {
name: "Ошибка доставки получателю",
description: "Произошла ошибка при доставке конкретному получателю",
category: "error"
}
};
function enrichStatuses(data) {
if (!Array.isArray(data)) return data;
data.forEach(singing => {
if (Array.isArray(singing.statuses)) {
singing.statuses.forEach(status => {
const statusInfo = statuses[status.status] || {
name: "Неизвестный статус",
description: `Статус ${status.status}`,
category: "error"
};
status.category = statusInfo.category;
status.description = statusInfo.description;
status.name = statusInfo.name;
});
}
});
return data;
}
async function sendDataToServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/singing`;
console.log('🌐 Отправка данных на сервер:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(30000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error: ${response.status} - ${errorText}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('❌ Ошибка отправки на сервер:', error);
throw error;
}
}
async function processAndSendDocuments(data, sender) {
if (isProcessing) {
throw new Error('Already processing documents');
}
isProcessing = true;
try {
const serverResponse = await sendDataToServer(data);
const isSuccess = serverResponse && serverResponse.status === 'SUCCESS';
return {
success: isSuccess,
message: serverResponse.message || 'Неизвестная ошибка',
sentCount: data.docs?.length || 0
};
} catch (error) {
console.error('❌ Ошибка обработки документов:', error);
return {
success: false,
message: error.message || 'Ошибка обработки документов',
error: error.toString()
};
} finally {
isProcessing = false;
}
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'processAndSendDocuments') {
setTimeout(async () => {
try {
const result = await processAndSendDocuments(message.data, sender);
sendResponse({
success: result.success,
message: result.message,
sentCount: result.sentCount,
});
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Неизвестная ошибка',
error: error.toString()
});
}
}, 0);
return true;
}
if (message.action === 'bgLog') {
try {
console.log(`[EXT][${message.sender}]`, ...message.payload);
} catch (e) { }
}
if (message.action === 'preSingingCheck') {
setTimeout(async () => {
try {
const result = await preSingingCheck(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Неизвестная ошибка',
error: error.toString()
});
}
}, 0);
return true;
}
if (message.action === 'getDocuments') {
setTimeout(async () => {
try {
const result = await getDocumentsFromServer(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка получения документов',
error: error.toString()
});
}
}, 0);
return true;
}
if (message.action === 'getDocument') {
setTimeout(async () => {
try {
const result = await getDocumentFromServer(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка получения документа'
});
}
}, 0);
return true;
}
if (message.action === 'revokeDocuments') {
setTimeout(async () => {
try {
const result = await revokeDocumentsOnServer(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка отзыва документов'
});
}
}, 0);
return true;
}
if (message.action === 'resendDocuments') {
setTimeout(async () => {
try {
const result = await resendDocumentsOnServer(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка повторной отправки документов'
});
}
}, 0);
return true;
}
// Новый обработчик для получения статусов из popup
if (message.action === 'getStatuses') {
setTimeout(async () => {
try {
const result = await getStatusesFromServer(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка получения статусов',
error: error.toString()
});
}
}, 0);
return true;
}
// Обработчик расширенного поиска
if (message.action === 'advancedSearch') {
setTimeout(async () => {
try {
const result = await advancedSearch(message.data);
sendResponse(result);
} catch (error) {
sendResponse({
success: false,
message: error.message || 'Ошибка расширенного поиска',
error: error.toString()
});
}
}, 0);
return true;
}
});
// Функция расширенного поиска
async function advancedSearch(data) {
if (isProcessing) {
throw new Error('Already processing documents');
}
isProcessing = true;
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/advanced-search`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(30000)
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
let result = await response.json();
result = result.data || [];
// Обогащаем статусы если есть данные
if (Array.isArray(result)) {
result = enrichStatuses(result);
}
return {
success: true,
data: result || []
};
} catch (error) {
console.error('Advanced search error:', error);
return {
success: false,
message: error.message || 'Ошибка расширенного поиска',
error: error.toString()
};
} finally {
isProcessing = false;
}
}
async function preSingingCheck(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/pre_singing`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(15000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error: ${response.status} - ${errorText}`);
}
const result = await response.json();
return {
success: true,
data: result
};
} catch (error) {
return {
success: false,
message: error.message || 'Ошибка предварительной проверки',
error: error.toString()
};
}
}
async function getDocumentsFromServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/documents`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
idPatientMis: data.idPatientMis
}),
signal: AbortSignal.timeout(30000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error: ${response.status} - ${errorText}`);
}
const result = await response.json();
if (result.status === 'SUCCESS' && Array.isArray(result.data)) {
result.data = enrichStatuses(result.data);
}
return {
success: result.status === 'SUCCESS',
data: result.data || [],
message: result.message || 'Документы получены успешно'
};
} catch (error) {
return {
success: false,
message: error.message || 'Ошибка получения документов',
error: error.toString()
};
}
}
// Новая функция для получения статусов
async function getStatusesFromServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/statuses`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data || {}),
signal: AbortSignal.timeout(30000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error: ${response.status} - ${errorText}`);
}
let result = await response.json();
if (Array.isArray(result)) {
result = enrichStatuses(result);
}
return {
success: true,
data: result || []
};
} catch (error) {
return {
success: false,
message: error.message || 'Ошибка получения статусов',
error: error.toString()
};
}
}
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.storage.local.set({
esiaAuth: false,
trackingIds: [],
serverIp: '',
serverPort: 8000,
processingStatus: 'idle'
});
}
});
async function getDocumentFromServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/document`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(30000)
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const result = await response.json();
return {
success: true,
data: result.data
};
} catch (error) {
return {
success: false,
message: error.message
};
}
}
async function revokeDocumentsOnServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/revoke`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(30000)
});
const result = await response.json();
return {
success: result.status === 'SUCCESS',
message: result.message
};
} catch (error) {
return {
success: false,
message: error.message
};
}
}
async function resendDocumentsOnServer(data) {
try {
const settings = await chrome.storage.local.get(['serverIp', 'serverPort']);
if (!settings.serverIp || !settings.serverPort) {
throw new Error('Server IP or port not configured');
}
const url = `http://${settings.serverIp}:${settings.serverPort}/api/resend`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(30000)
});
const result = await response.json();
return {
success: result.status === 'SUCCESS',
message: result.message
};
} catch (error) {
return {
success: false,
message: error.message
};
}
}
console.log('✅ Background script loaded');
+2106
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+386
View File
@@ -0,0 +1,386 @@
// content.js
console.log('[EXT][content] loaded');
const dataType = 'metaData';
const userDataType = 'userData';
let isProcessing = false;
let storageData = {};
function injectScript(file) {
console.log('[EXT][content] inject:', file);
const script = document.createElement('script');
script.src = chrome.runtime.getURL(file) + '?t=' + Date.now();
script.type = 'text/javascript';
script.async = false;
(document.head || document.documentElement).appendChild(script);
}
window.addEventListener('message', (event) => {
if (
event.source !== window ||
!event.data ||
event.data.source !== 'medods-extension' ||
event.data.type !== dataType &&
event.data.type !== userDataType
) {
return;
}
console.log(`[EXT][content] save ${event.data.type}`);
const saveData = {
[event.data.type]: event.data.payload,
};
chrome.storage.local.set(saveData);
});
async function loadPageData() {
storageData = await chrome.storage.local.get(dataType);
if (!storageData || !storageData.metaData || Object.keys(storageData.metaData).length === 0) {
console.log(`[EXT][content] no data for ${dataType}, injecting...`);
injectScript(`${dataType}.js`);
} else {
console.log(`[EXT][content] metadata already exists in storage`);
}
}
function isDocumentsPage() {
return (
location.href.includes('/clients/') &&
location.href.includes('/documents')
);
}
function isMedodsPage() {
return (
document.title === 'MEDODS'
)
}
// Обработчик сообщений от singing.js
window.addEventListener('message', async (event) => {
if (
event.source !== window ||
!event.data ||
event.data.source !== 'medods-extension'
) {
return;
}
// ОБРАБОТЧИК ПРЕДВАРИТЕЛЬНОЙ ПРОВЕРКИ
if (event.data.type === 'preSingingCheck') {
// Проверка занятости
if (isProcessing) {
window.postMessage({
source: 'medods-extension',
type: 'preSingingCheckResponse',
payload: {
success: false,
error: 'Система занята, попробуйте позже'
}
}, '*');
return;
}
// Получаем данные из storage
if (!storageData.metaData) {
window.postMessage({
source: 'medods-extension',
type: 'preSingingCheckResponse',
payload: {
success: false,
error: 'Данные не загружены'
}
}, '*');
return;
}
// Подготавливаем данные для отправки
const userData = storageData.metaData.userData;
const patientId = storageData.metaData.patients?.[0]?.idPatientMis;
if (!userData || !patientId) {
window.postMessage({
source: 'medods-extension',
type: 'preSingingCheckResponse',
payload: {
success: false,
error: 'Не удалось получить данные пользователя или пациента'
}
}, '*');
return;
}
const preSingingData = {
userId: userData.id,
patientId: patientId,
docNumbers: event.data.payload.docNumbers
};
// Отправляем запрос в background для предварительной проверки
chrome.runtime.sendMessage({
action: 'preSingingCheck',
data: preSingingData
}, (response) => {
// Пересылаем результат в singing.js
window.postMessage({
source: 'medods-extension',
type: 'preSingingCheckResponse',
payload: response
}, '*');
});
}
// ОБРАБОТЧИК ОТПРАВКИ ДОКУМЕНТОВ
if (event.data.type === 'sendDocuments') {
// Проверка занятости
if (isProcessing) {
window.postMessage({
source: 'medods-extension',
type: 'sendDocumentsResponse',
payload: {
response: {
success: false,
status: 'processing',
error: 'Система занята, попробуйте позже'
}
}
}, '*');
return;
}
isProcessing = true;
console.log('[EXT][content] Forwarding to background:', {
docNumbers: event.data.payload.docNumbers
});
// Немедленный ответ о принятии задачи
window.postMessage({
source: 'medods-extension',
type: 'sendDocumentsResponse',
payload: {
response: {
success: true,
message: 'Документы приняты в обработку',
status: 'processing'
}
}
}, '*');
// Фильтруем документы по номерам
const filteredDocs = storageData.metaData.docs.filter(doc =>
event.data.payload.docNumbers.includes(parseInt(doc.number, 10))
);
console.log(`📄 [Content] Найдено документов для отправки: ${filteredDocs.length}`);
if (filteredDocs.length === 0) {
window.postMessage({
source: 'medods-extension',
type: 'sendDocumentsResponse',
payload: {
response: {
success: false,
error: 'No matching documents found'
}
}
}, '*');
isProcessing = false;
return;
}
// Собираем все данные для отправки
const sendData = {
practitioner: storageData.metaData.practitioner,
patients: storageData.metaData.patients,
docs: filteredDocs,
};
// Асинхронная обработка в background
setTimeout(() => {
chrome.runtime.sendMessage({
action: 'processAndSendDocuments',
data: sendData
}, (response) => {
isProcessing = false;
console.log('[EXT][content] Response from background:', response);
// Пересылаем результат в singing.js
window.postMessage({
source: 'medods-extension',
type: 'backgroundProcessingResult',
payload: {
success: response?.success || false,
message: response.message || '',
sentCount: response?.sentCount || 0
}
}, '*');
});
}, 0);
}
// В content.js, в основном обработчике window.addEventListener добавьте:
// ОБРАБОТЧИК ПОДГОТОВКИ ДОКУМЕНТОВ (используем существующие метаданные)
if (event.data.type === 'prepareDocuments') {
// Проверяем наличие метаданных
if (!storageData.metaData) {
window.postMessage({
source: 'medods-extension',
type: 'prepareDocumentsResponse',
payload: {
success: false,
message: 'Метаданные не загружены'
}
}, '*');
return;
}
// Получаем idPatientMis из метаданных
const idPatientMis = storageData.metaData.patients?.[0]?.idPatientMis;
if (!idPatientMis) {
window.postMessage({
source: 'medods-extension',
type: 'prepareDocumentsResponse',
payload: {
success: false,
message: 'Не удалось получить idPatientMis из метаданных'
}
}, '*');
return;
}
// Отправляем запрос в background для получения документов
chrome.runtime.sendMessage({
action: 'getDocuments',
data: {
idPatientMis: idPatientMis
}
}, (response) => {
// Пересылаем результат в documents.js
window.postMessage({
source: 'medods-extension',
type: 'prepareDocumentsResponse',
payload: response
}, '*');
});
}
// ОБРАБОТЧИК ПОЛУЧЕНИЯ ДОКУМЕНТА
if (event.data.type === 'getDocument') {
chrome.runtime.sendMessage({
action: 'getDocument',
data: event.data.payload
}, (response) => {
window.postMessage({
source: 'medods-extension',
type: 'getDocumentResponse',
payload: response
}, '*');
});
}
// ОБРАБОТЧИК ПРОВЕРКИ ПРАВ НА ОТЗЫВ
if (event.data.type === 'checkRevokePermission') {
const hasPermission = String(storageData.metaData?.userData?.id) === String(event.data.payload.userIdLpu);
window.postMessage({
source: 'medods-extension',
type: 'checkRevokePermissionResponse',
payload: {
hasPermission: hasPermission
}
}, '*');
}
// ОБРАБОТЧИК ОТЗЫВА ДОКУМЕНТОВ
if (event.data.type === 'revokeDocuments') {
console.log('[EXT][content] Запрос на отзыв документов:', event.data.payload);
chrome.runtime.sendMessage({
action: 'revokeDocuments',
data: event.data.payload
}, (response) => {
window.postMessage({
source: 'medods-extension',
type: 'revokeDocumentsResponse',
payload: response
}, '*');
});
}
// ОБРАБОТЧИК ПОВТОРНОЙ ОТПРАВКИ ДОКУМЕНТОВ
if (event.data.type === 'resendDocuments') {
console.log('[EXT][content] Запрос на повторную отправку документов:', event.data.payload);
chrome.runtime.sendMessage({
action: 'resendDocuments',
data: event.data.payload
}, (response) => {
window.postMessage({
source: 'medods-extension',
type: 'resendDocumentsResponse',
payload: response
}, '*');
});
}
});
// Основной цикл
let documentsActive = false;
let medodsActive = false;
let lastCheck = 0;
function loop(ts) {
if (ts - lastCheck > 200) {
lastCheck = ts;
const now = isDocumentsPage();
if (now && !documentsActive) {
console.log('[EXT][content] ENTER documents');
loadPageData()
.then(() => {
injectScript('documents.js');
injectScript('singing.js');
documentsActive = true;
})
.catch(error => {
console.error('[EXT][content] Error on enter documents:', error);
window.location.reload();
});
}
if (!now && documentsActive) {
console.log('[EXT][content] LEAVE documents');
documentsActive = false;
isProcessing = false;
chrome.storage.local.remove(dataType)
.then(() => { })
.catch(error => {
console.error('[EXT][content] Error on leave documents:', error);
window.location.reload();
});
}
const medodsNow = isMedodsPage();
if (medodsNow && !medodsActive) {
medodsActive = true;
injectScript('userData.js');
}
}
requestAnimationFrame(loop);
}
// Запуск
loop(0);
+766
View File
@@ -0,0 +1,766 @@
// documents.js
(function () {
// Глобальная переменная для хранения текущего уведомления
let currentMessageEl = null;
let messageTimeout = null;
// Функция для отображения сообщений
function showMessage(message, type = 'success', duration = 3000) {
// Удаляем предыдущее уведомление, если оно есть
if (currentMessageEl && currentMessageEl.parentNode) {
clearTimeout(messageTimeout);
document.body.removeChild(currentMessageEl);
currentMessageEl = null;
}
const messageEl = document.createElement('div');
messageEl.className = `el-message el-message--${type}`;
messageEl.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
min-width: 380px;
padding: 15px 15px 15px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
background-color: ${type === 'success' ? '#f0f9eb' :
type === 'info' ? '#f4f4f5' : '#fef0f0'};
border: 1px solid ${type === 'success' ? '#e1f3d8' :
type === 'info' ? '#e9e9eb' : '#fde2e2'};
color: ${type === 'success' ? '#67c23a' :
type === 'info' ? '#909399' : '#f56c6c'};
display: flex;
align-items: center;
`;
const iconClass = type === 'success' ? 'el-icon-success' :
type === 'info' ? 'el-icon-info' : 'el-icon-error';
const iconColor = type === 'success' ? '#67c23a' :
type === 'info' ? '#909399' : '#f56c6c';
messageEl.innerHTML = `
<i class="el-icon ${iconClass}" style="font-size: 16px; color: ${iconColor}; margin-right: 10px;"></i>
<span style="font-size: 14px;">${message}</span>
`;
document.body.appendChild(messageEl);
currentMessageEl = messageEl;
// Скрываем через указанное время
messageTimeout = setTimeout(() => {
if (currentMessageEl === messageEl && messageEl.parentNode) {
document.body.removeChild(messageEl);
currentMessageEl = null;
}
}, duration);
}
// Функция отправки сообщения
function sendMessageToContent(type, payload) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Таймаут ожидания ответа'));
}, 60000);
const messageHandler = (event) => {
if (
event.source !== window ||
!event.data ||
event.data.source !== 'medods-extension' ||
event.data.type !== `${type}Response`
) {
return;
}
window.removeEventListener('message', messageHandler);
clearTimeout(timeoutId);
resolve(event.data.payload);
};
window.addEventListener('message', messageHandler);
window.postMessage({
source: 'medods-extension',
type: type,
payload: payload,
}, '*');
});
}
// Функция форматирования даты
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Функция получения класса для статуса
function getStatusClass(category) {
switch (category) {
case 'completed':
return 'status-completed';
case 'processing':
return 'status-processing';
case 'error':
return 'status-error';
default:
return '';
}
}
// Функция получения иконки для статуса
function getStatusIcon(category) {
switch (category) {
case 'completed':
return 'el-icon-success';
case 'processing':
return 'el-icon-loading';
case 'error':
return 'el-icon-error';
default:
return 'el-icon-question';
}
}
// Функция создания модального окна со статусами
function createStatusesModal(singing) {
const modalWrapper = document.createElement('div');
modalWrapper.className = 'el-dialog__wrapper';
modalWrapper.style.cssText = 'z-index: 2001; position: fixed; top: 0; right: 0; bottom: 0; left: 0; overflow: auto;';
const backdrop = document.createElement('div');
backdrop.className = 'v-modal';
backdrop.style.cssText = 'z-index: 2001;';
backdrop.tabIndex = 0;
const sortedStatuses = [...singing.statuses].sort((a, b) => a.id - b.id);
const statusesHtml = sortedStatuses.map(status => `
<div class="status-history-item" style="padding: 12px; border-bottom: 1px solid #ebeef5; display: flex; align-items: center; gap: 12px;">
<i class="el-icon ${getStatusIcon(status.category)}" style="font-size: 16px; color: ${status.category === 'completed' ? '#67C23A' :
status.category === 'processing' ? '#409EFF' :
status.category === 'error' ? '#F56C6C' : '#909399'
}; width: 20px;"></i>
<div style="flex: 1;">
<div style="font-weight: 500; color: #303133;">${status.description}</div>
<div style="font-size: 12px; color: #909399; margin-top: 4px;">${formatDate(status.created_at)}</div>
</div>
</div>
`).join('');
modalWrapper.innerHTML = `
<div class="el-dialog" style="width: 500px; margin-top: 15vh;">
<div class="el-dialog__header">
<span class="el-dialog__title">История статусов</span>
<button type="button" class="el-dialog__headerbtn" aria-label="Close">
<i class="el-dialog__close el-icon el-icon-close"></i>
</button>
</div>
<div class="el-dialog__body" style="max-height: 400px; overflow-y: auto; padding: 0;">
${statusesHtml}
</div>
<div class="el-dialog__footer" style="margin-top: 12px; width: 100%; text-align: right;">
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon close-btn">
<i class="m-icon fa-times button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Закрыть</span>
</button>
</div>
</div>
`;
document.body.appendChild(modalWrapper);
// Обработчики закрытия
const closeModal = () => modalWrapper.remove();
modalWrapper.querySelector('.el-dialog__headerbtn').addEventListener('click', closeModal);
modalWrapper.querySelector('.close-btn').addEventListener('click', closeModal);
modalWrapper.addEventListener('click', (e) => {
if (e.target === modalWrapper) closeModal();
});
// Закрытие по ESC
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
// Функция создания модального окна подтверждения отзыва
function createRevokeConfirmModal(singing) {
return new Promise((resolve) => {
const modalWrapper = document.createElement('div');
modalWrapper.className = 'el-dialog__wrapper';
modalWrapper.style.cssText = 'z-index: 2001; position: fixed; top: 0; right: 0; bottom: 0; left: 0; overflow: auto;';
const backdrop = document.createElement('div');
backdrop.className = 'v-modal';
backdrop.style.cssText = 'z-index: 2001;';
backdrop.tabIndex = 0;
modalWrapper.innerHTML = `
<div class="el-dialog" style="width: 450px; margin-top: 25vh;">
<div class="el-dialog__header">
<span class="el-dialog__title">Подтверждение отзыва</span>
<button type="button" class="el-dialog__headerbtn" aria-label="Close">
<i class="el-dialog__close el-icon el-icon-close"></i>
</button>
</div>
<div class="el-dialog__body" style="padding: 20px;">
<div style="display: flex; align-items: flex-start; gap: 12px;">
<i class="el-icon el-icon-warning" style="font-size: 24px; color: #E6A23C;"></i>
<div>
<p style="margin: 0 0 10px 0; color: #606266;">Вы действительно хотите отозвать документы из подписания?</p>
<div style="background: #f8f8f8; padding: 10px; border-radius: 4px; font-size: 13px; color: #E6A23C; border-left: 3px solid #E6A23C;">
<i class="el-icon el-icon-document" style="margin-right: 6px;"></i>
<strong>Документы:</strong> ${singing.documents.map(d => `${d.number} ${d.title}`).join(', ')}
</div>
</div>
</div>
</div>
<div class="el-dialog__footer" style="display: flex; justify-content: space-between; align-items: center;">
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon cancel-btn"
style="display: flex; align-items: center;">
<i class="m-icon fa-times button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Отмена</span>
</button>
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon confirm-btn" style="color: #F56C6C; border-color: #F56C6C; display: flex; align-items: center;">
<i class="m-icon fa-ban button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Отозвать</span>
</button>
</div>
</div>
`;
document.body.appendChild(modalWrapper);
const closeModal = () => {
modalWrapper.remove();
resolve(false);
};
modalWrapper.querySelector('.el-dialog__headerbtn').addEventListener('click', closeModal);
modalWrapper.querySelector('.cancel-btn').addEventListener('click', closeModal);
modalWrapper.querySelector('.confirm-btn').addEventListener('click', () => {
modalWrapper.remove();
resolve(true);
});
modalWrapper.addEventListener('click', (e) => {
if (e.target === modalWrapper) closeModal();
});
// Закрытие по ESC
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
});
}
// Функция отображения PDF
async function viewDocument(documentPath, title) {
try {
showMessage('Загрузка документа...', 'info', 1000);
const response = await sendMessageToContent('getDocument', { documentPath });
if (response.success && response.data) {
// Декодируем base64
const byteCharacters = atob(response.data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// Создаем blob
const blob = new Blob([byteArray], { type: 'application/pdf' });
// Создаем URL для blob
const url = window.URL.createObjectURL(blob);
// Пытаемся открыть в новой вкладке
const newWindow = window.open(url, '_blank');
// Если браузер заблокировал всплывающее окно
if (!newWindow) {
// Создаем ссылку и имитируем клик для скачивания
const link = document.createElement('a');
link.href = url;
link.download = 'document.pdf'; // Простое имя по умолчанию
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showMessage('Разрешите всплывающие окна для просмотра PDF', 'warning');
}
// Очищаем URL через некоторое время
setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 1000);
} else {
showMessage('Ошибка загрузки документа: ' + (response.message || 'Неизвестная ошибка'), 'error');
}
} catch (error) {
console.error('Ошибка просмотра документа:', error);
showMessage('Ошибка просмотра документа', 'error');
}
}
// Функция отзыва документов
async function revokeDocuments(singing, modalToClose) {
try {
// Сразу закрываем окно со списком документов
if (modalToClose && modalToClose.parentNode) {
modalToClose.remove();
}
// Проверяем права на отзыв
const checkResult = await sendMessageToContent('checkRevokePermission', {
userIdLpu: singing.userIdLpu
});
if (!checkResult.hasPermission) {
showMessage('Отозвать документы может только автор запроса', 'error');
return;
}
// Показываем подтверждение
const confirmed = await createRevokeConfirmModal(singing);
if (!confirmed) return;
showMessage('Отзыв документов...', 'info');
// Отправляем запрос на отзыв
const response = await sendMessageToContent('revokeDocuments', {
trackingId: singing.trackingId
});
if (response.success) {
showMessage('Документы успешно отозваны', 'success');
console.log('Документы успешно отозваны');
// Обновляем список документов
await prepareDocuments();
} else {
showMessage('Ошибка отзыва документов: ' + (response.message || 'Неизвестная ошибка'), 'error');
}
} catch (error) {
console.error('Ошибка отзыва документов:', error);
showMessage('Ошибка отзыва документов', 'error');
}
}
// Функция создания модального окна подтверждения повторной отправки
function createResendConfirmModal(singing) {
return new Promise((resolve) => {
const modalWrapper = document.createElement('div');
modalWrapper.className = 'el-dialog__wrapper';
modalWrapper.style.cssText = 'z-index: 2001; position: fixed; top: 0; right: 0; bottom: 0; left: 0; overflow: auto;';
const backdrop = document.createElement('div');
backdrop.className = 'v-modal';
backdrop.style.cssText = 'z-index: 2001;';
backdrop.tabIndex = 0;
modalWrapper.innerHTML = `
<div class="el-dialog" style="width: 450px; margin-top: 25vh;">
<div class="el-dialog__header">
<span class="el-dialog__title">Подтверждение повторной отправки</span>
<button type="button" class="el-dialog__headerbtn" aria-label="Close">
<i class="el-dialog__close el-icon el-icon-close"></i>
</button>
</div>
<div class="el-dialog__body" style="padding: 20px;">
<div style="display: flex; align-items: flex-start; gap: 12px;">
<i class="el-icon el-icon-warning" style="font-size: 24px; color: #E6A23C;"></i>
<div>
<p style="margin: 0 0 10px 0; color: #606266;">Вы действительно хотите повторно отправить документы на подписание?</p>
<div style="background: #f8f8f8; padding: 10px; border-radius: 4px; font-size: 13px; color: #E6A23C; border-left: 3px solid #E6A23C;">
<i class="el-icon el-icon-document" style="margin-right: 6px;"></i>
<strong>Документы:</strong> ${singing.documents.map(d => `${d.number} ${d.title}`).join(', ')}
</div>
</div>
</div>
</div>
<div class="el-dialog__footer" style="display: flex; justify-content: space-between; align-items: center;">
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon cancel-btn"
style="display: flex; align-items: center;">
<i class="m-icon fa-times button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Отмена</span>
</button>
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon confirm-btn" style="color: #67C23A; border-color: #67C23A; display: flex; align-items: center;">
<i class="m-icon fa-redo-alt button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Отправить повторно</span>
</button>
</div>
</div>
`;
document.body.appendChild(modalWrapper);
const closeModal = () => {
modalWrapper.remove();
resolve(false);
};
modalWrapper.querySelector('.el-dialog__headerbtn').addEventListener('click', closeModal);
modalWrapper.querySelector('.cancel-btn').addEventListener('click', closeModal);
modalWrapper.querySelector('.confirm-btn').addEventListener('click', () => {
modalWrapper.remove();
resolve(true);
});
modalWrapper.addEventListener('click', (e) => {
if (e.target === modalWrapper) closeModal();
});
// Закрытие по ESC
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
});
}
// Функция повторной отправки документов
async function resendDocuments(singing, modalToClose) {
try {
// Сразу закрываем окно со списком документов
if (modalToClose && modalToClose.parentNode) {
modalToClose.remove();
}
// Показываем подтверждение
const confirmed = await createResendConfirmModal(singing);
if (!confirmed) return;
showMessage('Повторная отправка документов...', 'info');
// Отправляем запрос на повторную отправку
const response = await sendMessageToContent('resendDocuments', {
id: singing.id,
trackingId: singing.trackingId,
idPatientMis: singing.idPatientMis
});
if (response.success) {
showMessage('Документы успешно отправлены повторно', 'success');
console.log('Документы успешно отправлены повторно');
// Обновляем список документов
await prepareDocuments();
} else {
showMessage('Ошибка повторной отправки документов: ' + (response.message || 'Неизвестная ошибка'), 'error');
}
} catch (error) {
console.error('Ошибка повторной отправки документов:', error);
showMessage('Ошибка повторной отправки документов', 'error');
}
}
// Функция создания таблицы документов
function createDocumentsTable(data) {
// Создаем модальное окно
const modalWrapper = document.createElement('div');
modalWrapper.className = 'el-dialog__wrapper';
modalWrapper.style.cssText = 'z-index: 2001; position: fixed; top: 0; right: 0; bottom: 0; left: 0; overflow: auto;';
const backdrop = document.createElement('div');
backdrop.className = 'v-modal';
backdrop.style.cssText = 'z-index: 2001;';
backdrop.tabIndex = 0;
if (!data || data.length === 0) {
modalWrapper.innerHTML = `
<div class="el-dialog" style="width: 500px; margin-top: 25vh;">
<div class="el-dialog__header">
<span class="el-dialog__title">Электронные документы</span>
<button type="button" class="el-dialog__headerbtn" aria-label="Close">
<i class="el-dialog__close el-icon el-icon-close"></i>
</button>
</div>
<div class="el-dialog__body" style="text-align: center; padding: 40px;">
<i class="el-icon el-icon-document" style="font-size: 48px; color: #DCDFE6; margin-bottom: 16px;"></i>
<p style="color: #909399; font-size: 16px; margin: 0;">Нет документов, подписанных электронным способом</p>
</div>
<div class="el-dialog__footer" style="text-align: right;">
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon close-btn">
<i class="m-icon fa-times button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Закрыть</span>
</button>
</div>
</div>
`;
} else {
const tableRows = data.map((singing, index) => {
// Определяем последний статус
const lastStatus = singing.statuses.reduce((max, status) =>
status.id > max.id ? status : max
, singing.statuses[0]);
const statusClass = getStatusClass(lastStatus.category);
const statusIcon = getStatusIcon(lastStatus.category);
const statusColor = lastStatus.category === 'completed' ? '#67C23A' :
lastStatus.category === 'processing' ? '#409EFF' :
lastStatus.category === 'error' ? '#F56C6C' : '#909399';
// Формируем ячейку с документами
const documentsHtml = singing.documents.map(doc => `
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: ${singing.documents.length > 1 ? '8px' : '0'}; padding: 4px 0;">
<div style="flex: 1; word-break: break-word; color: #606266;">
<span style="font-weight: 500; color: #303133;">${doc.number}</span>
<span style="margin-left: 4px;">${doc.title}</span>
</div>
${doc.storagePath ? `
<button class="el-button el-button--text view-doc-btn"
data-title="${doc.title}"
data-path="${doc.storagePath}"
style="padding: 0 4px; min-height: auto; border: none;">
<i class="el-icon el-icon-view" style="color: #409EFF; font-size: 16px;"></i>
</button>
` : ''}
</div>
`).join('');
return `
<tr style="border-bottom: 1px solid #EBEEF5;">
<td style="padding: 12px; vertical-align: top;">
<div style="font-weight: 500; color: #303133;">${formatDate(singing.created_at)}</div>
<div style="font-size: 12px; color: #909399; margin-top: 4px;">
<i class="el-icon el-icon-user" style="margin-right: 4px;"></i>
${singing.practitioner}
</div>
</td>
<td style="padding: 12px; vertical-align: top; max-width: 550px;">
${documentsHtml}
</td>
<td style="padding: 12px; vertical-align: top;">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="display: flex; align-items: center; gap: 8px; flex: 1;">
<i class="el-icon ${statusIcon}" style="color: ${statusColor}; font-size: 16px;"></i>
<div>
<div style="color: ${statusColor};">${lastStatus.description}</div>
<div style="font-size: 12px; color: #909399; margin-top: 2px;">${formatDate(lastStatus.created_at)}</div>
</div>
</div>
<div style="display: flex; gap: 8px;">
${singing.storagePath ? `
<button class="el-button el-button--text view-main-doc-btn"
data-title="${singing.title}"
data-path="${singing.storagePath}"
title="Просмотреть основной документ"
style="padding: 0; border: none;">
<i class="el-icon el-icon-document" style="color: #409EFF; font-size: 18px;"></i>
</button>
` : ''}
${singing.statuses.length > 1 ? `
<button class="el-button el-button--text view-statuses-btn"
data-index="${index}"
title="История статусов"
style="padding: 0; border: none;">
<i class="el-icon el-icon-time" style="color: #909399; font-size: 18px;"></i>
</button>
` : ''}
${lastStatus.category === 'processing' ? `
<div style="display: flex; gap: 4px;">
<button class="el-button el-button--text resend-btn"
data-index="${index}"
title="Повторно отправить документы"
style="padding: 0; border: none;">
<i class="el-icon el-icon-refresh" style="color: #67C23A; font-size: 18px;"></i>
</button>
<button class="el-button el-button--text revoke-btn"
data-index="${index}"
title="Отозвать документы"
style="padding: 0; border: none;">
<i class="el-icon el-icon-circle-close" style="color: #F56C6C; font-size: 18px;"></i>
</button>
</div>
` : ''}
</div>
</div>
</td>
</tr>
`;
}).join('');
modalWrapper.innerHTML = `
<div class="el-dialog" style="width: 90%; max-width: 1200px; margin-top: 5vh;">
<div class="el-dialog__header">
<span class="el-dialog__title">Электронные документы</span>
<button type="button" class="el-dialog__headerbtn" aria-label="Close">
<i class="el-dialog__close el-icon el-icon-close"></i>
</button>
</div>
<div class="el-dialog__body" style="padding: 20px;">
<div style="border: 1px solid #EBEEF5; border-radius: 4px; overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #F5F7FA;">
<th style="padding: 12px; text-align: left; font-weight: 500; color: #909399;">Оформлено</th>
<th style="padding: 12px; text-align: left; font-weight: 500; color: #909399;">Документы</th>
<th style="padding: 12px; text-align: left; font-weight: 500; color: #909399;">Статус</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
</div>
<div class="el-dialog__footer" style="text-align: right;">
<button type="button" class="el-button m-button el-button--small is-plain button-with-icon close-btn">
<i class="m-icon fa-times button-icon fad" style="font-size: 14px; margin-right: 6px;"></i>
<span>Закрыть</span>
</button>
</div>
</div>
`;
}
document.body.appendChild(modalWrapper);
// Обработчики закрытия
const closeModal = () => modalWrapper.remove();
modalWrapper.querySelector('.el-dialog__headerbtn').addEventListener('click', closeModal);
modalWrapper.querySelector('.close-btn').addEventListener('click', closeModal);
modalWrapper.addEventListener('click', (e) => {
if (e.target === modalWrapper) closeModal();
});
// Закрытие по ESC
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// Обработчики для кнопок просмотра документов
if (data && data.length > 0) {
modalWrapper.querySelectorAll('.view-doc-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const path = btn.dataset.path;
const title = btn.dataset.title;
viewDocument(path, title);
});
});
modalWrapper.querySelectorAll('.view-main-doc-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const path = btn.dataset.path;
const title = btn.dataset.title;
viewDocument(path, title);
});
});
modalWrapper.querySelectorAll('.view-statuses-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = btn.dataset.index;
createStatusesModal(data[index]);
});
});
modalWrapper.querySelectorAll('.revoke-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = btn.dataset.index;
revokeDocuments(data[index], modalWrapper);
});
});
// Добавляем обработчики для кнопок повторной отправки
modalWrapper.querySelectorAll('.resend-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = btn.dataset.index;
resendDocuments(data[index], modalWrapper);
});
});
}
}
// Основная функция подготовки документов
async function prepareDocuments() {
try {
showMessage('Загрузка документов...', 'info');
const response = await sendMessageToContent('prepareDocuments', {});
if (response.success) {
createDocumentsTable(response.data);
} else {
showMessage('Ошибка загрузки документов: ' + (response.message || 'Неизвестная ошибка'), 'error');
}
} catch (error) {
console.error('Ошибка подготовки документов:', error);
showMessage('Ошибка загрузки документов', 'error');
}
}
// Функция добавления кнопки
function addDocsBtn() {
const docsBtn = document.createElement('button');
docsBtn.className = 'el-button m-button el-button--small is-plain button-with-icon';
docsBtn.innerHTML = `
<span>
<i class="m-icon fa-copy button-icon fad" style="font-size: 16px;"></i>
<span class="m-button__text use-indent">ЭДО</span>
</span>
`;
const m_panel_footer = document.querySelector('.m-panel__footer');
if (m_panel_footer) {
m_panel_footer.insertAdjacentElement('afterbegin', docsBtn);
docsBtn.addEventListener('click', prepareDocuments);
} else {
// Если панель не найдена, пробуем еще раз через небольшую задержку
setTimeout(addDocsBtn, 1000);
}
}
// Добавляем стили для статусов
const style = document.createElement('style');
style.textContent = `
.status-history-item:hover {
background-color: #F5F7FA;
}
.el-button--text {
padding: 0;
border: none;
background: none;
}
.el-button--text:hover {
background: none;
opacity: 0.8;
}
.el-button--text:focus {
outline: none;
}
`;
document.head.appendChild(style);
// Запуск
setTimeout(() => {
addDocsBtn();
}, 500);
})();
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+57
View File
@@ -0,0 +1,57 @@
{
"manifest_version": 3,
"name": "Medods to N3.Health",
"version": "1.0",
"description": "Медодс. Электронное подписание документов",
"permissions": [
"activeTab",
"downloads",
"storage",
"scripting",
"declarativeNetRequest",
"offscreen"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"options_page": "search.html",
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["content.js"],
"run_at": "document_end"
}
],
"web_accessible_resources": [
{
"resources": [
"metaData.js",
"singing.js",
"documents.js",
"userData.js",
"search.html"
],
"matches": ["<all_urls>"]
}
],
"declarative_net_request": {
"rule_resources": [{
"id": "ruleset_1",
"enabled": true,
"path": "rules.json"
}]
}
}
+107
View File
@@ -0,0 +1,107 @@
// metaData.js
(function () {
const MAX_ATTEMPTS = 3;
const INTERVAL_MS = 1000;
let attempts = 0;
let done = false;
function sendMetaData() {
if (done) return;
attempts++;
if (window.gon) {
try {
done = true;
const metaData = {};
const clinicPhone = window.gon.application.current_clinic.phone1 || window.gon.application.current_clinic.phone2;
const clinicPhoneNormalized = `+7${clinicPhone.replace(/[^\d]/g, '').slice(1, 12)}`;
metaData.practitioner = {
familyName: window.gon.application.current_user.surname,
givenName: window.gon.application.current_user.name,
middleName: window.gon.application.current_user.second_name,
userIdLpu: window.gon.application.current_user.id,
telecom: [{
system: 'Email',
value: window.gon.application.current_clinic.email
}, {
system: 'Telephone',
value: clinicPhoneNormalized
}]
}
patientPhone = window.gon.specific.client.phone;
const patientPhoneNormalized = `+7${patientPhone.replace(/[^\d]/g, '').slice(1, 12)}`;
metaData.patients = [{
idPatientMis: window.gon.specific.client.id,
familyName: window.gon.specific.client.surname,
givenName: window.gon.specific.client.name,
middleName: window.gon.specific.client.second_name,
birthDate: window.gon.specific.client.birthdate,
sex: window.gon.specific.client.sex_id === 1 ? 'male' : 'female',
telecom: [{
system: 'Telephone',
value: patientPhoneNormalized
}],
documentDto: [{
providerName: 'ПФР',
docN: window.gon.specific.client.snils,
docS: '',
documentName: 'СНИЛС',
idDocumentType: 223
}]
}]
if (window.gon.specific.client.email) {
metaData.patients[0].telecom.push({
system: 'Email',
value: window.gon.specific.client.email
});
}
metaData.docs = window.gon.specific.docs.map(doc => ({
id: doc.id,
title: doc.title,
created_at: doc.created_at,
updated_at: doc.updated_at,
number: doc.number,
data: doc.data
}));
metaData.userData = {
id: window.gon.application.current_user.id,
login: window.gon.application.current_user.username,
};
// Отправляем только один раз
window.postMessage(
{
source: 'medods-extension',
type: 'metaData',
payload: metaData
},
'*'
);
return;
} catch (error) {
console.error('Error in metaData:', error);
}
}
if (attempts >= MAX_ATTEMPTS) {
console.warn('Max attempts reached for metaData');
return;
}
setTimeout(sendMetaData, INTERVAL_MS);
}
// Запускаем после небольшой задержки
setTimeout(sendMetaData, 100);
})();
+837
View File
@@ -0,0 +1,837 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Медодс. Электронное подписание</title>
<style>
:root {
--el-color-primary: #409eff;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
--el-color-info: #909399;
--el-text-color-primary: #303133;
--el-text-color-regular: #606266;
--el-text-color-secondary: #909399;
--el-text-color-placeholder: #c0c4cc;
--el-border-color-light: #e4e7ed;
--el-border-color-lighter: #ebeef5;
--el-border-color-extra-light: #f2f6fc;
--el-background-color-base: #f5f7fa;
--el-font-family: Verdana, Arial, Helvetica, sans-serif;
}
body {
width: 500px;
padding: 12px;
font-family: var(--el-font-family);
font-size: 13px;
color: var(--el-text-color-primary);
margin: 0;
background: var(--el-background-color-base);
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 16px;
}
h3 {
margin: 0 0 16px 0;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-align: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
/* Accordion */
.accordion {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
overflow: hidden;
margin-top: 16px;
}
.accordion-header {
display: flex;
align-items: center;
padding: 3px 12px;
background: var(--el-background-color-base);
cursor: pointer;
border-bottom: 1px solid var(--el-border-color-lighter);
user-select: none;
transition: background-color 0.2s;
}
.accordion-header:hover {
background: var(--el-border-color-extra-light);
}
.accordion-icon {
margin-right: 8px;
font-size: 16px;
}
.accordion-title {
font-weight: 500;
font-size: 13px;
flex: 1;
}
.accordion-chevron {
font-size: 12px;
transition: transform 0.2s;
}
.accordion-content {
padding: 12px;
background: white;
display: none;
}
.accordion.expanded .accordion-content {
display: block;
}
.accordion.expanded .accordion-chevron {
transform: rotate(180deg);
}
.status-success {
color: var(--el-color-success);
}
.status-warning {
color: var(--el-color-warning);
}
/* Server info - compact */
.server-info {
font-size: 12px;
position: relative;
}
.server-field {
display: flex;
align-items: center;
/*margin-bottom: 8px;*/
min-height: 24px;
padding-right: 70px;
}
.server-label {
font-weight: 500;
color: var(--el-text-color-primary);
width: 75px;
flex-shrink: 0;
padding-right: 5px;
text-align: right;
}
.server-value {
color: var(--el-text-color-regular);
flex: 1;
padding: 2px 0;
padding-left: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.server-input {
flex: 1;
height: 24px;
padding: 0 8px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
font-size: 12px;
background: white;
outline: none;
display: none;
margin-left: 5px;
max-width: 120px;
}
.server-input.visible {
display: block;
}
.server-input:focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.server-input.error {
border-color: var(--el-color-danger);
background: #fff5f5;
}
.server-value.hidden {
display: none;
}
/* Action buttons - positioned to the right of fields */
.action-buttons {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.action-btn {
width: 26px;
height: 26px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
border: 1px solid var(--el-border-color-light);
background: white;
padding: 0;
transition: all 0.15s;
}
.action-btn:hover {
background: var(--el-border-color-extra-light);
}
.edit-btn {
color: var(--el-color-primary);
}
.edit-btn:hover {
background: #ebf8ff;
border-color: var(--el-color-primary);
}
.save-btn {
color: var(--el-color-success);
border-color: var(--el-color-success);
background: #f0fff4;
}
.save-btn:hover:not(.disabled) {
background: #e1f3d8;
}
.save-btn.disabled {
color: var(--el-text-color-placeholder);
border-color: var(--el-border-color-light);
background: var(--el-background-color-base);
cursor: not-allowed;
}
.cancel-btn {
color: var(--el-text-color-secondary);
}
.cancel-btn:hover {
background: #fed7d7;
border-color: var(--el-color-danger);
color: var(--el-color-danger);
}
/* Auth warning */
.auth-warning {
display: none;
padding: 12px;
background: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 4px;
color: var(--el-color-warning);
font-size: 12px;
text-align: center;
margin-bottom: 12px;
}
.auth-warning.visible {
display: block;
}
/* Filters section - two rows */
.filters-section {
margin-bottom: 12px;
}
.filters-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-group {
flex: 1;
min-width: 0;
position: relative;
}
.select-wrapper {
position: relative;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: white;
transition: all 0.2s;
}
.select-wrapper:hover {
border-color: var(--el-color-primary);
}
.select-wrapper.focused {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.m-label {
position: absolute;
top: -7px;
left: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: white;
padding: 0 4px;
line-height: 1;
font-weight: 500;
z-index: 1;
pointer-events: none;
}
.m-select {
width: 100%;
height: 34px;
padding: 8px 24px 8px 8px;
border: none;
background: none;
font-size: 12px;
color: var(--el-text-color-regular);
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
font-family: var(--el-font-family);
}
.m-select:focus {
outline: none;
}
.select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
/* Table header section */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
position: relative;
}
.controls {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
z-index: 1;
}
.all-documents-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: white;
color: var(--el-text-color-regular);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
flex-shrink: 0;
font-family: var(--el-font-family);
}
.all-documents-btn:hover {
background: var(--el-background-color-base);
border-color: var(--el-border-color-lighter);
}
.search-container {
flex: 1;
min-width: 0;
margin: 0 12px;
position: relative;
}
.search-input {
width: 100%;
height: 28px;
padding: 2px 28px 2px 8px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
font-size: 12px;
background: white;
outline: none;
transition: all 0.2s;
box-sizing: border-box;
font-family: var(--el-font-family);
}
.search-input:focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.search-clear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--el-text-color-placeholder);
cursor: pointer;
font-size: 16px;
padding: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
display: none;
border-radius: 50%;
transition: all 0.2s;
}
.search-clear.visible {
display: flex;
}
.search-clear:hover {
color: var(--el-text-color-secondary);
background: var(--el-background-color-base);
}
/* Refresh button */
.refresh-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: white;
color: var(--el-text-color-regular);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
flex-shrink: 0;
font-family: var(--el-font-family);
}
.refresh-btn:hover {
background: var(--el-background-color-base);
border-color: var(--el-border-color-lighter);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Table section */
.table-section {
display: none;
margin-top: 16px;
}
.table-section.visible {
display: block;
}
/* Table */
.table-container {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
overflow: hidden;
max-height: 300px;
overflow-y: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
font-family: var(--el-font-family);
}
.data-table thead {
background: var(--el-background-color-base);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.data-table th {
padding: 10px 8px;
text-align: left;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-table th:nth-child(1) {
width: 30%;
text-align: left;
}
.data-table th:nth-child(2) {
width: 45%;
text-align: center;
}
.data-table th:nth-child(3) {
width: 25%;
text-align: right;
}
.data-table tbody tr {
border-bottom: 1px solid var(--el-border-color-extra-light);
transition: background-color 0.2s;
}
.data-table tbody tr:hover {
background: var(--el-background-color-base);
}
.data-table td {
padding: 10px 8px;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: break-word;
}
.data-table td:nth-child(1) {
text-align: left;
max-width: 150px;
}
.data-table td:nth-child(2) {
text-align: center;
}
.data-table td:nth-child(3) {
text-align: right;
}
.document-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 0;
word-break: break-word;
line-height: 1.4;
font-family: var(--el-font-family);
}
.document-date {
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.3;
display: block;
font-family: var(--el-font-family);
}
.document-docs {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
}
.doc-item {
font-size: 11px;
color: var(--el-text-color-regular);
background: #fdf6ec;
padding: 3px 6px;
border-radius: 3px;
width: 100%;
max-width: 220px;
box-sizing: border-box;
word-break: break-all;
overflow-wrap: break-word;
text-align: center;
white-space: normal;
border: 1px solid #faecd8;
font-family: var(--el-font-family);
}
.status-container {
display: flex;
flex-direction: column;
align-items: flex-end;
position: relative;
}
.document-status {
display: inline-flex;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: normal;
word-break: break-word;
text-align: right;
line-height: 1.4;
margin-bottom: 0;
font-family: var(--el-font-family);
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
cursor: help;
position: relative;
max-width: 120px;
}
.status-date {
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.3;
text-align: right;
display: block;
font-family: var(--el-font-family);
}
.status-completed {
background: #f0f9eb;
color: var(--el-color-success);
border: 1px solid #e1f3d8;
}
.status-processing {
background: #ecf5ff;
color: var(--el-color-primary);
border: 1px solid #d9ecff;
}
.status-error {
background: #fef0f0;
color: var(--el-color-danger);
border: 1px solid #fde2e2;
}
.status-waiting {
background: #fdf6ec;
color: var(--el-color-warning);
border: 1px solid #faecd8;
}
/* Empty state */
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 13px;
font-family: var(--el-font-family);
}
.empty-state.hidden {
display: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Utility classes */
.visible {
display: block !important;
}
.hidden {
display: none !important;
}
.table-section.hidden {
display: none !important;
}
.table-section.visible {
display: block !important;
}
</style>
</head>
<body>
<div class="container">
<h3>Медодс. Электронное подписание</h3>
<!-- Auth warning -->
<div id="authWarning" class="auth-warning">
Для загрузки данных необходимо войти в Медодс
</div>
<!-- Table section -->
<div id="tableSection" class="table-section">
<!-- Table header with search and buttons -->
<div class="table-header">
<button id="allDocumentsBtn" class="all-documents-btn" title="Все документы">
🔎 Расширенный поиск
</button>
<div class="search-container">
<input type="text" id="searchInput" class="search-input" placeholder="Поиск ...">
<button id="searchClear" class="search-clear" title="Очистить поиск">×</button>
</div>
<div class="controls">
<button id="refreshBtn" class="refresh-btn">
<span>🔄 Обновить</span>
</button>
</div>
</div>
<!-- Filters in two rows -->
<div class="filters-section">
<div class="filters-row">
<div class="filter-group">
<div class="select-wrapper" id="esiaFilterWrapper">
<span class="m-label">Подписание</span>
<select id="esiaFilter" class="m-select">
<option value="all">Все</option>
<option value="esia">Только ЕСИА</option>
<option value="not_esia">Только не ЕСИА</option>
</select>
<span class="select-arrow"></span>
</div>
</div>
<div class="filter-group">
<div class="select-wrapper" id="senderFilterWrapper">
<span class="m-label">Отправитель</span>
<select id="senderFilter" class="m-select">
<option value="all">Все</option>
<option value="my">Только мои</option>
<option value="not_my">Только не мои</option>
</select>
<span class="select-arrow"></span>
</div>
</div>
</div>
<div class="filters-row">
<div class="filter-group">
<div class="select-wrapper" id="statusFilterWrapper">
<span class="m-label">Статус</span>
<select id="statusFilter" class="m-select">
<option value="all">Все</option>
<option value="processing">В обработке</option>
<option value="completed">Завершено</option>
</select>
<span class="select-arrow"></span>
</div>
</div>
<div class="filter-group">
<div class="select-wrapper" id="periodFilterWrapper">
<span class="m-label">Период</span>
<select id="periodFilter" class="m-select">
<option value="today">Сегодня</option>
<option value="3days">За 3 дня</option>
<option value="7days">За 7 дней</option>
</select>
<span class="select-arrow"></span>
</div>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table" id="documentsTable">
<thead>
<tr>
<th>ФИО клиента</th>
<th>Документы на подписание</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- Данные будут добавляться динамически -->
</tbody>
</table>
<div id="emptyState" class="empty-state hidden">
Нет документов
</div>
</div>
</div>
<!-- Server settings accordion -->
<div id="serverAccordion" class="accordion">
<div class="accordion-header" id="accordionHeader">
<span class="accordion-icon" id="accordionIcon">⚠️</span>
<span class="accordion-title" id="accordionTitle">Настройка сервера</span>
<span class="accordion-chevron"></span>
</div>
<div class="accordion-content" id="accordionContent">
<div class="server-info">
<div class="server-field">
<span class="server-label">IP Сервера:</span>
<span id="currentServerIp" class="server-value">не настроен</span>
<input id="editServerIp" class="server-input" type="text" placeholder="127.0.0.1"
maxlength="15">
</div>
<div class="server-field">
<span class="server-label">Порт:</span>
<span id="currentServerPort" class="server-value">не настроен</span>
<input id="editServerPort" class="server-input" type="number" placeholder="8000" min="1"
max="65535">
</div>
<!-- Edit button (shows when viewing settings) -->
<div class="action-buttons" id="viewButtons">
<button class="action-btn edit-btn" id="editButton" title="Редактировать">⚙️</button>
<button class="action-btn edit-btn hidden" id="adminButton"
title="Администрирование">🔑</button>
</div>
<!-- Save/Cancel buttons (shows when editing) -->
<div class="action-buttons hidden" id="editButtons">
<button class="action-btn save-btn disabled" id="saveOptions" title="Сохранить"></button>
<button class="action-btn cancel-btn" id="cancelOptions" title="Отменить"></button>
</div>
</div>
</div>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
+620
View File
@@ -0,0 +1,620 @@
// popup.js
(function () {
// Состояние фильтров
let filters = {
esia: 'all',
sender: 'all',
status: 'all',
period: 'today'
};
let filtersChanged = false;
let allStatuses = [];
let currentUserId = null;
// DOM элементы
const authWarning = document.getElementById('authWarning');
const tableSection = document.getElementById('tableSection');
const tableBody = document.getElementById('tableBody');
const emptyState = document.getElementById('emptyState');
const refreshBtn = document.getElementById('refreshBtn');
const searchInput = document.getElementById('searchInput');
const searchClear = document.getElementById('searchClear');
const allDocumentsBtn = document.getElementById('allDocumentsBtn');
// Фильтры
const esiaFilter = document.getElementById('esiaFilter');
const senderFilter = document.getElementById('senderFilter');
const statusFilter = document.getElementById('statusFilter');
const periodFilter = document.getElementById('periodFilter');
// Настройки сервера
const serverAccordion = document.getElementById('serverAccordion');
const accordionHeader = document.getElementById('accordionHeader');
const accordionIcon = document.getElementById('accordionIcon');
const accordionTitle = document.getElementById('accordionTitle');
const currentServerIp = document.getElementById('currentServerIp');
const currentServerPort = document.getElementById('currentServerPort');
const editServerIp = document.getElementById('editServerIp');
const editServerPort = document.getElementById('editServerPort');
const viewButtons = document.getElementById('viewButtons');
const editButtons = document.getElementById('editButtons');
const editButton = document.getElementById('editButton');
const adminButton = document.getElementById('adminButton');
const saveOptions = document.getElementById('saveOptions');
const cancelOptions = document.getElementById('cancelOptions');
// Вспомогательные функции
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getStatusClass(category) {
switch (category) {
case 'completed': return 'status-completed';
case 'processing': return 'status-processing';
case 'error': return 'status-error';
default: return 'status-waiting';
}
}
function getStatusIcon(category) {
switch (category) {
case 'completed': return '✓';
case 'processing': return '⋯';
case 'error': return '✗';
default: return '?';
}
}
function showMessage(message, type = 'success', duration = 2000) {
const messageEl = document.createElement('div');
messageEl.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 8px 16px;
border-radius: 4px;
background: ${type === 'success' ? '#f0f9eb' : '#fef0f0'};
border: 1px solid ${type === 'success' ? '#e1f3d8' : '#fde2e2'};
color: ${type === 'success' ? '#67c23a' : '#f56c6c'};
font-size: 13px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
`;
messageEl.textContent = message;
document.body.appendChild(messageEl);
setTimeout(() => messageEl.remove(), duration);
}
// Функция для выделения кнопки обновления
function highlightRefreshButton() {
refreshBtn.style.animation = 'pulse 1.5s infinite';
refreshBtn.style.backgroundColor = '#e6f7ff';
refreshBtn.style.borderColor = '#91d5ff';
}
function resetRefreshButton() {
refreshBtn.style.animation = 'none';
refreshBtn.style.backgroundColor = '';
refreshBtn.style.borderColor = '';
}
// Добавляем стили
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0% { transform: scale(1); background-color: #ffffff; }
50% { transform: scale(1.05); background-color: #e6f7ff; box-shadow: 0 0 10px rgba(64, 158, 255, 0.5); }
100% { transform: scale(1); background-color: #ffffff; }
}
.clickable-doc {
cursor: pointer;
transition: opacity 0.2s;
}
.clickable-doc:hover {
opacity: 0.8;
background-color: #f0f9eb !important;
}
.doc-view-btn {
margin-left: 4px;
padding: 2px 6px;
border: none;
background: #ecf5ff;
color: #409eff;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.doc-view-btn:hover {
background: #d9ecff;
}
.status-container {
max-width: 140px;
}
.document-status {
max-width: 140px;
word-break: break-word;
}
.doctor-name {
font-size: 10px;
color: #909399;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
`;
document.head.appendChild(style);
// Отправка сообщений в background
async function sendMessageToBackground(action, data) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ action, data }, (response) => {
resolve(response || { success: false, message: 'Нет ответа от background' });
});
});
}
// Сохранение фильтров в storage
function saveFiltersToStorage() {
chrome.storage.local.set({ documentFilters: filters });
}
// Загрузка фильтров из storage
async function loadFiltersFromStorage() {
return new Promise((resolve) => {
chrome.storage.local.get(['documentFilters'], (result) => {
if (result.documentFilters) {
filters = { ...filters, ...result.documentFilters };
// Применяем фильтры к элементам управления
esiaFilter.value = filters.esia;
senderFilter.value = filters.sender;
statusFilter.value = filters.status;
periodFilter.value = filters.period;
}
resolve();
});
});
}
// Получение статусов с сервера
async function loadStatuses() {
try {
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<span>🔄 Загрузка...</span>';
// Получаем ID пользователя из storage
await getCurrentUserId();
// Если нет ID пользователя - считаем что не авторизованы
if (!currentUserId) {
authWarning.classList.add('visible');
tableSection.classList.remove('visible');
return;
}
// Формируем данные для отправки на сервер (без search)
const requestData = {
userIdLpu: currentUserId,
filters: {
esia: filters.esia,
sender: filters.sender,
status: filters.status,
period: filters.period
}
};
// Отправляем запрос с фильтрами
const response = await sendMessageToBackground('getStatuses', requestData);
if (response.success && Array.isArray(response.data)) {
allStatuses = response.data;
authWarning.classList.remove('visible');
tableSection.classList.add('visible');
// Применяем поиск на клиенте
applyClientFilters();
} else {
showMessage('Ошибка загрузки: ' + (response.message || 'Неизвестная ошибка'), 'error');
allStatuses = [];
renderTable([]);
}
} catch (error) {
showMessage('Ошибка загрузки данных', 'error');
allStatuses = [];
renderTable([]);
} finally {
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<span>🔄 Обновить</span>';
resetRefreshButton();
filtersChanged = false;
}
}
// Получение ID текущего пользователя из storage
async function getCurrentUserId() {
return new Promise((resolve) => {
chrome.storage.local.get(['userData'], (result) => {
if (result.userData && result.userData.id) {
currentUserId = result.userData.id;
resolve(currentUserId);
} else {
currentUserId = null;
resolve(null);
}
});
});
}
// Применение клиентских фильтров (поиск)
function applyClientFilters() {
let filtered = [...allStatuses];
// Поиск по тексту (на клиенте)
const searchText = searchInput.value.toLowerCase().trim();
if (searchText) {
filtered = filtered.filter(item => {
const patientName = (item.patientName || '').toLowerCase();
const documents = item.documents?.map(d => `${d.number} ${d.title}`).join(' ').toLowerCase() || '';
return patientName.includes(searchText) || documents.includes(searchText);
});
}
renderTable(filtered);
}
// Просмотр документа
async function viewDocument(documentPath, title) {
try {
const response = await sendMessageToBackground('getDocument', { documentPath });
if (response.success && response.data) {
const byteCharacters = atob(response.data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const newWindow = window.open(url, '_blank');
if (!newWindow) {
const link = document.createElement('a');
link.href = url;
link.download = 'document.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
} else {
showMessage('Ошибка загрузки документа', 'error');
}
} catch (error) {
showMessage('Ошибка просмотра документа', 'error');
}
}
// Рендер таблицы
function renderTable(data) {
if (!data || data.length === 0) {
tableBody.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
const rows = data.map(item => {
const lastStatus = item.statuses?.reduce((max, s) => s.id > max.id ? s : max, item.statuses[0]);
const statusClass = getStatusClass(lastStatus?.category);
const statusIcon = getStatusIcon(lastStatus?.category);
const documentsHtml = item.documents?.map(doc => {
const isClickable = lastStatus?.category !== 'error';
const baseClass = isClickable ? 'doc-item clickable-doc' : 'doc-item';
return `
<div class="${baseClass}"
${isClickable ? `data-path="${doc.storagePath}" data-title="${doc.title}"` : ''}
style="${isClickable ? 'cursor: pointer;' : ''} display: flex; align-items: center; justify-content: space-between;">
<span style="flex: 1; text-align: left; word-break: break-word;">${doc.number} ${doc.title?.replace(/\s+/g, ' ')}</span>
${isClickable ? '<span style="margin-left: 4px;">📄</span>' : ''}
</div>
`;
}).join('');
const hasMainDocument = item.storagePath && !item.storagePath.includes('null');
return `
<tr>
<td>
<div class="document-name">${item.patientName || 'Неизвестно'}</div>
<div class="document-date">${formatDate(item.created_at)}</div>
<div class="doctor-name" title="${item.userName || ''}">
<span>👤 ${item.userName || 'Неизвестно'}</span>
</div>
</td>
<td>
<div class="document-docs">
${documentsHtml}
</div>
</td>
<td>
<div class="status-container">
<div class="document-status ${statusClass}"
title="${lastStatus?.description || ''}">
<span style="margin-right: 4px;">${statusIcon}</span>
${lastStatus?.name || 'Неизвестно'}
</div>
<div class="status-date">${formatDate(lastStatus?.created_at || item.created_at)}</div>
${hasMainDocument ? `
<button class="doc-view-btn" data-path="${item.storagePath}" data-title="Основной документ">
📄 Просмотр
</button>
` : ''}
</div>
</td>
</tr>
`;
}).join('');
tableBody.innerHTML = rows;
// Добавляем обработчики для кликабельных документов
document.querySelectorAll('.clickable-doc').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const path = el.dataset.path;
const title = el.dataset.title;
if (path) viewDocument(path, title);
});
});
// Обработчики для кнопок просмотра
document.querySelectorAll('.doc-view-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const path = btn.dataset.path;
const title = btn.dataset.title;
if (path) viewDocument(path, title);
});
});
}
// Обработчики фильтров
function setupFilters() {
const filterElements = [esiaFilter, senderFilter, statusFilter, periodFilter];
filterElements.forEach(filter => {
filter.addEventListener('change', () => {
// Обновляем состояние фильтров
filters[filter.id.replace('Filter', '')] = filter.value;
filtersChanged = true;
highlightRefreshButton();
saveFiltersToStorage();
});
});
searchInput.addEventListener('input', () => {
// Для поиска не показываем уведомление о необходимости обновления
// применяем фильтрацию сразу на клиенте
applyClientFilters();
searchClear.classList.toggle('visible', searchInput.value.length > 0);
});
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchClear.classList.remove('visible');
applyClientFilters();
});
refreshBtn.addEventListener('click', () => {
// Сохраняем текущие фильтры и загружаем данные
saveFiltersToStorage();
loadStatuses();
});
allDocumentsBtn.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
}
// Настройки сервера
function setupServerSettings() {
// Загрузка сохраненных настроек
chrome.storage.local.get(['serverIp', 'serverPort'], (result) => {
if (result.serverIp) {
currentServerIp.textContent = result.serverIp;
editServerIp.value = result.serverIp;
}
if (result.serverPort) {
currentServerPort.textContent = result.serverPort;
editServerPort.value = result.serverPort;
}
if (result.serverIp && result.serverPort) {
accordionIcon.textContent = '✅';
accordionTitle.textContent = 'Сервер настроен';
adminButton.classList.remove('hidden');
}
});
// Аккордеон
accordionHeader.addEventListener('click', () => {
serverAccordion.classList.toggle('expanded');
});
// Редактирование
editButton.addEventListener('click', () => {
viewButtons.classList.add('hidden');
editButtons.classList.remove('hidden');
currentServerIp.classList.add('hidden');
currentServerPort.classList.add('hidden');
editServerIp.classList.add('visible');
editServerPort.classList.add('visible');
saveOptions.classList.add('disabled');
});
// Валидация полей
[editServerIp, editServerPort].forEach(input => {
input.addEventListener('input', () => {
const ipValid = /^(\d{1,3}\.){3}\d{1,3}$/.test(editServerIp.value) || editServerIp.value === '';
const portValid = editServerPort.value > 0 && editServerPort.value < 65536;
editServerIp.classList.toggle('error', !ipValid && editServerIp.value !== '');
editServerPort.classList.toggle('error', !portValid && editServerPort.value !== '');
if (ipValid && portValid && editServerIp.value && editServerPort.value) {
saveOptions.classList.remove('disabled');
} else {
saveOptions.classList.add('disabled');
}
});
});
// Сохранение с проверкой health
saveOptions.addEventListener('click', async () => {
if (saveOptions.classList.contains('disabled')) return;
const ip = editServerIp.value;
const port = parseInt(editServerPort.value);
// Показываем индикатор загрузки на кнопке
const originalText = saveOptions.textContent;
saveOptions.textContent = '⋯';
saveOptions.disabled = true;
try {
// Проверяем health endpoint
const healthUrl = `http://${ip}:${port}/api/health`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // Таймаут 5 секунд
const response = await fetch(healthUrl, {
method: 'GET',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
// Проверяем что получили ожидаемый ответ
if (data.status !== 'healthy') {
throw new Error('Сервер вернул некорректный ответ');
}
// Если все ок - сохраняем настройки
chrome.storage.local.set({ serverIp: ip, serverPort: port }, () => {
currentServerIp.textContent = ip;
currentServerPort.textContent = port;
viewButtons.classList.remove('hidden');
editButtons.classList.add('hidden');
currentServerIp.classList.remove('hidden');
currentServerPort.classList.remove('hidden');
editServerIp.classList.remove('visible', 'error');
editServerPort.classList.remove('visible', 'error');
accordionIcon.textContent = '✅';
accordionTitle.textContent = 'Сервер настроен';
adminButton.classList.remove('hidden');
// Показываем успешное сообщение
showMessage('Сервер успешно подключен', 'success', 2000);
});
} catch (error) {
console.error('Health check failed:', error);
// Определяем тип ошибки для понятного сообщения
let errorMessage = 'Ошибка подключения к серверу';
if (error.name === 'AbortError') {
errorMessage = 'Сервер не отвечает (таймаут 5 сек)';
} else if (error.message.includes('Failed to fetch')) {
errorMessage = 'Сервер недоступен. Проверьте IP и порт';
} else {
errorMessage = error.message || 'Неизвестная ошибка';
}
// Показываем уведомление об ошибке
showMessage(`${errorMessage}`, 'error', 3000);
// Подсвечиваем поля с ошибкой
editServerIp.classList.add('error');
editServerPort.classList.add('error');
// Возвращаем иконку на кнопке
saveOptions.textContent = originalText;
saveOptions.disabled = false;
}
});
// Отмена
cancelOptions.addEventListener('click', () => {
viewButtons.classList.remove('hidden');
editButtons.classList.add('hidden');
currentServerIp.classList.remove('hidden');
currentServerPort.classList.remove('hidden');
editServerIp.classList.remove('visible', 'error');
editServerPort.classList.remove('visible', 'error');
editServerIp.value = currentServerIp.textContent;
editServerPort.value = currentServerPort.textContent;
});
//Администрирование
adminButton.addEventListener('click', () => {
chrome.tabs.create({ url: `http://${currentServerIp.textContent}:${currentServerPort.textContent}/` });
});
}
// Проверка авторизации
async function checkAuth() {
const userId = await getCurrentUserId();
if (userId) {
authWarning.classList.remove('visible');
tableSection.classList.add('visible');
// Загружаем фильтры из storage и делаем первый запрос
await loadFiltersFromStorage();
loadStatuses();
} else {
authWarning.classList.add('visible');
tableSection.classList.remove('visible');
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
setupServerSettings();
setupFilters();
checkAuth();
});
})();
+26
View File
@@ -0,0 +1,26 @@
[
{
"id": 1,
"priority": 1,
"action": {
"type": "modifyHeaders",
"responseHeaders": [
{
"header": "Access-Control-Allow-Origin",
"operation": "set",
"value": "*"
}
]
},
"condition": {
"urlFilter": "*",
"resourceTypes": [
"image",
"stylesheet",
"font",
"script",
"xmlhttprequest"
]
}
}
]
+256
View File
@@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<link rel="icon" href="icons/icon128.png" type="image/png" sizes="128x128">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Медодс. Расширенный поиск документов</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="bootstrap/bootstrap.min.css">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="bootstrap/bootstrap-icons.css">
<!-- Минимальные кастомные стили только для специфических случаев -->
<style>
/* Только критически необходимые стили, которые нельзя реализовать через Bootstrap */
.table-container {
max-height: 500px;
overflow-y: auto;
}
thead.sticky-top {
z-index: 1020;
}
.document-item {
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.document-item:hover {
background-color: var(--bs-primary-bg-subtle) !important;
}
.status-badge {
cursor: pointer;
transition: opacity 0.2s;
}
.status-badge:hover {
opacity: 0.8;
}
/* Фикс для модального окна */
.modal.show {
display: block;
background-color: rgba(0, 0, 0, 0.5);
}
/* Для пустого состояния */
.empty-state-icon {
font-size: 3rem;
color: var(--bs-gray-400);
}
/* Для лоадера */
.spinner-wrapper {
min-height: 200px;
}
</style>
</head>
<body class="bg-light">
<div class="container py-4">
<!-- Header -->
<div class="card shadow-sm mb-4">
<div class="card-body d-flex justify-content-between align-items-center">
<h1 class="h4 mb-0">
Расширенный поиск документов
<small class="text-muted fs-6 ms-2">электронное подписание</small>
</h1>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" id="clearFiltersBtn">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Сбросить фильтры
</button>
<button class="btn btn-primary" id="searchBtn">
<i class="bi bi-search me-1"></i>
Найти
</button>
</div>
</div>
</div>
<!-- Filters Section -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="bi bi-sliders2 text-primary me-2"></i>
<h2 class="h6 mb-0">Фильтры поиска</h2>
</div>
<div class="row g-3">
<!-- Период -->
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold">Период</label>
<select class="form-select" id="periodSelect">
<option value="today">Сегодня</option>
<option value="3days">За 3 дня</option>
<option value="7days">За неделю</option>
<option value="30days">За месяц</option>
<option value="90days">За квартал</option>
<option value="custom">Произвольный</option>
</select>
</div>
<!-- Статус -->
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold">Статус подписания</label>
<select class="form-select" id="statusSelect">
<option value="all">Все статусы</option>
<option value="processing">В обработке</option>
<option value="completed">Завершено</option>
<option value="error">Ошибки (отмены)</option>
</select>
</div>
<!-- Тип подписания -->
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold">Тип подписания</label>
<select class="form-select" id="signatureTypeSelect">
<option value="all">Все типы</option>
<option value="esia">УКЭП для ЕСИА</option>
<option value="not_esia">МЧД для других</option>
</select>
</div>
</div>
<!-- Advanced Filters Toggle -->
<div class="mt-3">
<button class="btn btn-link text-decoration-none p-0" type="button" id="advancedToggle">
<i class="bi bi-chevron-down me-1" id="advancedIcon"></i>
Расширенные фильтры
</button>
</div>
<!-- Advanced Filters -->
<div class="mt-3" id="advancedFilters" style="display: none;">
<div class="row g-3">
<!-- Отправитель -->
<div class="col-md-4">
<label class="form-label">Отправитель (фамилия)</label>
<input type="text" class="form-control" id="senderInput" placeholder="Фамилия отправителя">
</div>
<!-- Пациент -->
<div class="col-md-4">
<label class="form-label">Пациент (фамилия)</label>
<input type="text" class="form-control" id="patientInput" placeholder="Фамилия пациента">
</div>
<!-- Номер документа -->
<div class="col-md-4">
<label class="form-label">Номер документа</label>
<input type="number" class="form-control" id="documentNumberInput"
placeholder="Только цифры" min="1" step="1">
</div>
</div>
<!-- Date Range for Custom Period -->
<div class="mt-3" id="customDateRange" style="display: none;">
<hr class="my-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">С</label>
<input type="date" class="form-control" id="dateFrom">
</div>
<div class="col-md-6">
<label class="form-label">По</label>
<input type="date" class="form-control" id="dateTo">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Results Section -->
<div class="card shadow-sm">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<i class="bi bi-file-text text-primary me-2"></i>
<h2 class="h6 mb-0">Результаты поиска</h2>
</div>
<div class="text-muted small" id="resultsStats">
Найдено: 0
</div>
</div>
<!-- Table -->
<div class="table-container">
<table class="table table-hover mb-0">
<thead class="sticky-top bg-white">
<tr>
<th style="width: 15%">Дата</th>
<th style="width: 15%">Пациент</th>
<th style="width: 35%">Документы</th>
<th style="width: 20%">Статус</th>
<th style="width: 15%">Отправитель</th>
<th style="width: 10%">Тип</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- Данные будут загружаться динамически -->
</tbody>
</table>
<!-- Empty State -->
<div class="text-center py-5" id="emptyState">
<i class="bi bi-search empty-state-icon d-block mb-3"></i>
<p class="text-muted mb-1">Нет данных для отображения</p>
<p class="text-muted small">Используйте фильтры для поиска документов</p>
</div>
</div>
<!-- Pagination -->
<div class="card-footer bg-white py-3 d-flex justify-content-end align-items-center gap-3" id="pagination"
style="display: none;">
<div class="text-muted small" id="paginationInfo">
Страница 1 из 1
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" id="prevPage" disabled>
<i class="bi bi-chevron-left"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" id="nextPage" disabled>
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Modal for Status History -->
<div class="modal fade" id="statusHistoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">История статусов</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="statusHistoryBody">
<!-- Динамическое содержимое -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle with Popper -->
<script src="bootstrap/bootstrap.bundle.min.js"></script>
<script src="search.js"></script>
</body>
</html>
+704
View File
@@ -0,0 +1,704 @@
// search.js - адаптированный под Bootstrap
(function () {
// Состояние приложения
let state = {
filters: {
period: 'today',
dateFrom: null,
dateTo: null,
status: 'all',
signatureType: 'all',
senderName: '',
patientName: '',
documentNumber: ''
},
results: [],
pagination: {
currentPage: 1,
totalPages: 1,
totalItems: 0,
itemsPerPage: 10
},
loading: false,
serverConfig: {
ip: null,
port: null
}
};
// Bootstrap модальное окно
let statusModal = null;
// Статусы из background.js
const statuses = {
201: { name: "Создан", description: "Документ создан через API, готов к обработке", category: "processing" },
202: { name: "Отправлен на подписание", description: "Документ отправлен получателю", category: "processing" },
203: { name: "Просмотрел", description: "Документ просмотрен получателем", category: "processing" },
204: { name: "Подписал", description: "Документ подписан получателем", category: "completed" },
205: { name: "Отказался", description: "Получатель отказался от подписания", category: "error" },
206: { name: "Срок для подписания истек", description: "Срок действия ссылки истек до момента проставления подписи или отказа", category: "error" },
207: { name: "Ожидание действий всех получателей", description: "Ожидание действий от всех получателей", category: "processing" },
208: { name: "Успешно. Подписан всеми — обработка", description: "Все получатели подписали, документ обрабатывается", category: "processing" },
209: { name: "Успешно. Подписан не всеми — обработка", description: "Не все получатели подписали, документ обрабатывается", category: "processing" },
210: { name: "Завершено. Подписано всеми", description: "Все получатели подписали, документ сохранен", category: "completed" },
211: { name: "Завершено. Подписано не всеми", description: "Не все получатели подписали, документ сохранен", category: "completed" },
212: { name: "Отменен для получателя", description: "Подписание отменено для конкретного получателя", category: "error" },
213: { name: "Завершено. Отменено для всех", description: "Подписание отменено для всех получателей", category: "completed" },
498: { name: "Завершено. Отклонено всеми", description: "Все получатели отказались от подписания", category: "error" },
499: { name: "Завершено. Истекло для всех", description: "Срок ссылки истек для всех получателей", category: "error" },
500: { name: "Ошибка обработки", description: "Ошибка во время обработки документа", category: "error" },
501: { name: "Ошибка доставки получателю", description: "Произошла ошибка при доставке конкретному получателю", category: "error" }
};
// DOM элементы
const elements = {
periodSelect: document.getElementById('periodSelect'),
customDateRange: document.getElementById('customDateRange'),
dateFrom: document.getElementById('dateFrom'),
dateTo: document.getElementById('dateTo'),
statusSelect: document.getElementById('statusSelect'),
signatureTypeSelect: document.getElementById('signatureTypeSelect'),
senderInput: document.getElementById('senderInput'),
patientInput: document.getElementById('patientInput'),
documentNumberInput: document.getElementById('documentNumberInput'),
advancedToggle: document.getElementById('advancedToggle'),
advancedFilters: document.getElementById('advancedFilters'),
advancedIcon: document.getElementById('advancedIcon'),
searchBtn: document.getElementById('searchBtn'),
clearFiltersBtn: document.getElementById('clearFiltersBtn'),
tableBody: document.getElementById('tableBody'),
emptyState: document.getElementById('emptyState'),
resultsStats: document.getElementById('resultsStats'),
pagination: document.getElementById('pagination'),
paginationInfo: document.getElementById('paginationInfo'),
prevPage: document.getElementById('prevPage'),
nextPage: document.getElementById('nextPage')
};
// Инициализация
async function init() {
// Инициализация Bootstrap модального окна
const modalElement = document.getElementById('statusHistoryModal');
if (modalElement) {
statusModal = new bootstrap.Modal(modalElement);
}
await loadServerConfig();
await loadFiltersFromStorage();
setupEventListeners();
setDefaultDates();
// Автоматически выполняем поиск при загрузке
setTimeout(() => performSearch(), 100);
}
// Загрузка конфигурации сервера
async function loadServerConfig() {
return new Promise((resolve) => {
chrome.storage.local.get(['serverIp', 'serverPort'], (result) => {
if (result.serverIp && result.serverPort) {
state.serverConfig.ip = result.serverIp;
state.serverConfig.port = result.serverPort;
} else {
showAlert('Сервер не настроен. Перейдите в настройки расширения.', 'warning');
}
resolve();
});
});
}
// Загрузка фильтров из storage
async function loadFiltersFromStorage() {
return new Promise((resolve) => {
chrome.storage.local.get(['advancedSearchFilters'], (result) => {
if (result.advancedSearchFilters) {
state.filters = { ...state.filters, ...result.advancedSearchFilters };
// Применяем фильтры к элементам управления
elements.periodSelect.value = state.filters.period;
elements.statusSelect.value = state.filters.status;
elements.signatureTypeSelect.value = state.filters.signatureType;
elements.senderInput.value = state.filters.senderName || '';
elements.patientInput.value = state.filters.patientName || '';
elements.documentNumberInput.value = state.filters.documentNumber || '';
if (state.filters.dateFrom) elements.dateFrom.value = state.filters.dateFrom;
if (state.filters.dateTo) elements.dateTo.value = state.filters.dateTo;
// Если период произвольный - показываем поля дат
if (state.filters.period === 'custom') {
elements.customDateRange.style.display = 'block';
}
}
resolve();
});
});
}
// Сохранение фильтров в storage
function saveFiltersToStorage() {
chrome.storage.local.set({ advancedSearchFilters: state.filters });
}
// Установка обработчиков событий
function setupEventListeners() {
// Период
elements.periodSelect.addEventListener('change', handlePeriodChange);
// Фильтры
elements.statusSelect.addEventListener('change', updateFilters);
elements.signatureTypeSelect.addEventListener('change', updateFilters);
elements.senderInput.addEventListener('input', debounce(updateFilters, 500));
elements.patientInput.addEventListener('input', debounce(updateFilters, 500));
elements.documentNumberInput.addEventListener('input', debounce(updateFilters, 500));
// Произвольные даты
elements.dateFrom.addEventListener('change', updateFilters);
elements.dateTo.addEventListener('change', updateFilters);
// Кнопки
elements.searchBtn.addEventListener('click', performSearch);
elements.clearFiltersBtn.addEventListener('click', clearFilters);
// Расширенные фильтры
elements.advancedToggle.addEventListener('click', toggleAdvancedFilters);
// Пагинация
elements.prevPage.addEventListener('click', () => changePage(-1));
elements.nextPage.addEventListener('click', () => changePage(1));
}
// Установка дат по умолчанию
function setDefaultDates() {
if (!state.filters.dateFrom || !state.filters.dateTo) {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
elements.dateTo.value = formatDate(today);
elements.dateFrom.value = formatDate(yesterday);
state.filters.dateFrom = elements.dateFrom.value;
state.filters.dateTo = elements.dateTo.value;
}
}
// Форматирование даты для input
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Обработчик изменения периода
function handlePeriodChange() {
const period = elements.periodSelect.value;
if (period === 'custom') {
elements.customDateRange.style.display = 'block';
elements.advancedFilters.style.display = 'block';
elements.advancedIcon.className = 'bi bi-chevron-up me-1';
} else {
elements.customDateRange.style.display = 'none';
updateDateRangeFromPeriod(period);
}
updateFilters();
}
// Обновление диапазона дат на основе периода
function updateDateRangeFromPeriod(period) {
const today = new Date();
let from = new Date(today);
switch (period) {
case 'today':
break;
case 'yesterday':
from.setDate(from.getDate() - 1);
today.setDate(today.getDate() - 1);
break;
case 'week':
from.setDate(from.getDate() - 7);
break;
case 'month':
from.setMonth(from.getMonth() - 1);
break;
case 'quarter':
from.setMonth(from.getMonth() - 3);
break;
}
elements.dateFrom.value = formatDate(from);
elements.dateTo.value = formatDate(today);
state.filters.dateFrom = elements.dateFrom.value;
state.filters.dateTo = elements.dateTo.value;
}
// Обновление состояния фильтров
function updateFilters() {
state.filters = {
period: elements.periodSelect.value,
dateFrom: elements.dateFrom.value,
dateTo: elements.dateTo.value,
status: elements.statusSelect.value,
signatureType: elements.signatureTypeSelect.value,
senderName: elements.senderInput.value.trim(),
patientName: elements.patientInput.value.trim(),
documentNumber: elements.documentNumberInput.value.trim()
};
saveFiltersToStorage();
}
// Переключение расширенных фильтров
function toggleAdvancedFilters() {
const isVisible = elements.advancedFilters.style.display !== 'none';
if (isVisible) {
elements.advancedFilters.style.display = 'none';
elements.advancedIcon.className = 'bi bi-chevron-down me-1';
if (state.filters.period !== 'custom') {
elements.customDateRange.style.display = 'none';
}
} else {
elements.advancedFilters.style.display = 'block';
elements.advancedIcon.className = 'bi bi-chevron-up me-1';
if (state.filters.period === 'custom') {
elements.customDateRange.style.display = 'block';
}
}
}
// Сброс фильтров
function clearFilters() {
elements.periodSelect.value = 'today';
elements.customDateRange.style.display = 'none';
elements.statusSelect.value = 'all';
elements.signatureTypeSelect.value = 'all';
elements.senderInput.value = '';
elements.patientInput.value = '';
elements.documentNumberInput.value = '';
setDefaultDates();
updateFilters();
elements.advancedFilters.style.display = 'none';
elements.advancedIcon.className = 'bi bi-chevron-down me-1';
// Выполняем поиск после сброса
performSearch();
}
// Выполнение поиска
async function performSearch() {
if (!validateServerConfig()) {
return;
}
setLoading(true);
try {
updateFilters();
const response = await sendMessageToBackground('advancedSearch', {
filters: state.filters
});
if (response.success) {
// Обогащаем статусы
state.results = enrichStatuses(response.data || []);
state.pagination.totalItems = state.results.length;
state.pagination.totalPages = Math.ceil(state.pagination.totalItems / state.pagination.itemsPerPage);
state.pagination.currentPage = 1;
renderResults();
} else {
showAlert(response.message || 'Ошибка при поиске', 'danger');
}
} catch (error) {
console.error('Search error:', error);
showAlert('Ошибка при выполнении поиска', 'danger');
} finally {
setLoading(false);
}
}
// Обогащение статусов
function enrichStatuses(data) {
if (!Array.isArray(data)) return data;
data.forEach(item => {
if (Array.isArray(item.statuses)) {
item.statuses.forEach(status => {
const statusInfo = statuses[status.status] || {
name: `Статус ${status.status}`,
description: `Неизвестный статус ${status.status}`,
category: "error"
};
status.name = statusInfo.name;
status.description = statusInfo.description;
status.category = statusInfo.category;
});
// Сортируем статусы по id для хронологии
item.statuses.sort((a, b) => a.id - b.id);
}
});
return data;
}
// Определение типа подписания
function getSignatureType(item) {
return item.esiaAuth === true ? 'esia' : 'mchd';
}
function getSignatureTypeText(item) {
return getSignatureType(item) === 'esia' ? 'УКЭП для ЕСИА' : 'МЧД для других';
}
function getSignatureTypeClass(item) {
return getSignatureType(item) === 'esia' ? 'bg-primary-subtle text-primary' : 'bg-secondary-subtle text-secondary';
}
// Отправка сообщения в background
function sendMessageToBackground(action, data) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ action, data }, (response) => {
resolve(response || { success: false, message: 'Нет ответа от background' });
});
});
}
// Валидация конфигурации сервера
function validateServerConfig() {
if (!state.serverConfig.ip || !state.serverConfig.port) {
showAlert('Сервер не настроен. Перейдите в настройки расширения.', 'warning');
return false;
}
return true;
}
// Установка состояния загрузки
function setLoading(isLoading) {
state.loading = isLoading;
if (isLoading) {
elements.searchBtn.disabled = true;
elements.tableBody.innerHTML = `
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<div class="text-muted">Поиск документов...</div>
</td>
</tr>
`;
elements.emptyState.style.display = 'none';
} else {
elements.searchBtn.disabled = false;
}
}
// Отображение результатов
function renderResults() {
if (state.results.length === 0) {
elements.tableBody.innerHTML = '';
elements.emptyState.style.display = 'block';
elements.pagination.style.display = 'none';
elements.resultsStats.textContent = 'Найдено: 0';
return;
}
elements.emptyState.style.display = 'none';
elements.pagination.style.display = 'flex';
// Пагинация
const startIndex = (state.pagination.currentPage - 1) * state.pagination.itemsPerPage;
const endIndex = Math.min(startIndex + state.pagination.itemsPerPage, state.results.length);
const paginatedResults = state.results.slice(startIndex, endIndex);
const rows = paginatedResults.map(item => createTableRow(item)).join('');
elements.tableBody.innerHTML = rows;
// Обновляем статистику
elements.resultsStats.textContent = `Найдено: ${state.pagination.totalItems}`;
// Обновляем пагинацию
updatePagination();
// Добавляем обработчики
addTableEventListeners();
}
// Создание строки таблицы
function createTableRow(item) {
const lastStatus = item.statuses?.length > 0 ? item.statuses[item.statuses.length - 1] : null;
const statusClass = lastStatus?.category || 'warning';
const date = formatDateTime(item.created_at);
// Документы
const documentsHtml = item.documents?.map(doc => {
const canView = lastStatus?.category !== 'completed';
return `
<div class="document-item p-2 mb-1 bg-light rounded ${canView ? 'text-primary' : 'text-muted'}"
data-doc-path="${doc.storagePath || ''}"
data-doc-title="${doc.title}"
data-can-view="${canView}"
style="cursor: ${canView ? 'pointer' : 'default'};">
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold">${doc.number}</span>
<span class="small text-truncate flex-grow-1">${doc.title}</span>
${canView ? '<i class="bi bi-eye"></i>' : ''}
</div>
</div>
`;
}).join('') || '<span class="text-muted small">—</span>';
// Статус
const statusHtml = lastStatus ? `
<div>
<span class="badge ${getStatusBadgeClass(lastStatus.category)} status-badge ${item.statuses?.length > 1 ? '' : ''}"
data-status-history='${JSON.stringify(item.statuses)}'>
<i class="bi ${getStatusIcon(lastStatus.category)} me-1"></i>
${lastStatus.name || 'Неизвестно'}
</span>
<div class="small text-muted mt-1">${formatDateTime(lastStatus.created_at)}</div>
</div>
` : '<span class="text-muted small">—</span>';
// Тип и кнопка
const signatureTypeClass = getSignatureTypeClass(item);
const finalAction = lastStatus?.category === 'completed' && item.storagePath ? `
<button class="btn btn-sm btn-outline-primary mt-1 view-final-btn" data-path="${item.storagePath}" data-title="Итоговый документ">
<i class="bi bi-file-pdf"></i>
</button>
` : '';
return `
<tr data-id="${item.id || ''}">
<td class="align-middle"><span class="small">${date}</span></td>
<td class="align-middle"><span class="small">${item.patientName || 'Неизвестно'}</span></td>
<td class="align-middle" style="max-width: 350px;">
<div class="documents-list" style="max-height: 100px; overflow-y: auto;">
${documentsHtml}
</div>
</td>
<td class="align-middle">${statusHtml}</td>
<td class="align-middle"><span class="small">${item.userName || '—'}</span></td>
<td class="align-middle">
<span class="badge ${signatureTypeClass}">${getSignatureTypeText(item)}</span>
${finalAction}
</td>
</tr>
`;
}
// Bootstrap классы для статусов
function getStatusBadgeClass(category) {
switch (category) {
case 'completed': return 'bg-success-subtle text-success';
case 'processing': return 'bg-info-subtle text-info';
case 'error': return 'bg-danger-subtle text-danger';
default: return 'bg-warning-subtle text-warning';
}
}
// Форматирование даты и времени
function formatDateTime(dateString) {
if (!dateString) return '—';
const date = new Date(dateString.replace(' ', 'T'));
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Получение иконки статуса
function getStatusIcon(category) {
switch (category) {
case 'completed': return 'bi-check-circle-fill';
case 'processing': return 'bi-clock-fill';
case 'error': return 'bi-exclamation-circle-fill';
default: return 'bi-question-circle-fill';
}
}
// Обновление пагинации
function updatePagination() {
const { currentPage, totalPages, totalItems } = state.pagination;
elements.paginationInfo.textContent = `Страница ${currentPage} из ${totalPages || 1}`;
elements.prevPage.disabled = currentPage <= 1;
elements.nextPage.disabled = currentPage >= totalPages;
elements.pagination.style.display = totalPages > 1 ? 'flex' : 'none';
}
// Изменение страницы
function changePage(delta) {
const newPage = state.pagination.currentPage + delta;
if (newPage >= 1 && newPage <= state.pagination.totalPages) {
state.pagination.currentPage = newPage;
renderResults();
}
}
// Добавление обработчиков для таблицы
function addTableEventListeners() {
// Кликабельные документы
document.querySelectorAll('.document-item[data-can-view="true"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const path = el.dataset.docPath;
const title = el.dataset.docTitle;
if (path) viewDocument(path, title);
});
});
// Кликабельные статусы с историей
document.querySelectorAll('.status-badge[data-status-history]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const historyData = el.dataset.statusHistory;
if (historyData) {
showStatusHistory(JSON.parse(historyData));
}
});
});
// Кнопки просмотра итогового документа
document.querySelectorAll('.view-final-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const path = btn.dataset.path;
const title = btn.dataset.title;
if (path) viewDocument(path, title);
});
});
}
// Просмотр документа
async function viewDocument(path, title) {
try {
showAlert('Загрузка документа...', 'info');
const response = await sendMessageToBackground('getDocument', { documentPath: path });
if (response.success && response.data) {
const byteCharacters = atob(response.data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
} else {
showAlert('Ошибка загрузки документа', 'danger');
}
} catch (error) {
console.error('Error viewing document:', error);
showAlert('Ошибка просмотра документа', 'danger');
}
}
// Показать историю статусов (Bootstrap модалка)
function showStatusHistory(statuses) {
const statusItems = statuses.map(status => {
const statusInfo = status || {
name: `Статус ${status.status}`,
description: '',
category: 'warning'
};
return `
<div class="d-flex gap-3 mb-3 pb-2 border-bottom">
<div class="flex-shrink-0">
<span class="badge ${getStatusBadgeClass(statusInfo.category)} p-2">
<i class="bi ${getStatusIcon(statusInfo.category)}"></i>
</span>
</div>
<div class="flex-grow-1">
<div class="fw-semibold">${statusInfo.name}</div>
<div class="small text-muted mb-1">${statusInfo.description || 'Нет описания'}</div>
<div class="small text-muted">${formatDateTime(status.created_at)}</div>
</div>
</div>
`;
}).join('');
document.getElementById('statusHistoryBody').innerHTML = statusItems || '<p class="text-muted text-center">Нет истории статусов</p>';
if (statusModal) {
statusModal.show();
}
}
// Отображение уведомления (Bootstrap Toast)
function showAlert(message, type = 'info') {
// Создаем контейнер для тостов, если его нет
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '1100';
document.body.appendChild(toastContainer);
}
// Создаем тост
const toastId = 'toast-' + Date.now();
const toastHtml = `
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, { autohide: true, delay: 5000 });
toast.show();
// Удаляем после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Debounce функция
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Запуск
document.addEventListener('DOMContentLoaded', init);
})();
+808
View File
@@ -0,0 +1,808 @@
// singing.js
(function () {
// Глобальная переменная для хранения текущего уведомления
let currentMessageEl = null;
let messageTimeout = null;
let isSending = false;
// Функция для отображения сообщений
function showMessage(message, type = 'success', duration = 3000) {
// Удаляем предыдущее уведомление, если оно есть
if (currentMessageEl && currentMessageEl.parentNode) {
clearTimeout(messageTimeout);
document.body.removeChild(currentMessageEl);
currentMessageEl = null;
}
const messageEl = document.createElement('div');
messageEl.className = `el-message el-message--${type}`;
messageEl.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
min-width: 380px;
padding: 15px 15px 15px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
background-color: ${type === 'success' ? '#f0f9eb' :
type === 'info' ? '#f4f4f5' : '#fef0f0'};
border: 1px solid ${type === 'success' ? '#e1f3d8' :
type === 'info' ? '#e9e9eb' : '#fde2e2'};
color: ${type === 'success' ? '#67c23a' :
type === 'info' ? '#909399' : '#f56c6c'};
display: flex;
align-items: center;
`;
const iconClass = type === 'success' ? 'el-icon-success' :
type === 'info' ? 'el-icon-info' : 'el-icon-error';
const iconColor = type === 'success' ? '#67c23a' :
type === 'info' ? '#909399' : '#f56c6c';
messageEl.innerHTML = `
<i class="el-icon ${iconClass}" style="font-size: 16px; color: ${iconColor}; margin-right: 10px;"></i>
<span style="font-size: 14px;">${message}</span>
`;
document.body.appendChild(messageEl);
currentMessageEl = messageEl;
// Скрываем через указанное время
messageTimeout = setTimeout(() => {
if (currentMessageEl === messageEl && messageEl.parentNode) {
document.body.removeChild(messageEl);
currentMessageEl = null;
}
}, duration);
}
// Функция отправки сообщения
function sendMessageToContent(type, payload) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Таймаут ожидания ответа'));
}, 60000);
const messageHandler = (event) => {
if (
event.source !== window ||
!event.data ||
event.data.source !== 'medods-extension' ||
event.data.type !== `${type}Response`
) {
return;
}
window.removeEventListener('message', messageHandler);
clearTimeout(timeoutId);
resolve(event.data.payload);
};
window.addEventListener('message', messageHandler);
window.postMessage({
source: 'medods-extension',
type: type,
payload: payload,
}, '*');
});
}
// Обработчик для финальных результатов от background
function setupBackgroundResultHandler() {
window.addEventListener('message', (event) => {
if (
event.source !== window ||
!event.data ||
event.data.source !== 'medods-extension' ||
event.data.type !== 'backgroundProcessingResult'
) {
return;
}
console.log('Получен финальный результат от background:', event.data);
const result = event.data.payload;
if (result.success) {
showMessage(`Успешно отправлено документов: ${result.sentCount || 1}`, 'success');
} else {
const errorMessage = result.message || 'Неизвестная ошибка';
showMessage(`Ошибка: ${errorMessage}`, 'error');
}
});
}
// Проверка совместимости документов
function checkDocumentsCompatibility(documents) {
if (documents.length === 0) {
return { compatible: true, type: null };
}
let hasIds = false;
let hasNonIds = false;
for (const doc of documents) {
if (doc.title.includes('ИДС')) {
hasIds = true;
} else {
hasNonIds = true;
}
if (hasIds && hasNonIds) {
return {
compatible: false,
type: 'error',
message: 'Нельзя одновременно отправлять документы требующие для подписания авторизацию ЕСИА (ГосУслуги) - "ИДС" и другие документы. Выберите документы одного типа.'
};
}
}
const docType = hasIds ? 'через ЕСИА (ГосУслуги)' : 'ПЭП';
return {
compatible: true,
type: docType
};
}
// Функция получения выбранных документов
function getSelectedDocuments() {
const table = document.querySelector('.m-table.m-table-generator.m-si-generator__table');
if (!table) return [];
const checkboxes = table.querySelectorAll('.el-checkbox__original');
const selectedDocs = [];
checkboxes.forEach((checkbox) => {
if (checkbox.checked) {
const row = checkbox.closest('.m-table-row');
if (row) {
const rowData = {};
const numberCell = row.querySelector('.col__number .m-table-row-cell__struct');
const titleCell = row.querySelector('.col__title .m-table-row-cell__struct');
if (numberCell) rowData.number = numberCell.textContent.trim();
if (titleCell) rowData.title = titleCell.textContent.trim();
if (rowData.number && rowData.title) {
selectedDocs.push(rowData);
}
}
}
});
return selectedDocs;
}
// Функция проверки ограничения в 5 документов
function validateDocumentCount(documents) {
if (documents.length === 0) {
return { valid: false, message: 'Выберите хотя бы один документ для отправки' };
}
if (documents.length > 5) {
return { valid: false, message: 'Нельзя отправить более 5 документов за раз' };
}
return { valid: true, message: '' };
}
// Функция создания модального окна предварительной проверки
function showPreSingingDialog(documents, preSingingData) {
// Проверяем данные
const signature = preSingingData.signature || {};
const inProgress = preSingingData.inProgress || [];
const complete = preSingingData.complete || [];
const daysRemainingControl = preSingingData.daysRemainingControl || 30;
// Определяем тип документов
const hasIds = documents.some(doc => doc.title.includes('ИДС'));
// Проверяем соответствие типов
let typeMatch = false;
if (signature.type === 'eSignature' && hasIds) {
typeMatch = true;
} else if (signature.type === 'eAttorney' && !hasIds) {
typeMatch = true;
}
// Проверяем срок действия
let expirationInfo = '';
let expirationFormatted = '';
let expirationClass = 'info';
let expirationTitle = '';
let isExpired = false;
let daysRemaining = 0;
if (signature.expiration) {
const expirationDate = new Date(signature.expiration);
const today = new Date();
const timeDiff = expirationDate - today;
daysRemaining = Math.ceil(timeDiff / (1000 * 3600 * 24));
// Форматируем дату
const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
expirationFormatted = dateFormatter.format(expirationDate);
expirationInfo = expirationFormatted;
// Определяем тип для заголовка
if (signature.type === 'eSignature') {
expirationTitle = 'Электронная подпись';
} else if (signature.type === 'eAttorney') {
expirationTitle = 'Электронная доверенность';
}
if (daysRemaining < 0) {
expirationClass = 'danger';
isExpired = true;
} else if (daysRemaining < daysRemainingControl) {
expirationClass = 'warning';
} else {
expirationClass = 'success';
}
}
// Определяем статусы документов
const documentsWithStatus = documents.map(doc => {
const docNumber = parseInt(doc.number, 10);
let status = 'ready';
let statusClass = 'success';
let statusIcon = 'el-icon-success';
if (inProgress.includes(docNumber)) {
status = 'in-progress';
statusClass = 'info';
statusIcon = 'el-icon-time';
} else if (complete.includes(docNumber)) {
status = 'complete';
statusClass = 'primary';
statusIcon = 'el-icon-check';
}
return {
...doc,
status,
statusClass,
statusIcon,
isEsia: doc.title.includes('ИДС')
};
});
// Определяем, можно ли отправлять
let canSend = typeMatch && !isExpired;
// Проверяем статусы документов
const hasInProgress = documentsWithStatus.some(doc => doc.status === 'in-progress');
const hasComplete = documentsWithStatus.some(doc => doc.status === 'complete');
if (hasInProgress || hasComplete) {
canSend = false;
}
// Если нет signature - нельзя отправлять
if (!signature.type) {
canSend = false;
}
// Если типы не соответствуют
if (!typeMatch && signature.type) {
canSend = false;
}
// Создаем элементы модального окна напрямую
const modalWrapper = document.createElement('div');
modalWrapper.className = 'el-dialog__wrapper';
modalWrapper.style.cssText = 'z-index: 2001; position: fixed; top: 0; right: 0; bottom: 0; left: 0; overflow: auto;';
const backdrop = document.createElement('div');
backdrop.className = 'v-modal';
backdrop.style.cssText = 'z-index: 2001;';
backdrop.tabIndex = 0;
const dialog = document.createElement('div');
dialog.className = 'el-dialog';
dialog.style.cssText = 'margin-top: 15vh; width: 700px; z-index: 2002; position: relative; margin: 15vh auto 50px; background: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);';
// Создаем заголовок
const dialogHeader = document.createElement('div');
dialogHeader.className = 'el-dialog__header';
const dialogTitle = document.createElement('span');
dialogTitle.className = 'el-dialog__title';
dialogTitle.style.cssText = 'line-height: 24px; font-size: 18px; color: #303133;';
dialogTitle.textContent = 'Подготовка документов к отправке для электронного подписания';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'el-dialog__headerbtn';
closeButton.style.cssText = 'position: absolute; top: 20px; right: 20px; padding: 0; background: 0 0; border: none; outline: 0; cursor: pointer; font-size: 16px;';
const closeIcon = document.createElement('i');
closeIcon.className = 'el-dialog__close el-icon el-icon-close';
closeButton.appendChild(closeIcon);
dialogHeader.appendChild(dialogTitle);
dialogHeader.appendChild(closeButton);
// Создаем тело диалога
const dialogBody = document.createElement('div');
dialogBody.className = 'el-dialog__body';
dialogBody.style.cssText = 'padding: 15px; color: #606266; font-size: 14px;';
// Создаем контент
const dialogContent = document.createElement('div');
dialogContent.className = 'dialog-content';
// Предупреждения (ближе к заголовку)
if (!canSend) {
const warningsSection = document.createElement('div');
warningsSection.className = 'warnings-section';
warningsSection.style.cssText = 'margin-bottom: 15px;';
// Собираем все предупреждения в один массив
const warnings = [];
if (!signature.type) {
warnings.push({
type: 'error',
title: 'Нет прав на отправку',
message: 'У вас нет прав на электронное подписание документов'
});
}
if (daysRemaining < 0) {
let typeName = 'документа';
if (signature.type === 'eSignature') {
typeName = 'электронной подписи';
} else if (signature.type === 'eAttorney') {
typeName = 'электронной доверенности';
}
warnings.push({
type: 'error',
title: 'Срок действия истек',
message: `Срок действия ${typeName} истек ${Math.abs(daysRemaining)} дней назад`
});
}
if (!typeMatch && signature.type) {
const availableType = signature.type === 'eSignature' ? 'электронная подпись' : 'электронная доверенность';
const neededType = hasIds ? 'электронная подпись' : 'электронная доверенность';
warnings.push({
type: 'warning',
title: 'Несоответствие типов документов',
message: `Для подписания подготовлены документы для которых требуется "${neededType}", но у вас доступна "${availableType}"`
});
}
if (hasInProgress || hasComplete) {
warnings.push({
type: 'warning',
title: 'Невозможно отправить документы',
message: 'Среди выбранных документов есть уже подписанные (✔️) или направленные для подписания (🕒)'
});
}
// Отображаем все предупреждения
warnings.forEach(warning => {
const warningEl = document.createElement('div');
warningEl.className = `el-alert el-alert--${warning.type} is-light`;
warningEl.style.cssText = 'margin-bottom: 8px; padding: 8px; border-radius: 4px;';
const warningContent = document.createElement('div');
warningContent.className = 'el-alert__content';
const warningTitle = document.createElement('span');
warningTitle.className = 'el-alert__title';
warningTitle.style.cssText = 'font-size: 13px; color: ' +
(warning.type === 'error' ? '#f56c6c' : '#e6a23c') + ';';
warningTitle.textContent = warning.title;
const warningDesc = document.createElement('p');
warningDesc.className = 'el-alert__description';
warningDesc.style.cssText = 'font-size: 12px; margin-top: 3px; color: ' +
(warning.type === 'error' ? '#f56c6c' : '#e6a23c') + ';';
warningDesc.textContent = warning.message;
warningContent.appendChild(warningTitle);
warningContent.appendChild(warningDesc);
warningEl.appendChild(warningContent);
warningsSection.appendChild(warningEl);
});
dialogContent.appendChild(warningsSection);
}
// Список документов
const documentsSection = document.createElement('div');
documentsSection.className = 'documents-section';
const documentsTitle = document.createElement('h4');
documentsTitle.style.cssText = 'margin: 0 0 10px 0; color: #303133; font-size: 16px;';
documentsTitle.textContent = 'Список документов:';
const documentsList = document.createElement('div');
documentsList.style.cssText = 'border: 1px solid #EBEEF5; border-radius: 4px; padding: 0 10px; max-height: 200px; overflow-y: auto;';
// Добавляем документы в список
documentsWithStatus.forEach((doc, index) => {
const docItem = document.createElement('div');
// Для последнего элемента не добавляем нижнюю границу
if (index === documentsWithStatus.length - 1) {
docItem.style.cssText = 'display: flex; align-items: center; padding: 8px 0;';
} else {
docItem.style.cssText = 'display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #F0F0F0;';
}
// Статус (иконка слева)
const docStatusIcon = document.createElement('div');
docStatusIcon.style.cssText = 'flex: 0 0 24px; margin-right: 8px;';
const statusIcon = document.createElement('i');
statusIcon.className = doc.statusIcon;
statusIcon.style.cssText = `font-size: 16px; color: ${doc.status === 'ready' ? '#67c23a' :
doc.status === 'in-progress' ? '#909399' : '#409eff'
};`;
docStatusIcon.appendChild(statusIcon);
// Номер документа (стилизован в зависимости от статуса)
const docNumber = document.createElement('div');
docNumber.style.cssText = 'flex: 0 0 60px;';
const numberTag = document.createElement('span');
if (doc.status === 'ready') {
numberTag.className = 'el-tag el-tag--success el-tag--small';
numberTag.style.cssText = 'display: inline-block; padding: 0 7px; height: 24px; line-height: 22px; font-size: 12px; border-width: 1px; border-style: solid; border-radius: 4px; box-sizing: border-box; white-space: nowrap;';
} else if (doc.status === 'in-progress') {
numberTag.className = 'el-tag el-tag--info el-tag--small';
numberTag.style.cssText = 'display: inline-block; padding: 0 7px; height: 24px; line-height: 22px; font-size: 12px; border-width: 1px; border-style: solid; border-radius: 4px; box-sizing: border-box; white-space: nowrap;';
} else {
numberTag.className = 'el-tag el-tag--primary el-tag--small';
numberTag.style.cssText = 'display: inline-block; padding: 0 7px; height: 24px; line-height: 22px; font-size: 12px; border-width: 1px; border-style: solid; border-radius: 4px; box-sizing: border-box; white-space: nowrap;';
}
numberTag.textContent = `${doc.number}`;
docNumber.appendChild(numberTag);
// Название документа (занимает все оставшееся пространство)
const docTitle = document.createElement('div');
docTitle.style.cssText = 'flex: 1; color: #606266; font-size: 13px; padding: 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
docTitle.textContent = doc.title;
docItem.appendChild(docStatusIcon);
docItem.appendChild(docNumber);
docItem.appendChild(docTitle);
documentsList.appendChild(docItem);
});
documentsSection.appendChild(documentsTitle);
documentsSection.appendChild(documentsList);
dialogContent.appendChild(documentsSection);
dialogBody.appendChild(dialogContent);
// Создаем футер
const dialogFooter = document.createElement('div');
dialogFooter.className = 'el-dialog__footer';
dialogFooter.style.cssText = 'display: flex; justify-content: space-between; align-items: center;';
// Левая часть футера (информация о сроке действия разрешения на подпись)
const footerLeft = document.createElement('div');
footerLeft.style.cssText = 'flex: 1; display: flex; flex-direction: column; align-items: flex-start;';
if (signature.expiration && signature.type) {
// Тип разрешения
const typeContainer = document.createElement('div');
typeContainer.style.cssText = 'margin-bottom: 5px; margin-top: 0px;';
const typeText = document.createElement('span');
typeText.style.cssText = 'font-size: 12px; font-weight: 500; color: #606266;';
if (signature.type === 'eSignature') {
typeText.textContent = 'Электронная подпись';
} else if (signature.type === 'eAttorney') {
typeText.textContent = 'Электронная доверенность';
}
typeContainer.appendChild(typeText);
// Срок действия
const expirationContainer = document.createElement('div');
expirationContainer.style.cssText = 'display: flex; align-items: center; flex-wrap: wrap; margin-bottom: 0px;';
const expirationLabel = document.createElement('span');
expirationLabel.style.cssText = 'font-size: 12px; color: #606266; margin-right: 5px;';
expirationLabel.textContent = 'Срок действия:';
const expirationDate = document.createElement('span');
expirationDate.style.cssText = 'font-size: 12px; color: #606266; margin-right: 8px;';
expirationDate.textContent = expirationFormatted;
expirationContainer.appendChild(expirationLabel);
expirationContainer.appendChild(expirationDate);
// Остаточный срок (показываем справа от даты если меньше 30 дней)
if (daysRemaining >= 0 && daysRemaining < 30) {
const warningIcon = document.createElement('i');
warningIcon.className = 'el-icon-warning';
warningIcon.style.cssText = 'font-size: 14px; color: #e6a23c; margin-left: 4px; margin-right: 4px;';
const remainingText = document.createElement('span');
remainingText.style.cssText = 'font-size: 12px; color: #e6a23c; font-weight: 500;';
remainingText.textContent = `осталось ${daysRemaining} ${getDaysText(daysRemaining)}`;
typeContainer.appendChild(warningIcon);
typeContainer.appendChild(remainingText);
}
footerLeft.appendChild(typeContainer);
footerLeft.appendChild(expirationContainer);
}
// Вспомогательная функция для склонения дней
function getDaysText(days) {
const lastDigit = days % 10;
const lastTwoDigits = days % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
return 'дней';
}
switch (lastDigit) {
case 1: return 'день';
case 2:
case 3:
case 4: return 'дня';
default: return 'дней';
}
}
// Правая часть футера (кнопки)
const footerRight = document.createElement('div');
footerRight.className = 'dialog-footer';
footerRight.style.cssText = 'display: flex; align-items: center; padding-top: 10px;';
// Кнопка "Отмена" - стиль как у кнопки на главной странице
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'el-button m-button el-button--small is-plain button-with-icon cancel-button';
const cancelIcon = document.createElement('i');
cancelIcon.className = 'm-icon fa-times button-icon fad';
cancelIcon.style.cssText = 'font-size: 14px; margin-right: 6px;';
const cancelText = document.createElement('span');
cancelText.textContent = 'Отмена';
cancelBtn.appendChild(cancelIcon);
cancelBtn.appendChild(cancelText);
// Кнопка "Отправить документы" - стиль как у кнопки "подписать электронно"
const sendBtn = document.createElement('button');
sendBtn.type = 'button';
if (canSend) {
sendBtn.className = 'el-button m-button el-button--small is-plain send-button button-with-icon';
} else {
sendBtn.className = 'el-button m-button el-button--small is-plain send-button button-with-icon is-disabled';
}
const sendIcon = document.createElement('i');
sendIcon.className = 'm-icon fa-file-signature button-icon fad';
sendIcon.style.cssText = 'font-size: 14px; margin-right: 6px;';
const sendText = document.createElement('span');
sendText.textContent = 'Отправить документы';
sendBtn.appendChild(sendIcon);
sendBtn.appendChild(sendText);
sendBtn.disabled = !canSend;
footerRight.appendChild(cancelBtn);
footerRight.appendChild(sendBtn);
dialogFooter.appendChild(footerLeft);
dialogFooter.appendChild(footerRight);
// Собираем диалог
dialog.appendChild(dialogHeader);
dialog.appendChild(dialogBody);
dialog.appendChild(dialogFooter);
// Собираем модальное окно
modalWrapper.appendChild(backdrop);
modalWrapper.appendChild(dialog);
document.body.appendChild(modalWrapper);
// Обработчик отправки
async function handleSend() {
if (!canSend) return;
closeModal();
// Отправляем документы на подписание
try {
const docNumbers = documents.map(doc => parseInt(doc.number, 10));
const result = await sendMessageToContent('sendDocuments', {
docNumbers: docNumbers
});
if (result && result.response) {
if (result.response.status === 'processing') {
showMessage('Документы приняты в обработку. Ожидайте результата...', 'info', 5000);
}
}
} catch (error) {
console.error('Ошибка при отправке:', error);
showMessage(`Ошибка: ${error.message}`, 'error');
}
}
function closeModal() {
if (modalWrapper.parentNode) {
document.body.removeChild(modalWrapper);
}
}
// Назначаем обработчики
closeButton.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', closeModal);
if (canSend) {
sendBtn.addEventListener('click', handleSend);
}
// Закрытие по ESC
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// Удаляем обработчик ESC при закрытии
const originalClose = closeModal;
closeModal = () => {
document.removeEventListener('keydown', escHandler);
originalClose();
};
}
// Функция предварительной проверки документов
async function preSingingCheck(documents) {
try {
// Проверяем количество документов
const countValidation = validateDocumentCount(documents);
if (!countValidation.valid) {
showMessage(countValidation.message, 'error');
return;
}
// Проверяем совместимость документов
const compatibility = checkDocumentsCompatibility(documents);
if (!compatibility.compatible) {
showMessage(compatibility.message, 'error');
return;
}
// Отправляем запрос на предварительную проверку
showMessage('Проверка документов...', 'info');
// Отправляем запрос в content.js, чтобы он собрал и отправил данные
const result = await sendMessageToContent('preSingingCheck', {
// Передаем только флаг о типе документов для совместимости
docNumbers: documents.map(doc => parseInt(doc.number))
});
if (result && result.success && result.data) {
// Показываем диалоговое окно с результатами проверки
showPreSingingDialog(documents, result.data);
} else {
showMessage('Ошибка при проверке документов', 'error');
}
} catch (error) {
console.error('Ошибка предварительной проверки:', error);
showMessage(`Ошибка: ${error.message}`, 'error');
}
}
// Функция добавления кнопки
function addSendBtn() {
const sendBtn = document.createElement('button');
sendBtn.className = 'el-button m-button el-button--small is-disabled is-plain button-with-icon';
sendBtn.innerHTML = `
<span>
<i class="m-icon fa-file-signature button-icon fad" style="font-size: 16px;"></i>
<span class="m-button__text use-indent">Подписать электронно</span>
</span>
`;
sendBtn.disabled = true;
// Локальный обработчик для кнопки
async function handleSendClick() {
if (isSending) {
console.log('Уже идет отправка, игнорируем клик');
return;
}
const selectedDocs = getSelectedDocuments();
// Запускаем предварительную проверку
preSingingCheck(selectedDocs);
}
function checkSelectedDocuments() {
return getSelectedDocuments().length > 0;
}
function updateButtonState() {
const hasSelectedDocs = checkSelectedDocuments();
sendBtn.disabled = !hasSelectedDocs || isSending;
if (sendBtn.disabled) {
sendBtn.classList.add('is-disabled');
} else {
sendBtn.classList.remove('is-disabled');
}
}
function setupTableObserver() {
const table = document.querySelector('.m-table.m-table-generator.m-si-generator__table');
if (!table) {
console.warn('Таблица документов не найдена');
return;
}
table.addEventListener('change', (e) => {
if (e.target.classList.contains('el-checkbox__original')) {
updateButtonState();
}
});
table.addEventListener('click', (e) => {
if (e.target.closest('.el-checkbox') ||
e.target.closest('.el-checkbox__inner')) {
setTimeout(updateButtonState, 10);
}
});
setTimeout(updateButtonState, 100);
}
const m_panel_footer = document.querySelector('.m-panel__footer');
if (m_panel_footer) {
m_panel_footer.prepend(sendBtn);
sendBtn.addEventListener('click', handleSendClick);
setupTableObserver();
updateButtonState();
setupBackgroundResultHandler();
} else {
location.reload();
}
}
// Запуск
setTimeout(() => {
addSendBtn();
}, 500);
})();
+29
View File
@@ -0,0 +1,29 @@
// userData.js
(function () {
function sendUserData() {
if (window.gon) {
try {
const userData = {
id: window.gon.application.current_user.id
};
// Отправляем только один раз
window.postMessage(
{
source: 'medods-extension',
type: 'userData',
payload: userData
},
'*'
);
return;
} catch (error) {
console.error('Error in sendUserData:', error);
}
}
}
// Запускаем после небольшой задержки
setTimeout(sendUserData, 100);
})();