diff --git a/api-client.js b/api-client.js index 09dea02..65ac3ee 100644 --- a/api-client.js +++ b/api-client.js @@ -4,6 +4,7 @@ const router = express.Router(); const path = require('path'); const fs = require('fs'); const axios = require('axios'); +const FormData = require('form-data'); module.exports = function(app, db, upload) { @@ -34,11 +35,6 @@ module.exports = function(app, db, upload) { /** * POST /api/client/connect - Проверка подключения к внешнему сервису - * Тело запроса: - * { - * "api_url": "https://example.com", - * "api_key": "ваш_ключ" - * } */ router.post('/api/client/connect', requireAuth, async (req, res) => { const { api_url, api_key } = req.body; @@ -51,7 +47,7 @@ module.exports = function(app, db, upload) { } try { - // Нормализуем URL (убираем слеш в конце если есть) + // Нормализуем URL const baseUrl = api_url.replace(/\/$/, ''); // Пробуем подключиться к сервису @@ -60,9 +56,9 @@ module.exports = function(app, db, upload) { 'X-API-Key': api_key }, params: { - limit: 1 // Запрашиваем только одну задачу для проверки + limit: 1 }, - timeout: 10000 // 10 секунд таймаут + timeout: 10000 }); if (response.data && response.data.success) { @@ -152,6 +148,25 @@ module.exports = function(app, db, upload) { }); }); + /** + * GET /api/client/connections/list - Получить список всех подключений для выбора + */ + router.get('/api/client/connections/list', requireAuth, (req, res) => { + const connections = req.session.clientConnections || {}; + + const connectionsList = Object.values(connections).map(conn => ({ + id: conn.id, + name: conn.name, + url: conn.url, + last_used: conn.last_used + })); + + res.json({ + success: true, + connections: connectionsList + }); + }); + /** * DELETE /api/client/connections/:id - Удалить сохраненное подключение */ @@ -180,12 +195,6 @@ module.exports = function(app, db, upload) { /** * GET /api/client/tasks - Получить список задач из внешнего сервиса - * Параметры запроса: - * - connection_id: ID сохраненного подключения (из сессии) - * - api_url: URL сервиса (если нет connection_id) - * - api_key: API ключ (если нет connection_id) - * - status: фильтр по статусу (опционально) - * - search: поиск по тексту (опционально) */ router.get('/api/client/tasks', requireAuth, async (req, res) => { const { connection_id, api_url, api_key, status, search, limit = 50, offset = 0 } = req.query; @@ -194,13 +203,11 @@ module.exports = function(app, db, upload) { let targetUrl = api_url; let targetKey = api_key; - // Если указан connection_id, берем данные из сессии if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) { const connection = req.session.clientConnections[connection_id]; targetUrl = connection.url; targetKey = connection.api_key; - // Обновляем время последнего использования connection.last_used = new Date().toISOString(); } @@ -213,11 +220,9 @@ module.exports = function(app, db, upload) { try { const baseUrl = targetUrl.replace(/\/$/, ''); - // Формируем параметры запроса const params = { limit, offset }; if (status) params.status = status; - // Получаем список задач const response = await axios.get(`${baseUrl}/api/external/tasks`, { headers: { 'X-API-Key': targetKey @@ -229,7 +234,6 @@ module.exports = function(app, db, upload) { if (response.data && response.data.success) { let tasks = response.data.tasks || []; - // Дополнительная фильтрация по поиску на стороне клиента if (search && tasks.length > 0) { const searchLower = search.toLowerCase(); tasks = tasks.filter(task => @@ -238,7 +242,6 @@ module.exports = function(app, db, upload) { ); } - // Логируем действие const { logActivity } = require('./database'); if (logActivity) { logActivity(0, userId, 'API_CLIENT_GET_TASKS', @@ -296,7 +299,6 @@ module.exports = function(app, db, upload) { router.get('/api/client/tasks/:taskId', requireAuth, async (req, res) => { const { taskId } = req.params; const { connection_id, api_url, api_key } = req.query; - const userId = req.session.user.id; let targetUrl = api_url; let targetKey = api_key; @@ -357,11 +359,6 @@ module.exports = function(app, db, upload) { /** * PUT /api/client/tasks/:taskId/status - Изменить статус задачи - * Тело запроса: - * { - * "status": "in_progress" | "completed", - * "comment": "Комментарий к изменению статуса" (опционально) - * } */ router.put('/api/client/tasks/:taskId/status', requireAuth, async (req, res) => { const { taskId } = req.params; @@ -404,7 +401,6 @@ module.exports = function(app, db, upload) { ); if (response.data && response.data.success) { - // Логируем действие const { logActivity } = require('./database'); if (logActivity) { logActivity(0, userId, 'API_CLIENT_UPDATE_STATUS', @@ -448,6 +444,206 @@ module.exports = function(app, db, upload) { } }); + /** + * POST /api/client/tasks/:taskId/copy - Скопировать задачу в целевой сервис + */ + router.post('/api/client/tasks/:taskId/copy', requireAuth, async (req, res) => { + const { taskId } = req.params; + const { + target_connection_id, + target_api_url, + target_api_key, + new_assignees, + due_date, + copy_files = true + } = req.body; + const { connection_id } = req.query; + const userId = req.session.user.id; + + if (!connection_id && !req.session.clientConnections) { + return res.status(400).json({ + error: 'Не указан источник (текущее подключение)' + }); + } + + let targetUrl = target_api_url; + let targetKey = target_api_key; + + if (target_connection_id && req.session.clientConnections && req.session.clientConnections[target_connection_id]) { + const connection = req.session.clientConnections[target_connection_id]; + targetUrl = connection.url; + targetKey = connection.api_key; + } + + if (!targetUrl || !targetKey) { + return res.status(400).json({ + error: 'Не указан целевой сервис (URL или API ключ)' + }); + } + + try { + const baseUrl = targetUrl.replace(/\/$/, ''); + const sourceConnection = req.session.clientConnections[connection_id]; + + if (!sourceConnection) { + return res.status(400).json({ error: 'Исходное подключение не найдено' }); + } + + // 1. Получаем исходную задачу + const sourceResponse = await axios.get( + `${sourceConnection.url}/api/external/tasks/${taskId}`, + { + headers: { + 'X-API-Key': sourceConnection.api_key + }, + timeout: 10000 + } + ); + + if (!sourceResponse.data || !sourceResponse.data.success) { + return res.status(404).json({ error: 'Исходная задача не найдена' }); + } + + const sourceTask = sourceResponse.data.task; + + // 2. Создаем копию задачи в целевом сервисе + const newTaskTitle = `Копия: ${sourceTask.title}`; + + const taskData = { + title: newTaskTitle, + description: sourceTask.description || '', + due_date: due_date || sourceTask.due_date, + task_type: sourceTask.task_type || 'regular' + }; + + if (new_assignees && new_assignees.length > 0) { + taskData.assignedUsers = new_assignees; + } + + const createResponse = await axios.post( + `${baseUrl}/api/external/tasks/create`, + taskData, + { + headers: { + 'X-API-Key': targetKey, + 'Content-Type': 'application/json' + }, + timeout: 15000 + } + ); + + if (!createResponse.data || !createResponse.data.success) { + return res.status(500).json({ + error: 'Не удалось создать задачу в целевом сервисе' + }); + } + + const newTaskId = createResponse.data.taskId; + + // 3. Копируем файлы, если нужно + const copiedFiles = []; + if (copy_files && sourceTask.files && sourceTask.files.length > 0) { + for (const file of sourceTask.files) { + try { + const fileResponse = await axios({ + method: 'GET', + url: `${sourceConnection.url}/api/external/tasks/${taskId}/files/${file.id}/download`, + headers: { + 'X-API-Key': sourceConnection.api_key + }, + responseType: 'arraybuffer', + timeout: 30000 + }); + + const formData = new FormData(); + formData.append('files', Buffer.from(fileResponse.data), { + filename: file.filename || file.original_name || 'file', + contentType: file.file_type || 'application/octet-stream' + }); + + const uploadResponse = await axios.post( + `${baseUrl}/api/external/tasks/${newTaskId}/files`, + formData, + { + headers: { + ...formData.getHeaders(), + 'X-API-Key': targetKey + }, + timeout: 60000, + maxContentLength: Infinity, + maxBodyLength: Infinity + } + ); + + copiedFiles.push({ + original_name: file.filename || file.original_name, + success: uploadResponse.data && uploadResponse.data.success + }); + } catch (fileError) { + console.error(`❌ Ошибка копирования файла:`, fileError.message); + copiedFiles.push({ + original_name: file.filename || file.original_name, + success: false, + error: fileError.message + }); + } + } + } + + const { logActivity } = require('./database'); + if (logActivity) { + logActivity(0, userId, 'API_CLIENT_COPY_TASK', + `Скопирована задача ${taskId} из ${sourceConnection.url} в ${baseUrl}. Новый ID: ${newTaskId}`); + } + + res.json({ + success: true, + message: `Задача успешно скопирована${copiedFiles.length > 0 ? `, скопировано файлов: ${copiedFiles.filter(f => f.success).length}` : ''}`, + data: { + original_task_id: taskId, + new_task_id: newTaskId, + new_task_title: newTaskTitle, + target_service: baseUrl, + copied_files: copiedFiles, + assignees: new_assignees || 'не изменены' + } + }); + + } catch (error) { + console.error('❌ Ошибка копирования задачи:', error.message); + + let errorMessage = 'Ошибка копирования задачи'; + let statusCode = 500; + + if (error.response) { + if (error.response.status === 401) { + errorMessage = 'Неверный API ключ для целевого сервиса'; + statusCode = 401; + } else if (error.response.status === 403) { + errorMessage = 'Нет прав для создания задачи в целевом сервисе'; + statusCode = 403; + } else if (error.response.status === 404) { + errorMessage = 'Исходная задача не найдена'; + statusCode = 404; + } else { + errorMessage = error.response.data?.error || `Ошибка сервера: ${error.response.status}`; + statusCode = error.response.status; + } + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Целевой сервис недоступен'; + statusCode = 503; + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Превышено время ожидания ответа'; + statusCode = 504; + } + + res.status(statusCode).json({ + error: errorMessage, + details: error.message + }); + } + }); + /** * POST /api/client/tasks/:taskId/files - Загрузить файлы в задачу */ @@ -470,7 +666,6 @@ module.exports = function(app, db, upload) { } if (!targetUrl || !targetKey) { - // Очищаем временные файлы req.files.forEach(file => { if (file.path && fs.existsSync(file.path)) { fs.unlinkSync(file.path); @@ -482,8 +677,6 @@ module.exports = function(app, db, upload) { try { const baseUrl = targetUrl.replace(/\/$/, ''); - // Создаем FormData для отправки файлов - const FormData = require('form-data'); const formData = new FormData(); req.files.forEach(file => { @@ -493,7 +686,6 @@ module.exports = function(app, db, upload) { }); }); - // Отправляем файлы на внешний сервис const response = await axios.post( `${baseUrl}/api/external/tasks/${taskId}/files`, formData, @@ -502,13 +694,12 @@ module.exports = function(app, db, upload) { ...formData.getHeaders(), 'X-API-Key': targetKey }, - timeout: 60000, // 60 секунд для загрузки файлов + timeout: 60000, maxContentLength: Infinity, maxBodyLength: Infinity } ); - // Удаляем временные файлы req.files.forEach(file => { if (file.path && fs.existsSync(file.path)) { fs.unlinkSync(file.path); @@ -516,7 +707,6 @@ module.exports = function(app, db, upload) { }); if (response.data && response.data.success) { - // Логируем действие const { logActivity } = require('./database'); if (logActivity) { logActivity(0, userId, 'API_CLIENT_UPLOAD_FILES', @@ -532,7 +722,6 @@ module.exports = function(app, db, upload) { res.status(400).json({ error: 'Не удалось загрузить файлы' }); } } catch (error) { - // Удаляем временные файлы в случае ошибки req.files.forEach(file => { if (file.path && fs.existsSync(file.path)) { fs.unlinkSync(file.path); @@ -597,7 +786,6 @@ module.exports = function(app, db, upload) { } try { - // Сначала получаем детали задачи, чтобы увидеть файлы const baseUrl = targetUrl.replace(/\/$/, ''); const response = await axios.get(`${baseUrl}/api/external/tasks/${taskId}`, { @@ -625,7 +813,7 @@ module.exports = function(app, db, upload) { }); /** - * GET /api/client/tasks/:taskId/files/:fileId/download - Скачать файл (через редирект) + * GET /api/client/tasks/:taskId/files/:fileId/download - Скачать файл */ router.get('/api/client/tasks/:taskId/files/:fileId/download', requireAuth, async (req, res) => { const { taskId, fileId } = req.params; @@ -647,7 +835,6 @@ module.exports = function(app, db, upload) { try { const baseUrl = targetUrl.replace(/\/$/, ''); - // Делаем запрос на скачивание файла const response = await axios({ method: 'GET', url: `${baseUrl}/api/external/tasks/${taskId}/files/${fileId}/download`, @@ -658,7 +845,6 @@ module.exports = function(app, db, upload) { timeout: 30000 }); - // Проксируем ответ клиенту const contentType = response.headers['content-type'] || 'application/octet-stream'; const contentDisposition = response.headers['content-disposition'] || 'attachment'; diff --git a/public/client.html b/public/client.html index 62ab776..7b6f4f4 100644 --- a/public/client.html +++ b/public/client.html @@ -112,7 +112,7 @@ color: #7f8c8d; } - .form-group input, .form-group select { + .form-group input, .form-group select, .form-group textarea { padding: 10px 12px; border: 1px solid #dce4ec; border-radius: 5px; @@ -120,7 +120,7 @@ transition: border-color 0.3s; } - .form-group input:focus, .form-group select:focus { + .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #3498db; } @@ -273,7 +273,7 @@ .tasks-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px; margin-bottom: 30px; } @@ -427,6 +427,7 @@ align-items: center; justify-content: center; gap: 5px; + min-width: 80px; } .action-progress { @@ -456,6 +457,15 @@ background: #2980b9; } + .action-copy { + background: #9b59b6; + color: white; + } + + .action-copy:hover { + background: #8e44ad; + } + .pagination { display: flex; justify-content: center; @@ -501,6 +511,12 @@ color: #3498db; } + .loading-small { + text-align: center; + padding: 10px; + color: #7f8c8d; + } + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } @@ -534,9 +550,9 @@ background: white; border-radius: 10px; padding: 30px; - max-width: 500px; + max-width: 600px; width: 90%; - max-height: 80vh; + max-height: 90vh; overflow-y: auto; } @@ -618,7 +634,7 @@ cursor: pointer; } - .upload-progress { + .upload-progress, .copy-progress { margin-top: 15px; padding: 10px; background: #ecf0f1; @@ -639,15 +655,42 @@ transition: width 0.3s; } + .copy-status { + margin-top: 10px; + padding: 10px; + background: #ecf0f1; + border-radius: 5px; + font-size: 13px; + line-height: 1.8; + } + .alert { - padding: 15px; + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; border-radius: 5px; margin-bottom: 20px; display: none; + z-index: 2000; + max-width: 400px; + box-shadow: 0 5px 15px rgba(0,0,0,0.2); } .alert.show { display: block; + animation: slideIn 0.3s ease; + } + + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } } .alert-success { @@ -715,6 +758,12 @@ background: #3498db; color: white; } + + small { + color: #95a5a6; + font-size: 12px; + margin-top: 3px; + } @@ -874,10 +923,81 @@ + +