копия задачи, доделать

This commit is contained in:
2026-02-25 23:17:24 +05:00
parent 0e838358f0
commit 908533929b
2 changed files with 532 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ const router = express.Router();
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const axios = require('axios'); const axios = require('axios');
const FormData = require('form-data');
module.exports = function(app, db, upload) { module.exports = function(app, db, upload) {
@@ -34,11 +35,6 @@ module.exports = function(app, db, upload) {
/** /**
* POST /api/client/connect - Проверка подключения к внешнему сервису * POST /api/client/connect - Проверка подключения к внешнему сервису
* Тело запроса:
* {
* "api_url": "https://example.com",
* "api_key": "ваш_ключ"
* }
*/ */
router.post('/api/client/connect', requireAuth, async (req, res) => { router.post('/api/client/connect', requireAuth, async (req, res) => {
const { api_url, api_key } = req.body; const { api_url, api_key } = req.body;
@@ -51,7 +47,7 @@ module.exports = function(app, db, upload) {
} }
try { try {
// Нормализуем URL (убираем слеш в конце если есть) // Нормализуем URL
const baseUrl = api_url.replace(/\/$/, ''); const baseUrl = api_url.replace(/\/$/, '');
// Пробуем подключиться к сервису // Пробуем подключиться к сервису
@@ -60,9 +56,9 @@ module.exports = function(app, db, upload) {
'X-API-Key': api_key 'X-API-Key': api_key
}, },
params: { params: {
limit: 1 // Запрашиваем только одну задачу для проверки limit: 1
}, },
timeout: 10000 // 10 секунд таймаут timeout: 10000
}); });
if (response.data && response.data.success) { 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 - Удалить сохраненное подключение * DELETE /api/client/connections/:id - Удалить сохраненное подключение
*/ */
@@ -180,12 +195,6 @@ module.exports = function(app, db, upload) {
/** /**
* GET /api/client/tasks - Получить список задач из внешнего сервиса * 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) => { router.get('/api/client/tasks', requireAuth, async (req, res) => {
const { connection_id, api_url, api_key, status, search, limit = 50, offset = 0 } = req.query; 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 targetUrl = api_url;
let targetKey = api_key; let targetKey = api_key;
// Если указан connection_id, берем данные из сессии
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) { if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
const connection = req.session.clientConnections[connection_id]; const connection = req.session.clientConnections[connection_id];
targetUrl = connection.url; targetUrl = connection.url;
targetKey = connection.api_key; targetKey = connection.api_key;
// Обновляем время последнего использования
connection.last_used = new Date().toISOString(); connection.last_used = new Date().toISOString();
} }
@@ -213,11 +220,9 @@ module.exports = function(app, db, upload) {
try { try {
const baseUrl = targetUrl.replace(/\/$/, ''); const baseUrl = targetUrl.replace(/\/$/, '');
// Формируем параметры запроса
const params = { limit, offset }; const params = { limit, offset };
if (status) params.status = status; if (status) params.status = status;
// Получаем список задач
const response = await axios.get(`${baseUrl}/api/external/tasks`, { const response = await axios.get(`${baseUrl}/api/external/tasks`, {
headers: { headers: {
'X-API-Key': targetKey 'X-API-Key': targetKey
@@ -229,7 +234,6 @@ module.exports = function(app, db, upload) {
if (response.data && response.data.success) { if (response.data && response.data.success) {
let tasks = response.data.tasks || []; let tasks = response.data.tasks || [];
// Дополнительная фильтрация по поиску на стороне клиента
if (search && tasks.length > 0) { if (search && tasks.length > 0) {
const searchLower = search.toLowerCase(); const searchLower = search.toLowerCase();
tasks = tasks.filter(task => tasks = tasks.filter(task =>
@@ -238,7 +242,6 @@ module.exports = function(app, db, upload) {
); );
} }
// Логируем действие
const { logActivity } = require('./database'); const { logActivity } = require('./database');
if (logActivity) { if (logActivity) {
logActivity(0, userId, 'API_CLIENT_GET_TASKS', 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) => { router.get('/api/client/tasks/:taskId', requireAuth, async (req, res) => {
const { taskId } = req.params; const { taskId } = req.params;
const { connection_id, api_url, api_key } = req.query; const { connection_id, api_url, api_key } = req.query;
const userId = req.session.user.id;
let targetUrl = api_url; let targetUrl = api_url;
let targetKey = api_key; let targetKey = api_key;
@@ -357,11 +359,6 @@ module.exports = function(app, db, upload) {
/** /**
* PUT /api/client/tasks/:taskId/status - Изменить статус задачи * PUT /api/client/tasks/:taskId/status - Изменить статус задачи
* Тело запроса:
* {
* "status": "in_progress" | "completed",
* "comment": "Комментарий к изменению статуса" (опционально)
* }
*/ */
router.put('/api/client/tasks/:taskId/status', requireAuth, async (req, res) => { router.put('/api/client/tasks/:taskId/status', requireAuth, async (req, res) => {
const { taskId } = req.params; const { taskId } = req.params;
@@ -404,7 +401,6 @@ module.exports = function(app, db, upload) {
); );
if (response.data && response.data.success) { if (response.data && response.data.success) {
// Логируем действие
const { logActivity } = require('./database'); const { logActivity } = require('./database');
if (logActivity) { if (logActivity) {
logActivity(0, userId, 'API_CLIENT_UPDATE_STATUS', 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 - Загрузить файлы в задачу * POST /api/client/tasks/:taskId/files - Загрузить файлы в задачу
*/ */
@@ -470,7 +666,6 @@ module.exports = function(app, db, upload) {
} }
if (!targetUrl || !targetKey) { if (!targetUrl || !targetKey) {
// Очищаем временные файлы
req.files.forEach(file => { req.files.forEach(file => {
if (file.path && fs.existsSync(file.path)) { if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path); fs.unlinkSync(file.path);
@@ -482,8 +677,6 @@ module.exports = function(app, db, upload) {
try { try {
const baseUrl = targetUrl.replace(/\/$/, ''); const baseUrl = targetUrl.replace(/\/$/, '');
// Создаем FormData для отправки файлов
const FormData = require('form-data');
const formData = new FormData(); const formData = new FormData();
req.files.forEach(file => { req.files.forEach(file => {
@@ -493,7 +686,6 @@ module.exports = function(app, db, upload) {
}); });
}); });
// Отправляем файлы на внешний сервис
const response = await axios.post( const response = await axios.post(
`${baseUrl}/api/external/tasks/${taskId}/files`, `${baseUrl}/api/external/tasks/${taskId}/files`,
formData, formData,
@@ -502,13 +694,12 @@ module.exports = function(app, db, upload) {
...formData.getHeaders(), ...formData.getHeaders(),
'X-API-Key': targetKey 'X-API-Key': targetKey
}, },
timeout: 60000, // 60 секунд для загрузки файлов timeout: 60000,
maxContentLength: Infinity, maxContentLength: Infinity,
maxBodyLength: Infinity maxBodyLength: Infinity
} }
); );
// Удаляем временные файлы
req.files.forEach(file => { req.files.forEach(file => {
if (file.path && fs.existsSync(file.path)) { if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path); fs.unlinkSync(file.path);
@@ -516,7 +707,6 @@ module.exports = function(app, db, upload) {
}); });
if (response.data && response.data.success) { if (response.data && response.data.success) {
// Логируем действие
const { logActivity } = require('./database'); const { logActivity } = require('./database');
if (logActivity) { if (logActivity) {
logActivity(0, userId, 'API_CLIENT_UPLOAD_FILES', logActivity(0, userId, 'API_CLIENT_UPLOAD_FILES',
@@ -532,7 +722,6 @@ module.exports = function(app, db, upload) {
res.status(400).json({ error: 'Не удалось загрузить файлы' }); res.status(400).json({ error: 'Не удалось загрузить файлы' });
} }
} catch (error) { } catch (error) {
// Удаляем временные файлы в случае ошибки
req.files.forEach(file => { req.files.forEach(file => {
if (file.path && fs.existsSync(file.path)) { if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path); fs.unlinkSync(file.path);
@@ -597,7 +786,6 @@ module.exports = function(app, db, upload) {
} }
try { try {
// Сначала получаем детали задачи, чтобы увидеть файлы
const baseUrl = targetUrl.replace(/\/$/, ''); const baseUrl = targetUrl.replace(/\/$/, '');
const response = await axios.get(`${baseUrl}/api/external/tasks/${taskId}`, { 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) => { router.get('/api/client/tasks/:taskId/files/:fileId/download', requireAuth, async (req, res) => {
const { taskId, fileId } = req.params; const { taskId, fileId } = req.params;
@@ -647,7 +835,6 @@ module.exports = function(app, db, upload) {
try { try {
const baseUrl = targetUrl.replace(/\/$/, ''); const baseUrl = targetUrl.replace(/\/$/, '');
// Делаем запрос на скачивание файла
const response = await axios({ const response = await axios({
method: 'GET', method: 'GET',
url: `${baseUrl}/api/external/tasks/${taskId}/files/${fileId}/download`, url: `${baseUrl}/api/external/tasks/${taskId}/files/${fileId}/download`,
@@ -658,7 +845,6 @@ module.exports = function(app, db, upload) {
timeout: 30000 timeout: 30000
}); });
// Проксируем ответ клиенту
const contentType = response.headers['content-type'] || 'application/octet-stream'; const contentType = response.headers['content-type'] || 'application/octet-stream';
const contentDisposition = response.headers['content-disposition'] || 'attachment'; const contentDisposition = response.headers['content-disposition'] || 'attachment';

View File

@@ -112,7 +112,7 @@
color: #7f8c8d; color: #7f8c8d;
} }
.form-group input, .form-group select { .form-group input, .form-group select, .form-group textarea {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #dce4ec; border: 1px solid #dce4ec;
border-radius: 5px; border-radius: 5px;
@@ -120,7 +120,7 @@
transition: border-color 0.3s; 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; outline: none;
border-color: #3498db; border-color: #3498db;
} }
@@ -273,7 +273,7 @@
.tasks-grid { .tasks-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px; gap: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
@@ -427,6 +427,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 5px; gap: 5px;
min-width: 80px;
} }
.action-progress { .action-progress {
@@ -456,6 +457,15 @@
background: #2980b9; background: #2980b9;
} }
.action-copy {
background: #9b59b6;
color: white;
}
.action-copy:hover {
background: #8e44ad;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -501,6 +511,12 @@
color: #3498db; color: #3498db;
} }
.loading-small {
text-align: center;
padding: 10px;
color: #7f8c8d;
}
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
@@ -534,9 +550,9 @@
background: white; background: white;
border-radius: 10px; border-radius: 10px;
padding: 30px; padding: 30px;
max-width: 500px; max-width: 600px;
width: 90%; width: 90%;
max-height: 80vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
} }
@@ -618,7 +634,7 @@
cursor: pointer; cursor: pointer;
} }
.upload-progress { .upload-progress, .copy-progress {
margin-top: 15px; margin-top: 15px;
padding: 10px; padding: 10px;
background: #ecf0f1; background: #ecf0f1;
@@ -639,15 +655,42 @@
transition: width 0.3s; transition: width 0.3s;
} }
.copy-status {
margin-top: 10px;
padding: 10px;
background: #ecf0f1;
border-radius: 5px;
font-size: 13px;
line-height: 1.8;
}
.alert { .alert {
padding: 15px; position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 5px; border-radius: 5px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
z-index: 2000;
max-width: 400px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
} }
.alert.show { .alert.show {
display: block; display: block;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
.alert-success { .alert-success {
@@ -715,6 +758,12 @@
background: #3498db; background: #3498db;
color: white; color: white;
} }
small {
color: #95a5a6;
font-size: 12px;
margin-top: 3px;
}
</style> </style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head> </head>
@@ -874,10 +923,81 @@
</div> </div>
</div> </div>
<!-- Модальное окно копирования задачи -->
<div class="modal" id="copyModal">
<div class="modal-content">
<div class="modal-header">
<h3>Копирование задачи</h3>
<span class="modal-close" onclick="closeCopyModal()">&times;</span>
</div>
<div style="margin-bottom: 20px;">
<p>Вы копируете задачу: <strong id="copyTaskTitle"></strong></p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 5px;">
Автором новой задачи будете <strong id="currentUserName"></strong>
</p>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Целевой сервис <span style="color: #e74c3c;">*</span></label>
<select id="targetService" onchange="toggleTargetServiceInput()">
<option value="">-- Выберите сервис --</option>
<optgroup label="Сохраненные подключения" id="savedTargetConnections"></optgroup>
<option value="new">Указать новый сервис</option>
</select>
</div>
<div id="newServiceInputs" style="display: none;">
<div class="form-group" style="margin-bottom: 15px;">
<label>URL сервиса</label>
<input type="url" id="targetApiUrl" placeholder="https://example.com">
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>API ключ</label>
<input type="text" id="targetApiKey" placeholder="Введите API ключ">
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Новые исполнители (ID пользователей через запятую)</label>
<input type="text" id="newAssignees" placeholder="123, 456, 789">
<small>Оставьте пустым, чтобы сохранить текущих исполнителей</small>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Новый срок выполнения (необязательно)</label>
<input type="datetime-local" id="newDueDate">
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="copyFiles" checked>
<span>Копировать файлы</span>
</label>
</div>
<div id="copyProgress" style="display: none;">
<div style="margin-bottom: 5px;">Копирование: <span id="copyPercent">0%</span></div>
<div class="progress-bar">
<div class="progress-fill" id="copyProgressBar" style="width: 0%;"></div>
</div>
<div id="copyStatus" class="copy-status"></div>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeCopyModal()">Отмена</button>
<button class="btn btn-success" onclick="copyTask()" id="copyBtn">
<i class="fas fa-copy"></i> Копировать задачу
</button>
</div>
</div>
</div>
<!-- Уведомления --> <!-- Уведомления -->
<div id="alert" class="alert"></div> <div id="alert" class="alert"></div>
<script> <script>
// Глобальные переменные
let currentConnectionId = null; let currentConnectionId = null;
let currentTasks = []; let currentTasks = [];
let currentPage = 0; let currentPage = 0;
@@ -885,6 +1005,8 @@
let pageSize = 50; let pageSize = 50;
let currentTaskId = null; let currentTaskId = null;
let selectedFiles = []; let selectedFiles = [];
let copyTaskId = null;
let copyTaskTitle = '';
// Проверка авторизации // Проверка авторизации
async function checkAuth() { async function checkAuth() {
@@ -894,6 +1016,7 @@
if (data.user) { if (data.user) {
document.getElementById('userName').textContent = data.user.name || data.user.login; document.getElementById('userName').textContent = data.user.name || data.user.login;
document.getElementById('currentUserName').textContent = data.user.name || data.user.login;
} else { } else {
window.location.href = '/'; window.location.href = '/';
} }
@@ -946,17 +1069,14 @@
function selectConnection(connectionId) { function selectConnection(connectionId) {
currentConnectionId = connectionId; currentConnectionId = connectionId;
// Подсвечиваем активное подключение
document.querySelectorAll('.connection-item').forEach(el => { document.querySelectorAll('.connection-item').forEach(el => {
el.classList.remove('active'); el.classList.remove('active');
}); });
event.currentTarget.classList.add('active'); event.currentTarget.classList.add('active');
// Активируем кнопки
document.getElementById('loadTasksBtn').disabled = false; document.getElementById('loadTasksBtn').disabled = false;
document.getElementById('refreshTasksBtn').disabled = false; document.getElementById('refreshTasksBtn').disabled = false;
// Загружаем задачи
loadTasks(); loadTasks();
} }
@@ -1018,10 +1138,8 @@
if (data.success) { if (data.success) {
showAlert('Подключение успешно установлено', 'success'); showAlert('Подключение успешно установлено', 'success');
// Сохраняем ID подключения
currentConnectionId = data.connection.id; currentConnectionId = data.connection.id;
// Показываем информацию о сервере
const serverInfo = document.getElementById('serverInfo'); const serverInfo = document.getElementById('serverInfo');
serverInfo.innerHTML = ` serverInfo.innerHTML = `
<i class="fas fa-server"></i> <i class="fas fa-server"></i>
@@ -1031,18 +1149,14 @@
`; `;
serverInfo.style.display = 'block'; serverInfo.style.display = 'block';
// Обновляем список сохраненных подключений
loadSavedConnections(); loadSavedConnections();
// Активируем кнопки
document.getElementById('loadTasksBtn').disabled = false; document.getElementById('loadTasksBtn').disabled = false;
document.getElementById('refreshTasksBtn').disabled = false; document.getElementById('refreshTasksBtn').disabled = false;
// Очищаем поля ввода
document.getElementById('apiUrl').value = ''; document.getElementById('apiUrl').value = '';
document.getElementById('apiKey').value = ''; document.getElementById('apiKey').value = '';
// Загружаем задачи
loadTasks(); loadTasks();
} else { } else {
showAlert(data.error || 'Ошибка подключения', 'danger'); showAlert(data.error || 'Ошибка подключения', 'danger');
@@ -1110,7 +1224,6 @@
const statusClass = `status-${task.assignment_status || 'default'}`; const statusClass = `status-${task.assignment_status || 'default'}`;
const statusText = getStatusText(task.assignment_status); const statusText = getStatusText(task.assignment_status);
// Форматируем даты
const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д'; const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д';
const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока'; const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока';
@@ -1154,6 +1267,9 @@
<button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')"> <button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')">
<i class="fas fa-upload"></i> Файлы <i class="fas fa-upload"></i> Файлы
</button> </button>
<button class="task-action-btn action-copy" onclick="openCopyModal('${task.id}', '${escapeHtml(task.title)}')">
<i class="fas fa-copy"></i> Копировать
</button>
</div> </div>
</div> </div>
`; `;
@@ -1176,11 +1292,11 @@
${files.map(file => ` ${files.map(file => `
<li class="file-item"> <li class="file-item">
<i class="fas fa-file file-icon"></i> <i class="fas fa-file file-icon"></i>
<span class="file-name" title="${escapeHtml(file.filename)}"> <span class="file-name" title="${escapeHtml(file.filename || file.original_name)}">
${escapeHtml(file.filename)} ${escapeHtml(file.filename || file.original_name)}
</span> </span>
<span class="file-size">${formatFileSize(file.file_size)}</span> <span class="file-size">${formatFileSize(file.file_size)}</span>
<a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename)}'); return false;"> <a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename || file.original_name)}'); return false;">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
</a> </a>
</li> </li>
@@ -1217,7 +1333,6 @@
if (data.success) { if (data.success) {
showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success'); showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success');
// Обновляем список задач
loadTasks(); loadTasks();
} else { } else {
showAlert(data.error || 'Ошибка обновления статуса', 'danger'); showAlert(data.error || 'Ошибка обновления статуса', 'danger');
@@ -1253,7 +1368,7 @@
document.getElementById('progressPercent').textContent = '0%'; document.getElementById('progressPercent').textContent = '0%';
} }
// Закрыть модальное окно // Закрыть модальное окно загрузки
function closeUploadModal() { function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('active'); document.getElementById('uploadModal').classList.remove('active');
currentTaskId = null; currentTaskId = null;
@@ -1339,9 +1454,9 @@
if (xhr.status === 200) { if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText); const data = JSON.parse(xhr.responseText);
if (data.success) { if (data.success) {
showAlert(`Успешно загружено ${data.data?.files_uploaded || selectedFiles.length} файлов`, 'success'); showAlert(`Успешно загружено ${selectedFiles.length} файлов`, 'success');
closeUploadModal(); closeUploadModal();
loadTasks(); // Обновляем список задач loadTasks();
} else { } else {
showAlert(data.error || 'Ошибка загрузки файлов', 'danger'); showAlert(data.error || 'Ошибка загрузки файлов', 'danger');
uploadBtn.disabled = false; uploadBtn.disabled = false;
@@ -1394,6 +1509,173 @@
} }
} }
// Загрузка списка подключений для копирования
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 openCopyModal(taskId, taskTitle) {
copyTaskId = taskId;
copyTaskTitle = taskTitle;
document.getElementById('copyTaskTitle').textContent = taskTitle;
loadTargetConnections();
document.getElementById('copyModal').classList.add('active');
}
// Закрыть модальное окно копирования
function closeCopyModal() {
document.getElementById('copyModal').classList.remove('active');
copyTaskId = null;
document.getElementById('targetService').value = '';
document.getElementById('newServiceInputs').style.display = 'none';
document.getElementById('targetApiUrl').value = '';
document.getElementById('targetApiKey').value = '';
document.getElementById('newAssignees').value = '';
document.getElementById('newDueDate').value = '';
document.getElementById('copyFiles').checked = true;
document.getElementById('copyProgress').style.display = 'none';
document.getElementById('copyBtn').disabled = false;
}
// Переключение между сохраненным и новым сервисом
function toggleTargetServiceInput() {
const targetService = document.getElementById('targetService').value;
document.getElementById('newServiceInputs').style.display =
targetService === 'new' ? 'block' : 'none';
}
// Копирование задачи
async function copyTask() {
if (!copyTaskId) return;
const targetService = document.getElementById('targetService').value;
const newAssignees = document.getElementById('newAssignees').value;
const newDueDate = document.getElementById('newDueDate').value;
const copyFiles = document.getElementById('copyFiles').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,
copy_files: copyFiles
};
if (newAssignees) {
requestData.new_assignees = newAssignees.split(',').map(id => parseInt(id.trim()));
}
if (newDueDate) {
requestData.due_date = new Date(newDueDate).toISOString();
}
document.getElementById('copyProgress').style.display = 'block';
document.getElementById('copyBtn').disabled = true;
try {
const response = await fetch(`/api/client/tasks/${copyTaskId}/copy?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('copyProgressBar').style.width = progress + '%';
document.getElementById('copyPercent').textContent = progress + '%';
if (progress >= 100) {
clearInterval(interval);
let statusText = `✅ Задача скопирована! Новый ID: ${data.data.new_task_id}`;
if (data.data.assignees && data.data.assignees !== 'не изменены') {
if (Array.isArray(data.data.assignees)) {
statusText += `<br>👥 Исполнители: ${data.data.assignees.join(', ')}`;
}
}
if (data.data.copied_files && data.data.copied_files.length > 0) {
const successCount = data.data.copied_files.filter(f => f.success).length;
const failCount = data.data.copied_files.filter(f => !f.success).length;
statusText += `<br>📁 Файлы: ${successCount} скопировано, ${failCount} ошибок`;
if (failCount > 0) {
const errors = data.data.copied_files
.filter(f => !f.success)
.map(f => f.original_name)
.join(', ');
statusText += `<br><small style="color: #e74c3c;">Ошибки: ${errors}</small>`;
}
}
document.getElementById('copyStatus').innerHTML = statusText;
setTimeout(() => {
closeCopyModal();
showAlert(`Задача скопирована в ${data.data.target_service}`, 'success');
}, 3000);
}
}, 200);
} else {
showAlert(data.error || 'Ошибка копирования задачи', 'danger');
document.getElementById('copyProgress').style.display = 'none';
document.getElementById('copyBtn').disabled = false;
}
} catch (error) {
console.error('Ошибка копирования:', error);
showAlert('Ошибка при копировании задачи', 'danger');
document.getElementById('copyProgress').style.display = 'none';
document.getElementById('copyBtn').disabled = false;
}
}
// Смена страницы // Смена страницы
function changePage(delta) { function changePage(delta) {
const newPage = currentPage + delta; const newPage = currentPage + delta;
@@ -1449,6 +1731,7 @@
} }
function escapeHtml(unsafe) { function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe return unsafe
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")