api-client
This commit is contained in:
138
api-client.js
138
api-client.js
@@ -445,7 +445,7 @@ module.exports = function(app, db, upload) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/client/tasks/:taskId/sync - Синхронизировать задачу с целевым сервисом
|
* POST /api/client/tasks/:taskId/sync - Синхронизировать ЛОКАЛЬНУЮ задачу с целевым сервисом
|
||||||
*/
|
*/
|
||||||
router.post('/api/client/tasks/:taskId/sync', requireAuth, async (req, res) => {
|
router.post('/api/client/tasks/:taskId/sync', requireAuth, async (req, res) => {
|
||||||
const { taskId } = req.params;
|
const { taskId } = req.params;
|
||||||
@@ -455,15 +455,9 @@ module.exports = function(app, db, upload) {
|
|||||||
target_api_key,
|
target_api_key,
|
||||||
sync_files = true
|
sync_files = true
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const { connection_id } = req.query;
|
|
||||||
const userId = req.session.user.id;
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
if (!connection_id && !req.session.clientConnections) {
|
// Определяем целевой сервис
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Не указан источник (текущее подключение)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetUrl = target_api_url;
|
let targetUrl = target_api_url;
|
||||||
let targetKey = target_api_key;
|
let targetKey = target_api_key;
|
||||||
|
|
||||||
@@ -479,30 +473,35 @@ module.exports = function(app, db, upload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = targetUrl.replace(/\/$/, '');
|
// 1. Получаем задачу из ЛОКАЛЬНОЙ базы данных
|
||||||
const sourceConnection = req.session.clientConnections[connection_id];
|
const localTask = await new Promise((resolve, reject) => {
|
||||||
|
db.get(`
|
||||||
|
SELECT t.*, u.name as creator_name, u.login as creator_login
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN users u ON t.created_by = u.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
`, [taskId], (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (!sourceConnection) {
|
if (!localTask) {
|
||||||
return res.status(400).json({ error: 'Исходное подключение не найдено' });
|
return res.status(404).json({ error: 'Локальная задача не найдена' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Получаем исходную задачу
|
// Преобразуем в формат, аналогичный внешнему API
|
||||||
const sourceResponse = await axios.get(
|
const sourceTask = {
|
||||||
`${sourceConnection.url}/api/external/tasks/${taskId}`,
|
id: localTask.id,
|
||||||
{
|
title: localTask.title,
|
||||||
headers: {
|
description: localTask.description || '',
|
||||||
'X-API-Key': sourceConnection.api_key
|
due_date: localTask.due_date,
|
||||||
},
|
task_type: localTask.task_type || 'regular',
|
||||||
timeout: 10000
|
files: await getLocalTaskFiles(taskId)
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
if (!sourceResponse.data || !sourceResponse.data.success) {
|
|
||||||
return res.status(404).json({ error: 'Исходная задача не найдена' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceTask = sourceResponse.data.task;
|
|
||||||
|
|
||||||
// 2. Проверяем, существует ли задача в целевой системе (поиск по ID)
|
// 2. Проверяем, существует ли задача в целевой системе (поиск по ID)
|
||||||
let existingTask = null;
|
let existingTask = null;
|
||||||
@@ -510,9 +509,7 @@ module.exports = function(app, db, upload) {
|
|||||||
const checkResponse = await axios.get(
|
const checkResponse = await axios.get(
|
||||||
`${baseUrl}/api/external/tasks/${taskId}`,
|
`${baseUrl}/api/external/tasks/${taskId}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: { 'X-API-Key': targetKey },
|
||||||
'X-API-Key': targetKey
|
|
||||||
},
|
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -534,7 +531,7 @@ module.exports = function(app, db, upload) {
|
|||||||
title: sourceTask.title,
|
title: sourceTask.title,
|
||||||
description: sourceTask.description,
|
description: sourceTask.description,
|
||||||
due_date: sourceTask.due_date,
|
due_date: sourceTask.due_date,
|
||||||
task_type: sourceTask.task_type || 'regular'
|
task_type: sourceTask.task_type
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateResponse = await axios.put(
|
const updateResponse = await axios.put(
|
||||||
@@ -550,22 +547,17 @@ module.exports = function(app, db, upload) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!updateResponse.data || !updateResponse.data.success) {
|
if (!updateResponse.data || !updateResponse.data.success) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({ error: 'Не удалось обновить задачу в целевом сервисе' });
|
||||||
error: 'Не удалось обновить задачу в целевом сервисе'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = { taskId: taskId, action: 'updated' };
|
||||||
taskId: taskId,
|
|
||||||
action: 'updated'
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
// 4. Создаем новую задачу (без префикса "Копия:")
|
// 4. Создаём новую задачу
|
||||||
const taskData = {
|
const taskData = {
|
||||||
title: sourceTask.title,
|
title: sourceTask.title,
|
||||||
description: sourceTask.description || '',
|
description: sourceTask.description,
|
||||||
due_date: sourceTask.due_date,
|
due_date: sourceTask.due_date,
|
||||||
task_type: sourceTask.task_type || 'regular'
|
task_type: sourceTask.task_type
|
||||||
};
|
};
|
||||||
|
|
||||||
const createResponse = await axios.post(
|
const createResponse = await axios.post(
|
||||||
@@ -581,9 +573,7 @@ module.exports = function(app, db, upload) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!createResponse.data || !createResponse.data.success) {
|
if (!createResponse.data || !createResponse.data.success) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({ error: 'Не удалось создать задачу в целевом сервисе' });
|
||||||
error: 'Не удалось создать задачу в целевом сервисе'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -596,21 +586,17 @@ module.exports = function(app, db, upload) {
|
|||||||
if (sync_files && sourceTask.files && sourceTask.files.length > 0) {
|
if (sync_files && sourceTask.files && sourceTask.files.length > 0) {
|
||||||
for (const file of sourceTask.files) {
|
for (const file of sourceTask.files) {
|
||||||
try {
|
try {
|
||||||
// Скачиваем файл из источника
|
// Читаем файл с диска
|
||||||
const fileResponse = await axios({
|
if (!fs.existsSync(file.file_path)) {
|
||||||
method: 'GET',
|
throw new Error(`Файл не найден на диске: ${file.file_path}`);
|
||||||
url: `${sourceConnection.url}/api/external/tasks/${taskId}/files/${file.id}/download`,
|
}
|
||||||
headers: {
|
const fileBuffer = fs.readFileSync(file.file_path);
|
||||||
'X-API-Key': sourceConnection.api_key
|
const fileName = file.original_name || path.basename(file.file_path);
|
||||||
},
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Загружаем в целевую систему
|
// Загружаем в целевую систему
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('files', Buffer.from(fileResponse.data), {
|
formData.append('files', fileBuffer, {
|
||||||
filename: file.filename || file.original_name || 'file',
|
filename: fileName,
|
||||||
contentType: file.file_type || 'application/octet-stream'
|
contentType: file.file_type || 'application/octet-stream'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -629,25 +615,26 @@ module.exports = function(app, db, upload) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
syncedFiles.push({
|
syncedFiles.push({
|
||||||
original_name: file.filename || file.original_name,
|
original_name: fileName,
|
||||||
success: uploadResponse.data && uploadResponse.data.success
|
success: uploadResponse.data && uploadResponse.data.success
|
||||||
});
|
});
|
||||||
} catch (fileError) {
|
} catch (fileError) {
|
||||||
console.error(`❌ Ошибка синхронизации файла:`, fileError.message);
|
console.error(`❌ Ошибка синхронизации файла:`, fileError.message);
|
||||||
syncedFiles.push({
|
syncedFiles.push({
|
||||||
original_name: file.filename || file.original_name,
|
original_name: file.original_name || 'unknown',
|
||||||
success: false,
|
success: false,
|
||||||
error: fileError.message
|
error: fileError.message
|
||||||
});
|
});
|
||||||
warnings.push(`Не удалось синхронизировать файл: ${file.filename || file.original_name}`);
|
warnings.push(`Не удалось синхронизировать файл: ${file.original_name || file.filename}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
const { logActivity } = require('./database');
|
const { logActivity } = require('./database');
|
||||||
if (logActivity) {
|
if (logActivity) {
|
||||||
logActivity(0, userId, 'API_CLIENT_SYNC_TASK',
|
logActivity(0, userId, 'API_CLIENT_SYNC_TASK',
|
||||||
`${existingTask ? 'Обновлена' : 'Создана'} задача ${taskId} из ${sourceConnection.url} в ${baseUrl}. ${existingTask ? 'Обновление' : 'Новый ID: ' + result.taskId}`);
|
`Локальная задача ${taskId} ${existingTask ? 'обновлена' : 'создана'} в ${baseUrl}. Новый ID: ${result.taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -659,14 +646,14 @@ module.exports = function(app, db, upload) {
|
|||||||
target_task_id: result.taskId,
|
target_task_id: result.taskId,
|
||||||
target_service: baseUrl,
|
target_service: baseUrl,
|
||||||
synced_files: syncedFiles,
|
synced_files: syncedFiles,
|
||||||
assignees: sourceTask.assignments || [],
|
assignees: [], // исполнители не передаём
|
||||||
warnings: warnings
|
warnings: warnings,
|
||||||
|
source: 'local'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка синхронизации задачи:', error.message);
|
console.error('❌ Ошибка синхронизации задачи:', error.message);
|
||||||
|
|
||||||
let errorMessage = 'Ошибка синхронизации задачи';
|
let errorMessage = 'Ошибка синхронизации задачи';
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
|
|
||||||
@@ -678,7 +665,7 @@ module.exports = function(app, db, upload) {
|
|||||||
errorMessage = 'Нет прав для создания/обновления задачи в целевом сервисе';
|
errorMessage = 'Нет прав для создания/обновления задачи в целевом сервисе';
|
||||||
statusCode = 403;
|
statusCode = 403;
|
||||||
} else if (error.response.status === 404) {
|
} else if (error.response.status === 404) {
|
||||||
errorMessage = 'Исходная задача не найдена';
|
errorMessage = 'Целевой эндпоинт не найден';
|
||||||
statusCode = 404;
|
statusCode = 404;
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.response.data?.error || `Ошибка сервера: ${error.response.status}`;
|
errorMessage = error.response.data?.error || `Ошибка сервера: ${error.response.status}`;
|
||||||
@@ -692,10 +679,7 @@ module.exports = function(app, db, upload) {
|
|||||||
statusCode = 504;
|
statusCode = 504;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(statusCode).json({
|
res.status(statusCode).json({ error: errorMessage, details: error.message });
|
||||||
error: errorMessage,
|
|
||||||
details: error.message
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -929,3 +913,19 @@ module.exports = function(app, db, upload) {
|
|||||||
|
|
||||||
console.log('✅ API клиент для внешних сервисов подключен');
|
console.log('✅ API клиент для внешних сервисов подключен');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения файлов локальной задачи
|
||||||
|
async function getLocalTaskFiles(taskId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT tf.*, u.name as user_name
|
||||||
|
FROM task_files tf
|
||||||
|
LEFT JOIN users u ON tf.user_id = u.id
|
||||||
|
WHERE tf.task_id = ?
|
||||||
|
ORDER BY tf.uploaded_at DESC
|
||||||
|
`, [taskId], (err, files) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(files || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -989,757 +989,6 @@
|
|||||||
<!-- Уведомления -->
|
<!-- Уведомления -->
|
||||||
<div id="alert" class="alert"></div>
|
<div id="alert" class="alert"></div>
|
||||||
|
|
||||||
<script>
|
<script src="client.js"></script>
|
||||||
// Глобальные переменные
|
|
||||||
let currentConnectionId = null;
|
|
||||||
let currentTasks = [];
|
|
||||||
let currentPage = 0;
|
|
||||||
let totalTasks = 0;
|
|
||||||
let pageSize = 50;
|
|
||||||
let currentTaskId = null;
|
|
||||||
let selectedFiles = [];
|
|
||||||
let syncTaskId = null;
|
|
||||||
let syncTaskTitle = '';
|
|
||||||
|
|
||||||
// Проверка авторизации
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.user) {
|
|
||||||
document.getElementById('userName').textContent = data.user.name || data.user.login;
|
|
||||||
} else {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка проверки авторизации:', error);
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выход
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/logout', { method: 'POST' });
|
|
||||||
window.location.href = '/';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при выходе:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка сохраненных подключений
|
|
||||||
async function loadSavedConnections() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/client/connections');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const connectionsList = document.getElementById('connectionsList');
|
|
||||||
|
|
||||||
if (data.success && data.connections.length > 0) {
|
|
||||||
connectionsList.innerHTML = data.connections.map(conn => `
|
|
||||||
<div class="connection-item ${currentConnectionId === conn.id ? 'active' : ''}"
|
|
||||||
onclick="selectConnection('${conn.id}')">
|
|
||||||
<i class="fas fa-database"></i>
|
|
||||||
<span>${conn.name}</span>
|
|
||||||
<span class="remove-conn" onclick="event.stopPropagation(); removeConnection('${conn.id}')">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
connectionsList.innerHTML = '<div class="connection-item">Нет сохраненных подключений</div>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки подключений:', error);
|
|
||||||
document.getElementById('connectionsList').innerHTML =
|
|
||||||
'<div class="connection-item">Ошибка загрузки</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выбор сохраненного подключения
|
|
||||||
function selectConnection(connectionId) {
|
|
||||||
currentConnectionId = connectionId;
|
|
||||||
|
|
||||||
document.querySelectorAll('.connection-item').forEach(el => {
|
|
||||||
el.classList.remove('active');
|
|
||||||
});
|
|
||||||
event.currentTarget.classList.add('active');
|
|
||||||
|
|
||||||
document.getElementById('loadTasksBtn').disabled = false;
|
|
||||||
document.getElementById('refreshTasksBtn').disabled = false;
|
|
||||||
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаление подключения
|
|
||||||
async function removeConnection(connectionId) {
|
|
||||||
if (!confirm('Удалить это подключение?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/client/connections/${connectionId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
if (currentConnectionId === connectionId) {
|
|
||||||
currentConnectionId = null;
|
|
||||||
document.getElementById('loadTasksBtn').disabled = true;
|
|
||||||
document.getElementById('refreshTasksBtn').disabled = true;
|
|
||||||
document.getElementById('tasksContainer').innerHTML =
|
|
||||||
'<div class="loading"><i class="fas fa-plug"></i><p>Подключитесь к серверу</p></div>';
|
|
||||||
document.getElementById('tasksCount').textContent = '0';
|
|
||||||
document.getElementById('serverInfo').style.display = 'none';
|
|
||||||
}
|
|
||||||
loadSavedConnections();
|
|
||||||
showAlert('Подключение удалено', 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка удаления подключения:', error);
|
|
||||||
showAlert('Ошибка при удалении подключения', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключение к серверу
|
|
||||||
async function connect() {
|
|
||||||
const apiUrl = document.getElementById('apiUrl').value.trim();
|
|
||||||
const apiKey = document.getElementById('apiKey').value.trim();
|
|
||||||
|
|
||||||
if (!apiUrl || !apiKey) {
|
|
||||||
showAlert('Заполните URL сервиса и API ключ', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectBtn = document.getElementById('connectBtn');
|
|
||||||
connectBtn.disabled = true;
|
|
||||||
connectBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Подключение...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/client/connect', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ api_url: apiUrl, api_key: apiKey })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showAlert('Подключение успешно установлено', 'success');
|
|
||||||
|
|
||||||
currentConnectionId = data.connection.id;
|
|
||||||
|
|
||||||
const serverInfo = document.getElementById('serverInfo');
|
|
||||||
serverInfo.innerHTML = `
|
|
||||||
<i class="fas fa-server"></i>
|
|
||||||
<strong>Сервер:</strong> ${data.connection.url}<br>
|
|
||||||
<strong>Пользователь:</strong> ${data.server_info.user}<br>
|
|
||||||
<strong>Задач на сервере:</strong> ${data.server_info.tasks_count}
|
|
||||||
`;
|
|
||||||
serverInfo.style.display = 'block';
|
|
||||||
|
|
||||||
loadSavedConnections();
|
|
||||||
|
|
||||||
document.getElementById('loadTasksBtn').disabled = false;
|
|
||||||
document.getElementById('refreshTasksBtn').disabled = false;
|
|
||||||
|
|
||||||
document.getElementById('apiUrl').value = '';
|
|
||||||
document.getElementById('apiKey').value = '';
|
|
||||||
|
|
||||||
loadTasks();
|
|
||||||
} else {
|
|
||||||
showAlert(data.error || 'Ошибка подключения', 'danger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка подключения:', error);
|
|
||||||
showAlert('Ошибка при подключении к серверу', 'danger');
|
|
||||||
} finally {
|
|
||||||
connectBtn.disabled = false;
|
|
||||||
connectBtn.innerHTML = '<i class="fas fa-link"></i> Подключиться';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка задач
|
|
||||||
async function loadTasks() {
|
|
||||||
if (!currentConnectionId) {
|
|
||||||
showAlert('Сначала выберите подключение', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusFilter = document.getElementById('statusFilter').value;
|
|
||||||
const searchFilter = document.getElementById('searchFilter').value;
|
|
||||||
const limit = parseInt(document.getElementById('limitFilter').value);
|
|
||||||
|
|
||||||
pageSize = limit;
|
|
||||||
|
|
||||||
const tasksContainer = document.getElementById('tasksContainer');
|
|
||||||
tasksContainer.innerHTML = '<div class="loading"><i class="fas fa-circle-notch fa-spin"></i><p>Загрузка задач...</p></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
let url = `/api/client/tasks?connection_id=${currentConnectionId}&limit=${limit}&offset=${currentPage * limit}`;
|
|
||||||
if (statusFilter) url += `&status=${statusFilter}`;
|
|
||||||
if (searchFilter) url += `&search=${encodeURIComponent(searchFilter)}`;
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
currentTasks = data.tasks || [];
|
|
||||||
totalTasks = data.meta.total;
|
|
||||||
|
|
||||||
document.getElementById('tasksCount').textContent = totalTasks;
|
|
||||||
|
|
||||||
if (currentTasks.length === 0) {
|
|
||||||
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-folder-open"></i><p>Задачи не найдены</p></div>';
|
|
||||||
} else {
|
|
||||||
renderTasks(currentTasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePagination();
|
|
||||||
} else {
|
|
||||||
tasksContainer.innerHTML = `<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка: ${data.error || 'Неизвестная ошибка'}</p></div>`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки задач:', error);
|
|
||||||
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка загрузки задач</p></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовка задач
|
|
||||||
function renderTasks(tasks) {
|
|
||||||
const container = document.getElementById('tasksContainer');
|
|
||||||
|
|
||||||
container.innerHTML = tasks.map(task => {
|
|
||||||
const statusClass = `status-${task.assignment_status || 'default'}`;
|
|
||||||
const statusText = getStatusText(task.assignment_status);
|
|
||||||
|
|
||||||
const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д';
|
|
||||||
const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="task-card" id="task-${task.id}">
|
|
||||||
<div class="task-header">
|
|
||||||
<div class="task-title">${escapeHtml(task.title || 'Без названия')}</div>
|
|
||||||
<div class="task-status ${statusClass}">${statusText}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-description">
|
|
||||||
${escapeHtml(task.description || 'Нет описания')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-meta">
|
|
||||||
<div class="task-meta-item">
|
|
||||||
<i class="fas fa-calendar-alt"></i>
|
|
||||||
<span>Создана: ${createdDate}</span>
|
|
||||||
</div>
|
|
||||||
<div class="task-meta-item">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<span>Срок: ${dueDate}</span>
|
|
||||||
</div>
|
|
||||||
<div class="task-meta-item">
|
|
||||||
<i class="fas fa-user"></i>
|
|
||||||
<span>Автор: ${escapeHtml(task.creator_name || 'Н/Д')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${renderFiles(task.files, task.id)}
|
|
||||||
|
|
||||||
<div class="task-actions">
|
|
||||||
${task.assignment_status !== 'completed' ? `
|
|
||||||
<button class="task-action-btn action-progress" onclick="updateTaskStatus('${task.id}', 'in_progress')">
|
|
||||||
<i class="fas fa-play"></i> В работу
|
|
||||||
</button>
|
|
||||||
<button class="task-action-btn action-complete" onclick="updateTaskStatus('${task.id}', 'completed')">
|
|
||||||
<i class="fas fa-check"></i> Завершить
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
<button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')">
|
|
||||||
<i class="fas fa-upload"></i> Файлы
|
|
||||||
</button>
|
|
||||||
<button class="task-action-btn action-sync" onclick="openSyncModal('${task.id}', '${escapeHtml(task.title)}')">
|
|
||||||
<i class="fas fa-sync-alt"></i> Синхронизировать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовка файлов задачи
|
|
||||||
function renderFiles(files, taskId) {
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="task-files">
|
|
||||||
<div class="files-title">
|
|
||||||
<i class="fas fa-paperclip"></i>
|
|
||||||
Файлы (${files.length})
|
|
||||||
</div>
|
|
||||||
<ul class="files-list">
|
|
||||||
${files.map(file => `
|
|
||||||
<li class="file-item">
|
|
||||||
<i class="fas fa-file file-icon"></i>
|
|
||||||
<span class="file-name" title="${escapeHtml(file.filename || file.original_name)}">
|
|
||||||
${escapeHtml(file.filename || file.original_name)}
|
|
||||||
</span>
|
|
||||||
<span class="file-size">${formatFileSize(file.file_size)}</span>
|
|
||||||
<a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename || file.original_name)}'); return false;">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновление статуса задачи
|
|
||||||
async function updateTaskStatus(taskId, status) {
|
|
||||||
if (!currentConnectionId) return;
|
|
||||||
|
|
||||||
const comment = status === 'completed' ?
|
|
||||||
prompt('Введите комментарий к завершению (необязательно):') :
|
|
||||||
null;
|
|
||||||
|
|
||||||
const button = document.querySelector(`#task-${taskId} .action-${status === 'in_progress' ? 'progress' : 'complete'}`);
|
|
||||||
if (button) {
|
|
||||||
button.disabled = true;
|
|
||||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Обновление...';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/client/tasks/${taskId}/status?connection_id=${currentConnectionId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ status, comment })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success');
|
|
||||||
loadTasks();
|
|
||||||
} else {
|
|
||||||
showAlert(data.error || 'Ошибка обновления статуса', 'danger');
|
|
||||||
if (button) {
|
|
||||||
button.disabled = false;
|
|
||||||
button.innerHTML = status === 'in_progress' ?
|
|
||||||
'<i class="fas fa-play"></i> В работу' :
|
|
||||||
'<i class="fas fa-check"></i> Завершить';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка обновления статуса:', error);
|
|
||||||
showAlert('Ошибка при обновлении статуса', 'danger');
|
|
||||||
if (button) {
|
|
||||||
button.disabled = false;
|
|
||||||
button.innerHTML = status === 'in_progress' ?
|
|
||||||
'<i class="fas fa-play"></i> В работу' :
|
|
||||||
'<i class="fas fa-check"></i> Завершить';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть модальное окно загрузки файлов
|
|
||||||
function openUploadModal(taskId) {
|
|
||||||
currentTaskId = taskId;
|
|
||||||
document.getElementById('uploadModal').classList.add('active');
|
|
||||||
selectedFiles = [];
|
|
||||||
document.getElementById('selectedFiles').style.display = 'none';
|
|
||||||
document.getElementById('filesList').innerHTML = '';
|
|
||||||
document.getElementById('uploadBtn').disabled = true;
|
|
||||||
document.getElementById('uploadProgress').style.display = 'none';
|
|
||||||
document.getElementById('progressBar').style.width = '0%';
|
|
||||||
document.getElementById('progressPercent').textContent = '0%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть модальное окно загрузки
|
|
||||||
function closeUploadModal() {
|
|
||||||
document.getElementById('uploadModal').classList.remove('active');
|
|
||||||
currentTaskId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка выбора файлов
|
|
||||||
function handleFileSelect() {
|
|
||||||
const files = document.getElementById('fileInput').files;
|
|
||||||
selectedFiles = Array.from(files);
|
|
||||||
|
|
||||||
if (selectedFiles.length > 0) {
|
|
||||||
const filesList = document.getElementById('filesList');
|
|
||||||
filesList.innerHTML = selectedFiles.map((file, index) => `
|
|
||||||
<div class="selected-file">
|
|
||||||
<i class="fas fa-file"></i>
|
|
||||||
<span class="file-name">${escapeHtml(file.name)}</span>
|
|
||||||
<span class="file-size">${formatFileSize(file.size)}</span>
|
|
||||||
<span class="remove-file" onclick="removeFile(${index})">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
document.getElementById('selectedFiles').style.display = 'block';
|
|
||||||
document.getElementById('uploadBtn').disabled = false;
|
|
||||||
} else {
|
|
||||||
document.getElementById('selectedFiles').style.display = 'none';
|
|
||||||
document.getElementById('uploadBtn').disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить файл из списка
|
|
||||||
function removeFile(index) {
|
|
||||||
selectedFiles.splice(index, 1);
|
|
||||||
|
|
||||||
if (selectedFiles.length > 0) {
|
|
||||||
const filesList = document.getElementById('filesList');
|
|
||||||
filesList.innerHTML = selectedFiles.map((file, i) => `
|
|
||||||
<div class="selected-file">
|
|
||||||
<i class="fas fa-file"></i>
|
|
||||||
<span class="file-name">${escapeHtml(file.name)}</span>
|
|
||||||
<span class="file-size">${formatFileSize(file.size)}</span>
|
|
||||||
<span class="remove-file" onclick="removeFile(${i})">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
document.getElementById('selectedFiles').style.display = 'none';
|
|
||||||
document.getElementById('uploadBtn').disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка файлов
|
|
||||||
async function uploadFiles() {
|
|
||||||
if (!currentConnectionId || !currentTaskId || selectedFiles.length === 0) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
selectedFiles.forEach(file => {
|
|
||||||
formData.append('files', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
|
||||||
const progressDiv = document.getElementById('uploadProgress');
|
|
||||||
const progressBar = document.getElementById('progressBar');
|
|
||||||
const progressPercent = document.getElementById('progressPercent');
|
|
||||||
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
progressDiv.style.display = 'block';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const percent = Math.round((e.loaded / e.total) * 100);
|
|
||||||
progressBar.style.width = percent + '%';
|
|
||||||
progressPercent.textContent = percent + '%';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
const data = JSON.parse(xhr.responseText);
|
|
||||||
if (data.success) {
|
|
||||||
showAlert(`Успешно загружено ${selectedFiles.length} файлов`, 'success');
|
|
||||||
closeUploadModal();
|
|
||||||
loadTasks();
|
|
||||||
} else {
|
|
||||||
showAlert(data.error || 'Ошибка загрузки файлов', 'danger');
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showAlert('Ошибка загрузки файлов', 'danger');
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
|
||||||
showAlert('Ошибка сети при загрузке', 'danger');
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.open('POST', `/api/client/tasks/${currentTaskId}/files?connection_id=${currentConnectionId}`, true);
|
|
||||||
xhr.send(formData);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки файлов:', error);
|
|
||||||
showAlert('Ошибка при загрузке файлов', 'danger');
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скачать файл
|
|
||||||
async function downloadFile(taskId, fileId, fileName) {
|
|
||||||
if (!currentConnectionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/client/tasks/${taskId}/files/${fileId}/download?connection_id=${currentConnectionId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Ошибка скачивания');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка скачивания файла:', error);
|
|
||||||
showAlert('Ошибка при скачивании файла', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка списка подключений для синхронизации
|
|
||||||
async function loadTargetConnections() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/client/connections/list');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
const select = document.getElementById('savedTargetConnections');
|
|
||||||
select.innerHTML = data.connections.map(conn =>
|
|
||||||
`<option value="${conn.id}">${conn.name} (${conn.url})</option>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки подключений:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть модальное окно синхронизации
|
|
||||||
function openSyncModal(taskId, taskTitle) {
|
|
||||||
syncTaskId = taskId;
|
|
||||||
syncTaskTitle = taskTitle;
|
|
||||||
|
|
||||||
document.getElementById('syncTaskTitle').textContent = taskTitle;
|
|
||||||
|
|
||||||
loadTargetConnections();
|
|
||||||
|
|
||||||
document.getElementById('syncModal').classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть модальное окно синхронизации
|
|
||||||
function closeSyncModal() {
|
|
||||||
document.getElementById('syncModal').classList.remove('active');
|
|
||||||
syncTaskId = null;
|
|
||||||
|
|
||||||
document.getElementById('targetService').value = '';
|
|
||||||
document.getElementById('newServiceInputs').style.display = 'none';
|
|
||||||
document.getElementById('targetApiUrl').value = '';
|
|
||||||
document.getElementById('targetApiKey').value = '';
|
|
||||||
document.getElementById('syncFiles').checked = true;
|
|
||||||
document.getElementById('syncProgress').style.display = 'none';
|
|
||||||
document.getElementById('syncBtn').disabled = false;
|
|
||||||
document.getElementById('syncStatus').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключение между сохраненным и новым сервисом
|
|
||||||
function toggleTargetServiceInput() {
|
|
||||||
const targetService = document.getElementById('targetService').value;
|
|
||||||
document.getElementById('newServiceInputs').style.display =
|
|
||||||
targetService === 'new' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Синхронизация задачи
|
|
||||||
async function syncTask() {
|
|
||||||
if (!syncTaskId) return;
|
|
||||||
|
|
||||||
const targetService = document.getElementById('targetService').value;
|
|
||||||
const syncFiles = document.getElementById('syncFiles').checked;
|
|
||||||
|
|
||||||
if (!targetService) {
|
|
||||||
showAlert('Выберите целевой сервис', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetData = {};
|
|
||||||
|
|
||||||
if (targetService === 'new') {
|
|
||||||
const targetApiUrl = document.getElementById('targetApiUrl').value.trim();
|
|
||||||
const targetApiKey = document.getElementById('targetApiKey').value.trim();
|
|
||||||
|
|
||||||
if (!targetApiUrl || !targetApiKey) {
|
|
||||||
showAlert('Заполните URL и API ключ целевого сервиса', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetData = {
|
|
||||||
target_api_url: targetApiUrl,
|
|
||||||
target_api_key: targetApiKey
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
targetData = {
|
|
||||||
target_connection_id: targetService
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestData = {
|
|
||||||
...targetData,
|
|
||||||
sync_files: syncFiles
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('syncProgress').style.display = 'block';
|
|
||||||
document.getElementById('syncBtn').disabled = true;
|
|
||||||
document.getElementById('syncStatus').innerHTML = 'Начинаем синхронизацию...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/client/tasks/${syncTaskId}/sync?connection_id=${currentConnectionId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += 10;
|
|
||||||
document.getElementById('syncProgressBar').style.width = progress + '%';
|
|
||||||
document.getElementById('syncPercent').textContent = progress + '%';
|
|
||||||
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
|
|
||||||
let statusText = `✅ Задача синхронизирована!`;
|
|
||||||
|
|
||||||
if (data.data.sync_type === 'created') {
|
|
||||||
statusText += `<br>📋 Создана новая задача в целевой системе. ID: ${data.data.target_task_id}`;
|
|
||||||
} else if (data.data.sync_type === 'updated') {
|
|
||||||
statusText += `<br>🔄 Обновлена существующая задача в целевой системе. ID: ${data.data.target_task_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
statusText += `<br>👥 Исполнители: сохранены как в исходной задаче`;
|
|
||||||
|
|
||||||
if (data.data.assignees && data.data.assignees.length > 0) {
|
|
||||||
statusText += `<br>👤 Количество исполнителей: ${data.data.assignees.length}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.synced_files && data.data.synced_files.length > 0) {
|
|
||||||
const successCount = data.data.synced_files.filter(f => f.success).length;
|
|
||||||
const failCount = data.data.synced_files.filter(f => !f.success).length;
|
|
||||||
statusText += `<br>📁 Файлы: ${successCount} синхронизировано, ${failCount} ошибок`;
|
|
||||||
|
|
||||||
if (failCount > 0) {
|
|
||||||
const errors = data.data.synced_files
|
|
||||||
.filter(f => !f.success)
|
|
||||||
.map(f => f.original_name)
|
|
||||||
.join(', ');
|
|
||||||
statusText += `<br><small style="color: #e74c3c;">Ошибки: ${errors}</small>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.data.warnings && data.data.warnings.length > 0) {
|
|
||||||
statusText += `<br><small style="color: #f39c12;">⚠️ ${data.data.warnings.join('; ')}</small>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('syncStatus').innerHTML = statusText;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
closeSyncModal();
|
|
||||||
showAlert(`Задача синхронизирована с ${data.data.target_service}`, 'success');
|
|
||||||
loadTasks();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
} else {
|
|
||||||
showAlert(data.error || 'Ошибка синхронизации задачи', 'danger');
|
|
||||||
document.getElementById('syncProgress').style.display = 'none';
|
|
||||||
document.getElementById('syncBtn').disabled = false;
|
|
||||||
document.getElementById('syncStatus').innerHTML = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка синхронизации:', error);
|
|
||||||
showAlert('Ошибка при синхронизации задачи', 'danger');
|
|
||||||
document.getElementById('syncProgress').style.display = 'none';
|
|
||||||
document.getElementById('syncBtn').disabled = false;
|
|
||||||
document.getElementById('syncStatus').innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Смена страницы
|
|
||||||
function changePage(delta) {
|
|
||||||
const newPage = currentPage + delta;
|
|
||||||
if (newPage >= 0 && newPage * pageSize < totalTasks) {
|
|
||||||
currentPage = newPage;
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновление пагинации
|
|
||||||
function updatePagination() {
|
|
||||||
const totalPages = Math.ceil(totalTasks / pageSize);
|
|
||||||
|
|
||||||
if (totalPages > 1) {
|
|
||||||
document.getElementById('pagination').style.display = 'flex';
|
|
||||||
document.getElementById('pageInfo').textContent = `Страница ${currentPage + 1} из ${totalPages}`;
|
|
||||||
document.getElementById('prevPage').disabled = currentPage === 0;
|
|
||||||
document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
|
|
||||||
} else {
|
|
||||||
document.getElementById('pagination').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать уведомление
|
|
||||||
function showAlert(message, type) {
|
|
||||||
const alert = document.getElementById('alert');
|
|
||||||
alert.className = `alert alert-${type} show`;
|
|
||||||
alert.innerHTML = message;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
alert.classList.remove('show');
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательные функции
|
|
||||||
function getStatusText(status) {
|
|
||||||
const statusMap = {
|
|
||||||
'assigned': 'Назначена',
|
|
||||||
'in_progress': 'В работе',
|
|
||||||
'completed': 'Выполнена',
|
|
||||||
'overdue': 'Просрочена',
|
|
||||||
'rework': 'На доработке'
|
|
||||||
};
|
|
||||||
return statusMap[status] || status || 'Неизвестно';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 Б';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
|
||||||
if (!unsafe) return '';
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализация
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
checkAuth();
|
|
||||||
loadSavedConnections();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
790
public/client.js
Normal file
790
public/client.js
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
// client.js – клиентская логика для работы с внешним API
|
||||||
|
|
||||||
|
// Глобальные переменные
|
||||||
|
let currentConnectionId = null;
|
||||||
|
let currentTasks = [];
|
||||||
|
let currentPage = 0;
|
||||||
|
let totalTasks = 0;
|
||||||
|
let pageSize = 50;
|
||||||
|
let currentTaskId = null;
|
||||||
|
let selectedFiles = [];
|
||||||
|
let syncTaskId = null;
|
||||||
|
let syncTaskTitle = '';
|
||||||
|
|
||||||
|
// Проверка авторизации
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
document.getElementById('userName').textContent = data.user.name || data.user.login;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки авторизации:', error);
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при выходе:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка сохраненных подключений
|
||||||
|
async function loadSavedConnections() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/client/connections');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const connectionsList = document.getElementById('connectionsList');
|
||||||
|
|
||||||
|
if (data.success && data.connections.length > 0) {
|
||||||
|
connectionsList.innerHTML = data.connections.map(conn => `
|
||||||
|
<div class="connection-item ${currentConnectionId === conn.id ? 'active' : ''}"
|
||||||
|
onclick="selectConnection('${conn.id}')">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>${conn.name}</span>
|
||||||
|
<span class="remove-conn" onclick="event.stopPropagation(); removeConnection('${conn.id}')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
connectionsList.innerHTML = '<div class="connection-item">Нет сохраненных подключений</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки подключений:', error);
|
||||||
|
document.getElementById('connectionsList').innerHTML =
|
||||||
|
'<div class="connection-item">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор сохраненного подключения
|
||||||
|
function selectConnection(connectionId) {
|
||||||
|
currentConnectionId = connectionId;
|
||||||
|
|
||||||
|
document.querySelectorAll('.connection-item').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('loadTasksBtn').disabled = false;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = false;
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление подключения
|
||||||
|
async function removeConnection(connectionId) {
|
||||||
|
if (!confirm('Удалить это подключение?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/connections/${connectionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (currentConnectionId === connectionId) {
|
||||||
|
currentConnectionId = null;
|
||||||
|
document.getElementById('loadTasksBtn').disabled = true;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = true;
|
||||||
|
document.getElementById('tasksContainer').innerHTML =
|
||||||
|
'<div class="loading"><i class="fas fa-plug"></i><p>Подключитесь к серверу</p></div>';
|
||||||
|
document.getElementById('tasksCount').textContent = '0';
|
||||||
|
document.getElementById('serverInfo').style.display = 'none';
|
||||||
|
}
|
||||||
|
loadSavedConnections();
|
||||||
|
showAlert('Подключение удалено', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления подключения:', error);
|
||||||
|
showAlert('Ошибка при удалении подключения', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключение к серверу
|
||||||
|
async function connect() {
|
||||||
|
const apiUrl = document.getElementById('apiUrl').value.trim();
|
||||||
|
const apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
showAlert('Заполните URL сервиса и API ключ', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
connectBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Подключение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/client/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ api_url: apiUrl, api_key: apiKey })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('Подключение успешно установлено', 'success');
|
||||||
|
|
||||||
|
currentConnectionId = data.connection.id;
|
||||||
|
|
||||||
|
const serverInfo = document.getElementById('serverInfo');
|
||||||
|
serverInfo.innerHTML = `
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
<strong>Сервер:</strong> ${data.connection.url}<br>
|
||||||
|
<strong>Пользователь:</strong> ${data.server_info.user}<br>
|
||||||
|
<strong>Задач на сервере:</strong> ${data.server_info.tasks_count}
|
||||||
|
`;
|
||||||
|
serverInfo.style.display = 'block';
|
||||||
|
|
||||||
|
loadSavedConnections();
|
||||||
|
|
||||||
|
document.getElementById('loadTasksBtn').disabled = false;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = false;
|
||||||
|
|
||||||
|
document.getElementById('apiUrl').value = '';
|
||||||
|
document.getElementById('apiKey').value = '';
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка подключения', 'danger');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка подключения:', error);
|
||||||
|
showAlert('Ошибка при подключении к серверу', 'danger');
|
||||||
|
} finally {
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
connectBtn.innerHTML = '<i class="fas fa-link"></i> Подключиться';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка задач
|
||||||
|
async function loadTasks() {
|
||||||
|
if (!currentConnectionId) {
|
||||||
|
showAlert('Сначала выберите подключение', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const searchFilter = document.getElementById('searchFilter').value;
|
||||||
|
const limit = parseInt(document.getElementById('limitFilter').value);
|
||||||
|
|
||||||
|
pageSize = limit;
|
||||||
|
|
||||||
|
const tasksContainer = document.getElementById('tasksContainer');
|
||||||
|
tasksContainer.innerHTML = '<div class="loading"><i class="fas fa-circle-notch fa-spin"></i><p>Загрузка задач...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/client/tasks?connection_id=${currentConnectionId}&limit=${limit}&offset=${currentPage * limit}`;
|
||||||
|
if (statusFilter) url += `&status=${statusFilter}`;
|
||||||
|
if (searchFilter) url += `&search=${encodeURIComponent(searchFilter)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentTasks = data.tasks || [];
|
||||||
|
totalTasks = data.meta.total;
|
||||||
|
|
||||||
|
document.getElementById('tasksCount').textContent = totalTasks;
|
||||||
|
|
||||||
|
if (currentTasks.length === 0) {
|
||||||
|
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-folder-open"></i><p>Задачи не найдены</p></div>';
|
||||||
|
} else {
|
||||||
|
renderTasks(currentTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePagination();
|
||||||
|
} else {
|
||||||
|
tasksContainer.innerHTML = `<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка: ${data.error || 'Неизвестная ошибка'}</p></div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки задач:', error);
|
||||||
|
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка загрузки задач</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовка задач
|
||||||
|
function renderTasks(tasks) {
|
||||||
|
const container = document.getElementById('tasksContainer');
|
||||||
|
|
||||||
|
container.innerHTML = tasks.map(task => {
|
||||||
|
const statusClass = `status-${task.assignment_status || 'default'}`;
|
||||||
|
const statusText = getStatusText(task.assignment_status);
|
||||||
|
|
||||||
|
const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д';
|
||||||
|
const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-card" id="task-${task.id}">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-title">${escapeHtml(task.title || 'Без названия')}</div>
|
||||||
|
<div class="task-status ${statusClass}">${statusText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-description">
|
||||||
|
${escapeHtml(task.description || 'Нет описания')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-meta">
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Создана: ${createdDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>Срок: ${dueDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Автор: ${escapeHtml(task.creator_name || 'Н/Д')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${renderFiles(task.files, task.id)}
|
||||||
|
|
||||||
|
<div class="task-actions">
|
||||||
|
${task.assignment_status !== 'completed' ? `
|
||||||
|
<button class="task-action-btn action-progress" onclick="updateTaskStatus('${task.id}', 'in_progress')">
|
||||||
|
<i class="fas fa-play"></i> В работу
|
||||||
|
</button>
|
||||||
|
<button class="task-action-btn action-complete" onclick="updateTaskStatus('${task.id}', 'completed')">
|
||||||
|
<i class="fas fa-check"></i> Завершить
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')">
|
||||||
|
<i class="fas fa-upload"></i> Файлы
|
||||||
|
</button>
|
||||||
|
<button class="task-action-btn action-sync" onclick="openSyncModal('${task.id}', '${escapeHtml(task.title)}')">
|
||||||
|
<i class="fas fa-sync-alt"></i> Синхронизировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовка файлов задачи
|
||||||
|
function renderFiles(files, taskId) {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-files">
|
||||||
|
<div class="files-title">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
|
Файлы (${files.length})
|
||||||
|
</div>
|
||||||
|
<ul class="files-list">
|
||||||
|
${files.map(file => `
|
||||||
|
<li class="file-item">
|
||||||
|
<i class="fas fa-file file-icon"></i>
|
||||||
|
<span class="file-name" title="${escapeHtml(file.filename || file.original_name)}">
|
||||||
|
${escapeHtml(file.filename || file.original_name)}
|
||||||
|
</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.file_size)}</span>
|
||||||
|
<a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename || file.original_name)}'); return false;">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статуса задачи
|
||||||
|
async function updateTaskStatus(taskId, status) {
|
||||||
|
if (!currentConnectionId) return;
|
||||||
|
|
||||||
|
const comment = status === 'completed' ?
|
||||||
|
prompt('Введите комментарий к завершению (необязательно):') :
|
||||||
|
null;
|
||||||
|
|
||||||
|
const button = document.querySelector(`#task-${taskId} .action-${status === 'in_progress' ? 'progress' : 'complete'}`);
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Обновление...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/tasks/${taskId}/status?connection_id=${currentConnectionId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status, comment })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success');
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка обновления статуса', 'danger');
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = status === 'in_progress' ?
|
||||||
|
'<i class="fas fa-play"></i> В работу' :
|
||||||
|
'<i class="fas fa-check"></i> Завершить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления статуса:', error);
|
||||||
|
showAlert('Ошибка при обновлении статуса', 'danger');
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = status === 'in_progress' ?
|
||||||
|
'<i class="fas fa-play"></i> В работу' :
|
||||||
|
'<i class="fas fa-check"></i> Завершить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть модальное окно загрузки файлов
|
||||||
|
function openUploadModal(taskId) {
|
||||||
|
currentTaskId = taskId;
|
||||||
|
document.getElementById('uploadModal').classList.add('active');
|
||||||
|
selectedFiles = [];
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('filesList').innerHTML = '';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
document.getElementById('uploadProgress').style.display = 'none';
|
||||||
|
document.getElementById('progressBar').style.width = '0%';
|
||||||
|
document.getElementById('progressPercent').textContent = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть модальное окно загрузки
|
||||||
|
function closeUploadModal() {
|
||||||
|
document.getElementById('uploadModal').classList.remove('active');
|
||||||
|
currentTaskId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка выбора файлов
|
||||||
|
function handleFileSelect() {
|
||||||
|
const files = document.getElementById('fileInput').files;
|
||||||
|
selectedFiles = Array.from(files);
|
||||||
|
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
filesList.innerHTML = selectedFiles.map((file, index) => `
|
||||||
|
<div class="selected-file">
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
<span class="file-name">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.size)}</span>
|
||||||
|
<span class="remove-file" onclick="removeFile(${index})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('selectedFiles').style.display = 'block';
|
||||||
|
document.getElementById('uploadBtn').disabled = false;
|
||||||
|
} else {
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить файл из списка
|
||||||
|
function removeFile(index) {
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
filesList.innerHTML = selectedFiles.map((file, i) => `
|
||||||
|
<div class="selected-file">
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
<span class="file-name">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.size)}</span>
|
||||||
|
<span class="remove-file" onclick="removeFile(${i})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файлов
|
||||||
|
async function uploadFiles() {
|
||||||
|
if (!currentConnectionId || !currentTaskId || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
const progressDiv = document.getElementById('uploadProgress');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
|
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
|
progressBar.style.width = percent + '%';
|
||||||
|
progressPercent.textContent = percent + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(`Успешно загружено ${selectedFiles.length} файлов`, 'success');
|
||||||
|
closeUploadModal();
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка загрузки файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert('Ошибка загрузки файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
showAlert('Ошибка сети при загрузке', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/client/tasks/${currentTaskId}/files?connection_id=${currentConnectionId}`, true);
|
||||||
|
xhr.send(formData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки файлов:', error);
|
||||||
|
showAlert('Ошибка при загрузке файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачать файл
|
||||||
|
async function downloadFile(taskId, fileId, fileName) {
|
||||||
|
if (!currentConnectionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/tasks/${taskId}/files/${fileId}/download?connection_id=${currentConnectionId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка скачивания');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка скачивания файла:', error);
|
||||||
|
showAlert('Ошибка при скачивании файла', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка списка подключений для синхронизации
|
||||||
|
async function loadTargetConnections() {
|
||||||
|
console.log('[Sync] Загрузка списка сохраненных подключений для синхронизации...');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/client/connections/list');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('[Sync] Получен ответ от /api/client/connections/list:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const select = document.getElementById('savedTargetConnections');
|
||||||
|
select.innerHTML = data.connections.map(conn =>
|
||||||
|
`<option value="${conn.id}">${conn.name} (${conn.url})</option>`
|
||||||
|
).join('');
|
||||||
|
console.log(`[Sync] Загружено ${data.connections.length} подключений.`);
|
||||||
|
} else {
|
||||||
|
console.warn('[Sync] Не удалось загрузить подключения:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sync] Ошибка загрузки подключений:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть модальное окно синхронизации
|
||||||
|
function openSyncModal(taskId, taskTitle) {
|
||||||
|
console.log(`[Sync] Открытие модального окна синхронизации для задачи ID=${taskId}, название="${taskTitle}"`);
|
||||||
|
syncTaskId = taskId;
|
||||||
|
syncTaskTitle = taskTitle;
|
||||||
|
|
||||||
|
document.getElementById('syncTaskTitle').textContent = taskTitle;
|
||||||
|
|
||||||
|
loadTargetConnections();
|
||||||
|
|
||||||
|
document.getElementById('syncModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть модальное окно синхронизации
|
||||||
|
function closeSyncModal() {
|
||||||
|
console.log('[Sync] Закрытие модального окна синхронизации');
|
||||||
|
document.getElementById('syncModal').classList.remove('active');
|
||||||
|
syncTaskId = null;
|
||||||
|
|
||||||
|
document.getElementById('targetService').value = '';
|
||||||
|
document.getElementById('newServiceInputs').style.display = 'none';
|
||||||
|
document.getElementById('targetApiUrl').value = '';
|
||||||
|
document.getElementById('targetApiKey').value = '';
|
||||||
|
document.getElementById('syncFiles').checked = true;
|
||||||
|
document.getElementById('syncProgress').style.display = 'none';
|
||||||
|
document.getElementById('syncBtn').disabled = false;
|
||||||
|
document.getElementById('syncStatus').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение между сохраненным и новым сервисом
|
||||||
|
function toggleTargetServiceInput() {
|
||||||
|
const targetService = document.getElementById('targetService').value;
|
||||||
|
const isNew = targetService === 'new';
|
||||||
|
document.getElementById('newServiceInputs').style.display = isNew ? 'block' : 'none';
|
||||||
|
console.log(`[Sync] Переключение режима ввода: ${isNew ? 'новый сервис' : 'сохраненное подключение'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронизация задачи
|
||||||
|
async function syncTask() {
|
||||||
|
if (!syncTaskId) {
|
||||||
|
console.warn('[Sync] syncTaskId отсутствует, синхронизация невозможна');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Sync] Начало синхронизации задачи ID=${syncTaskId}, название="${syncTaskTitle}"`);
|
||||||
|
|
||||||
|
const targetService = document.getElementById('targetService').value;
|
||||||
|
const syncFiles = document.getElementById('syncFiles').checked;
|
||||||
|
|
||||||
|
console.log('[Sync] Выбранный целевой сервис (ID или "new"):', targetService);
|
||||||
|
console.log('[Sync] Синхронизировать файлы:', syncFiles);
|
||||||
|
|
||||||
|
if (!targetService) {
|
||||||
|
showAlert('Выберите целевой сервис', 'warning');
|
||||||
|
console.warn('[Sync] Целевой сервис не выбран');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetData = {};
|
||||||
|
|
||||||
|
if (targetService === 'new') {
|
||||||
|
const targetApiUrl = document.getElementById('targetApiUrl').value.trim();
|
||||||
|
const targetApiKey = document.getElementById('targetApiKey').value.trim();
|
||||||
|
|
||||||
|
console.log('[Sync] Ввод нового сервиса: URL=', targetApiUrl);
|
||||||
|
|
||||||
|
if (!targetApiUrl || !targetApiKey) {
|
||||||
|
showAlert('Заполните URL и API ключ целевого сервиса', 'warning');
|
||||||
|
console.warn('[Sync] Не заполнены URL или ключ для нового сервиса');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetData = {
|
||||||
|
target_api_url: targetApiUrl,
|
||||||
|
target_api_key: targetApiKey
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
targetData = {
|
||||||
|
target_connection_id: targetService
|
||||||
|
};
|
||||||
|
console.log('[Sync] Используется сохраненное подключение с ID:', targetService);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
...targetData,
|
||||||
|
sync_files: syncFiles
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Sync] Формирование тела запроса:', requestData);
|
||||||
|
|
||||||
|
document.getElementById('syncProgress').style.display = 'block';
|
||||||
|
document.getElementById('syncBtn').disabled = true;
|
||||||
|
document.getElementById('syncStatus').innerHTML = 'Начинаем синхронизацию...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ИЗМЕНЕНИЕ: убираем connection_id из URL, так как источник всегда локальный
|
||||||
|
const url = `/api/client/tasks/${syncTaskId}/sync`;
|
||||||
|
console.log('[Sync] Отправка POST запроса на', url);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Sync] Статус ответа:', response.status, response.statusText);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[Sync] Получен ответ от сервера:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
console.log('[Sync] Синхронизация успешно запущена, ожидаем прогресс...');
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 10;
|
||||||
|
document.getElementById('syncProgressBar').style.width = progress + '%';
|
||||||
|
document.getElementById('syncPercent').textContent = progress + '%';
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
let statusText = `✅ Задача синхронизирована!`;
|
||||||
|
|
||||||
|
if (data.data.sync_type === 'created') {
|
||||||
|
statusText += `<br>📋 Создана новая задача в целевой системе. ID: ${data.data.target_task_id}`;
|
||||||
|
console.log('[Sync] Задача создана в целевой системе, новый ID:', data.data.target_task_id);
|
||||||
|
} else if (data.data.sync_type === 'updated') {
|
||||||
|
statusText += `<br>🔄 Обновлена существующая задача в целевой системе. ID: ${data.data.target_task_id}`;
|
||||||
|
console.log('[Sync] Задача обновлена в целевой системе, ID:', data.data.target_task_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText += `<br>👥 Исполнители: не копируются`;
|
||||||
|
|
||||||
|
if (data.data.synced_files && data.data.synced_files.length > 0) {
|
||||||
|
const successCount = data.data.synced_files.filter(f => f.success).length;
|
||||||
|
const failCount = data.data.synced_files.filter(f => !f.success).length;
|
||||||
|
statusText += `<br>📁 Файлы: ${successCount} синхронизировано, ${failCount} ошибок`;
|
||||||
|
console.log('[Sync] Синхронизация файлов: успешно=', successCount, 'ошибок=', failCount);
|
||||||
|
|
||||||
|
if (failCount > 0) {
|
||||||
|
const errors = data.data.synced_files
|
||||||
|
.filter(f => !f.success)
|
||||||
|
.map(f => f.original_name)
|
||||||
|
.join(', ');
|
||||||
|
statusText += `<br><small style="color: #e74c3c;">Ошибки: ${errors}</small>`;
|
||||||
|
console.warn('[Sync] Ошибки при синхронизации файлов:', errors);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Sync] Файлы не синхронизировались (нет файлов или sync_files=false)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.data.warnings && data.data.warnings.length > 0) {
|
||||||
|
statusText += `<br><small style="color: #f39c12;">⚠️ ${data.data.warnings.join('; ')}</small>`;
|
||||||
|
console.warn('[Sync] Предупреждения:', data.data.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('syncStatus').innerHTML = statusText;
|
||||||
|
console.log('[Sync] Процесс синхронизации завершен');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSyncModal();
|
||||||
|
showAlert(`Задача синхронизирована с ${data.data.target_service}`, 'success');
|
||||||
|
loadTasks();
|
||||||
|
console.log('[Sync] Модальное окно закрыто, задачи перезагружены');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
console.error('[Sync] Ошибка синхронизации:', data.error);
|
||||||
|
showAlert(data.error || 'Ошибка синхронизации задачи', 'danger');
|
||||||
|
document.getElementById('syncProgress').style.display = 'none';
|
||||||
|
document.getElementById('syncBtn').disabled = false;
|
||||||
|
document.getElementById('syncStatus').innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sync] Ошибка при выполнении запроса синхронизации:', error);
|
||||||
|
showAlert('Ошибка при синхронизации задачи', 'danger');
|
||||||
|
document.getElementById('syncProgress').style.display = 'none';
|
||||||
|
document.getElementById('syncBtn').disabled = false;
|
||||||
|
document.getElementById('syncStatus').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Смена страницы
|
||||||
|
function changePage(delta) {
|
||||||
|
const newPage = currentPage + delta;
|
||||||
|
if (newPage >= 0 && newPage * pageSize < totalTasks) {
|
||||||
|
currentPage = newPage;
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление пагинации
|
||||||
|
function updatePagination() {
|
||||||
|
const totalPages = Math.ceil(totalTasks / pageSize);
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
document.getElementById('pagination').style.display = 'flex';
|
||||||
|
document.getElementById('pageInfo').textContent = `Страница ${currentPage + 1} из ${totalPages}`;
|
||||||
|
document.getElementById('prevPage').disabled = currentPage === 0;
|
||||||
|
document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
|
||||||
|
} else {
|
||||||
|
document.getElementById('pagination').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать уведомление
|
||||||
|
function showAlert(message, type) {
|
||||||
|
const alert = document.getElementById('alert');
|
||||||
|
alert.className = `alert alert-${type} show`;
|
||||||
|
alert.innerHTML = message;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции
|
||||||
|
function getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'assigned': 'Назначена',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'completed': 'Выполнена',
|
||||||
|
'overdue': 'Просрочена',
|
||||||
|
'rework': 'На доработке'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status || 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Б';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
checkAuth();
|
||||||
|
loadSavedConnections();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user