Комментарии, файлы и права проекта
- Система комментариев к задачам с вложенными ответами - Редактирование и удаление комментариев - Прикрепление файлов к задачам и комментариям (картинки, архивы до 10 МБ) - Система прав проекта: админ проекта может удалять чужие комментарии и файлы - Универсальный класс FileUpload для загрузки файлов - Защита загрузки: только автор комментария может добавлять файлы - Каскадное удаление: задача → комментарии → файлы - Автообновление комментариев в реальном времени
This commit is contained in:
81
backend/api/comment.php
Normal file
81
backend/api/comment.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$data = RestApi::getInput();
|
||||||
|
$action = $data['action'] ?? null;
|
||||||
|
$comment = new Comment();
|
||||||
|
|
||||||
|
// Получаем ID текущего пользователя из сессии
|
||||||
|
$current_user_id = RestApi::getCurrentUserId();
|
||||||
|
|
||||||
|
// Создание комментария
|
||||||
|
if ($action === 'create') {
|
||||||
|
$comment->id_task = $data['id_task'] ?? null;
|
||||||
|
$comment->id_accounts = $current_user_id;
|
||||||
|
$comment->id_answer = $data['id_answer'] ?? null; // Ответ на комментарий
|
||||||
|
$comment->text = $data['text'] ?? '';
|
||||||
|
|
||||||
|
$result = $comment->create();
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление комментария
|
||||||
|
if ($action === 'update') {
|
||||||
|
$comment->id = $data['id'] ?? null;
|
||||||
|
$comment->id_accounts = $current_user_id;
|
||||||
|
$comment->text = $data['text'] ?? '';
|
||||||
|
|
||||||
|
$result = $comment->update();
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление комментария
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
$result = Comment::delete($id, $current_user_id);
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файла к комментарию (только автор)
|
||||||
|
if ($action === 'upload_image') {
|
||||||
|
$comment_id = $data['comment_id'] ?? null;
|
||||||
|
$file_base64 = $data['file_data'] ?? null;
|
||||||
|
$file_name = $data['file_name'] ?? null;
|
||||||
|
|
||||||
|
$result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id);
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файлов комментария (автор или админ проекта)
|
||||||
|
if ($action === 'delete_image') {
|
||||||
|
$comment_id = $data['comment_id'] ?? null;
|
||||||
|
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
||||||
|
|
||||||
|
$result = Comment::deleteFile($comment_id, $file_names, $current_user_id);
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод не указан
|
||||||
|
if (!$action) {
|
||||||
|
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
// Получение комментариев задачи
|
||||||
|
// ?id_task=X (обязательный)
|
||||||
|
$id_task = $_GET['id_task'] ?? null;
|
||||||
|
|
||||||
|
if (!$id_task) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['id_task' => 'Задача не указана']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = new Comment();
|
||||||
|
$comments = $comment->getByTask($id_task);
|
||||||
|
|
||||||
|
RestApi::response(['success' => true, 'data' => $comments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
25
backend/api/server.php
Normal file
25
backend/api/server.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$data = RestApi::getInput();
|
||||||
|
$action = $data['action'] ?? null;
|
||||||
|
|
||||||
|
// Получение настроек сервера (публичный action)
|
||||||
|
if ($action === 'get_settings') {
|
||||||
|
$timezone = date_default_timezone_get();
|
||||||
|
$offset = date('P'); // +03:00 формат
|
||||||
|
|
||||||
|
RestApi::response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'timezone' => $timezone, // Europe/Moscow
|
||||||
|
'timezone_offset' => $offset, // +03:00
|
||||||
|
'server_time' => date('c') // 2026-01-15T18:30:00+03:00
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -13,7 +13,7 @@ if ($method === 'POST') {
|
|||||||
$file_base64 = $data['file_data'] ?? null;
|
$file_base64 = $data['file_data'] ?? null;
|
||||||
$file_name = $data['file_name'] ?? null;
|
$file_name = $data['file_name'] ?? null;
|
||||||
|
|
||||||
$result = TaskImage::upload($task_id, $file_base64, $file_name);
|
$result = Task::uploadFile($task_id, $file_base64, $file_name);
|
||||||
RestApi::response($result);
|
RestApi::response($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ if ($method === 'POST') {
|
|||||||
$task_id = $data['task_id'] ?? null;
|
$task_id = $data['task_id'] ?? null;
|
||||||
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
||||||
|
|
||||||
$result = TaskImage::delete($task_id, $file_names);
|
$result = Task::deleteFile($task_id, $file_names);
|
||||||
RestApi::response($result);
|
RestApi::response($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
246
backend/app/class/enity/class_comment.php
Normal file
246
backend/app/class/enity/class_comment.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Comment extends BaseEntity {
|
||||||
|
|
||||||
|
protected $db_name = 'comments';
|
||||||
|
|
||||||
|
// Свойства комментария
|
||||||
|
public $id;
|
||||||
|
public $id_task;
|
||||||
|
public $id_accounts;
|
||||||
|
public $id_answer; // ID родительского комментария (ответ на)
|
||||||
|
public $text;
|
||||||
|
public $date_create;
|
||||||
|
|
||||||
|
// Создание комментария
|
||||||
|
public function create() {
|
||||||
|
static::$error_message = [];
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!$this->id_task) {
|
||||||
|
$this->addError('id_task', 'Задача не указана');
|
||||||
|
}
|
||||||
|
if (!$this->id_accounts) {
|
||||||
|
$this->addError('id_accounts', 'Пользователь не указан');
|
||||||
|
}
|
||||||
|
if (!$this->text || trim($this->text) === '') {
|
||||||
|
$this->addError('text', 'Текст комментария не может быть пустым');
|
||||||
|
}
|
||||||
|
if ($errors = $this->getErrors()) {
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что задача существует
|
||||||
|
Task::check_task($this->id_task);
|
||||||
|
|
||||||
|
// Если это ответ — проверяем что родительский комментарий существует
|
||||||
|
if ($this->id_answer) {
|
||||||
|
self::checkComment($this->id_answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем в базу
|
||||||
|
Database::insert($this->db_name, [
|
||||||
|
'id_task' => $this->id_task,
|
||||||
|
'id_accounts' => $this->id_accounts,
|
||||||
|
'id_answer' => $this->id_answer ?: null,
|
||||||
|
'text' => $this->text,
|
||||||
|
'date_create' => date('Y-m-d H:i:s'),
|
||||||
|
'file_img' => '[]'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->id = Database::id();
|
||||||
|
|
||||||
|
// Возвращаем созданный комментарий с данными пользователя
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'comment' => $this->getById($this->id)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление комментария
|
||||||
|
public function update() {
|
||||||
|
static::$error_message = [];
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!$this->id) {
|
||||||
|
$this->addError('id', 'ID комментария не указан');
|
||||||
|
}
|
||||||
|
if (!$this->text || trim($this->text) === '') {
|
||||||
|
$this->addError('text', 'Текст комментария не может быть пустым');
|
||||||
|
}
|
||||||
|
if ($errors = $this->getErrors()) {
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что комментарий существует
|
||||||
|
$comment = self::checkComment($this->id);
|
||||||
|
|
||||||
|
// Проверяем что пользователь — автор комментария
|
||||||
|
if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) {
|
||||||
|
$this->addError('access', 'Вы можете редактировать только свои комментарии');
|
||||||
|
return $this->getErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
Database::update($this->db_name, [
|
||||||
|
'text' => $this->text
|
||||||
|
], [
|
||||||
|
'id' => $this->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'comment' => $this->getById($this->id)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление комментария (с дочерними)
|
||||||
|
public static function delete($id, $id_accounts) {
|
||||||
|
// Проверяем что комментарий существует
|
||||||
|
$comment = self::checkComment($id);
|
||||||
|
|
||||||
|
// Получаем задачу для проверки админа проекта
|
||||||
|
$task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]);
|
||||||
|
|
||||||
|
// Проверяем права: автор комментария ИЛИ админ проекта
|
||||||
|
$isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts;
|
||||||
|
$isProjectAdmin = $task && Project::isAdmin($task['id_project'], $id_accounts);
|
||||||
|
|
||||||
|
if (!$isAuthor && !$isProjectAdmin) {
|
||||||
|
RestApi::response([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => ['access' => 'Нет прав на удаление комментария']
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рекурсивно удаляем все дочерние комментарии
|
||||||
|
self::deleteWithChildren($id);
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рекурсивное удаление комментария и всех его дочерних
|
||||||
|
private static function deleteWithChildren($id) {
|
||||||
|
// Находим все дочерние комментарии
|
||||||
|
$children = Database::select('comments', ['id'], ['id_answer' => $id]);
|
||||||
|
|
||||||
|
// Рекурсивно удаляем дочерние
|
||||||
|
if ($children) {
|
||||||
|
foreach ($children as $child) {
|
||||||
|
self::deleteWithChildren($child['id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем папку с файлами комментария
|
||||||
|
FileUpload::deleteFolder('comment', $id);
|
||||||
|
|
||||||
|
// Удаляем сам комментарий
|
||||||
|
Database::delete('comments', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ ===
|
||||||
|
|
||||||
|
// Загрузка файла к комментарию (только автор может загружать)
|
||||||
|
public static function uploadFile($comment_id, $file_base64, $file_name, $user_id) {
|
||||||
|
// Проверка что комментарий существует
|
||||||
|
$comment = self::checkComment($comment_id);
|
||||||
|
|
||||||
|
// Проверка что пользователь — автор комментария
|
||||||
|
if ((int)$comment['id_accounts'] !== (int)$user_id) {
|
||||||
|
RestApi::response([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => ['access' => 'Вы можете загружать файлы только к своим комментариям']
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileUpload::upload('comment', $comment_id, $file_base64, $file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файлов комментария (автор или админ проекта)
|
||||||
|
public static function deleteFile($comment_id, $file_names, $user_id) {
|
||||||
|
// Проверка что комментарий существует
|
||||||
|
$comment = self::checkComment($comment_id);
|
||||||
|
|
||||||
|
// Получаем задачу для проверки админа проекта
|
||||||
|
$task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]);
|
||||||
|
|
||||||
|
// Проверка прав: автор комментария ИЛИ админ проекта
|
||||||
|
$isAuthor = (int)$comment['id_accounts'] === (int)$user_id;
|
||||||
|
$isProjectAdmin = $task && Project::isAdmin($task['id_project'], $user_id);
|
||||||
|
|
||||||
|
if (!$isAuthor && !$isProjectAdmin) {
|
||||||
|
RestApi::response([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => ['access' => 'Нет прав на удаление файлов']
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileUpload::delete('comment', $comment_id, $file_names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение комментария по ID (с данными пользователя)
|
||||||
|
public function getById($id) {
|
||||||
|
$comment = Database::get($this->db_name, [
|
||||||
|
'[>]accounts' => ['id_accounts' => 'id']
|
||||||
|
], [
|
||||||
|
'comments.id',
|
||||||
|
'comments.id_task',
|
||||||
|
'comments.id_accounts',
|
||||||
|
'comments.id_answer',
|
||||||
|
'comments.text',
|
||||||
|
'comments.date_create',
|
||||||
|
'comments.file_img',
|
||||||
|
'accounts.name(author_name)',
|
||||||
|
'accounts.avatar_url(author_avatar)'
|
||||||
|
], [
|
||||||
|
'comments.id' => $id
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Декодируем JSON файлов
|
||||||
|
if ($comment) {
|
||||||
|
$comment['file_img'] = $comment['file_img'] ? json_decode($comment['file_img'], true) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение всех комментариев задачи
|
||||||
|
public function getByTask($id_task) {
|
||||||
|
// Проверяем что задача существует
|
||||||
|
Task::check_task($id_task);
|
||||||
|
|
||||||
|
$comments = Database::select($this->db_name, [
|
||||||
|
'[>]accounts' => ['id_accounts' => 'id']
|
||||||
|
], [
|
||||||
|
'comments.id',
|
||||||
|
'comments.id_task',
|
||||||
|
'comments.id_accounts',
|
||||||
|
'comments.id_answer',
|
||||||
|
'comments.text',
|
||||||
|
'comments.date_create',
|
||||||
|
'comments.file_img',
|
||||||
|
'accounts.name(author_name)',
|
||||||
|
'accounts.avatar_url(author_avatar)'
|
||||||
|
], [
|
||||||
|
'comments.id_task' => $id_task,
|
||||||
|
'ORDER' => ['comments.date_create' => 'ASC']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Декодируем JSON файлов для каждого комментария
|
||||||
|
return array_map(function($comment) {
|
||||||
|
$comment['file_img'] = $comment['file_img'] ? json_decode($comment['file_img'], true) : [];
|
||||||
|
return $comment;
|
||||||
|
}, $comments ?: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка и получение комментария (при ошибке — сразу ответ и exit)
|
||||||
|
public static function checkComment($id) {
|
||||||
|
$comment = Database::get('comments', '*', ['id' => $id]);
|
||||||
|
if (!$id || !$comment) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['comment' => 'Комментарий не найден']], 404);
|
||||||
|
}
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
223
backend/app/class/enity/class_fileUpload.php
Normal file
223
backend/app/class/enity/class_fileUpload.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class FileUpload {
|
||||||
|
|
||||||
|
protected static $base_path = __DIR__ . '/../../../public/';
|
||||||
|
protected static $base_url = '/public/';
|
||||||
|
|
||||||
|
// === Маппинг сущностей (все параметры обязательны) ===
|
||||||
|
protected static $entities = [
|
||||||
|
'task' => [
|
||||||
|
'table' => 'cards_task',
|
||||||
|
'folder' => 'task',
|
||||||
|
'field' => 'file_img',
|
||||||
|
'allowed_ext' => ['png', 'jpg', 'jpeg', 'zip', 'rar'],
|
||||||
|
'archive_ext' => ['zip', 'rar'],
|
||||||
|
'max_size' => 10 * 1024 * 1024 // 10 MB
|
||||||
|
],
|
||||||
|
'comment' => [
|
||||||
|
'table' => 'comments',
|
||||||
|
'folder' => 'comment',
|
||||||
|
'field' => 'file_img',
|
||||||
|
'allowed_ext' => ['png', 'jpg', 'jpeg', 'zip', 'rar'],
|
||||||
|
'archive_ext' => ['zip', 'rar'],
|
||||||
|
'max_size' => 10 * 1024 * 1024 // 10 MB
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Получение конфигурации сущности
|
||||||
|
protected static function getConfig($entity_type) {
|
||||||
|
if (!isset(self::$entities[$entity_type])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::$entities[$entity_type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных сущности из БД
|
||||||
|
protected static function getEntityData($config, $entity_id) {
|
||||||
|
return Database::get($config['table'], '*', ['id' => $entity_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление файлов сущности в БД
|
||||||
|
protected static function updateEntityFiles($config, $entity_id, $files) {
|
||||||
|
Database::update($config['table'], [
|
||||||
|
$config['field'] => json_encode($files, JSON_UNESCAPED_UNICODE)
|
||||||
|
], [
|
||||||
|
'id' => $entity_id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация уникального имени файла
|
||||||
|
protected static function getUniqueName($upload_dir, $file_name) {
|
||||||
|
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
|
||||||
|
$base_name = pathinfo($file_name, PATHINFO_FILENAME);
|
||||||
|
$final_name = $file_name;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while (file_exists($upload_dir . '/' . $final_name)) {
|
||||||
|
$final_name = $base_name . '_' . $counter . '.' . $ext;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $final_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование размера файла для ошибки
|
||||||
|
protected static function formatSize($bytes) {
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return round($bytes / 1048576, 1) . ' МБ';
|
||||||
|
}
|
||||||
|
return round($bytes / 1024, 1) . ' КБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ЗАГРУЗКА ФАЙЛА ===
|
||||||
|
public static function upload($entity_type, $entity_id, $file_base64, $file_name) {
|
||||||
|
// Получаем конфигурацию
|
||||||
|
$config = self::getConfig($entity_type);
|
||||||
|
if (!$config) {
|
||||||
|
return ['success' => false, 'errors' => ['entity' => 'Неизвестный тип сущности']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что сущность существует
|
||||||
|
$entity = self::getEntityData($config, $entity_id);
|
||||||
|
if (!$entity) {
|
||||||
|
return ['success' => false, 'errors' => ['entity' => 'Сущность не найдена']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Декодируем base64
|
||||||
|
$file_data = base64_decode(preg_replace('/^data:[^;]+;base64,/', '', $file_base64));
|
||||||
|
if (!$file_data) {
|
||||||
|
return ['success' => false, 'errors' => ['file' => 'Ошибка декодирования файла']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка расширения
|
||||||
|
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, $config['allowed_ext'])) {
|
||||||
|
$allowed = strtoupper(implode(', ', $config['allowed_ext']));
|
||||||
|
return ['success' => false, 'errors' => ['file' => "Разрешены только: $allowed"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка размера
|
||||||
|
$file_size = strlen($file_data);
|
||||||
|
if ($file_size > $config['max_size']) {
|
||||||
|
$max_formatted = self::formatSize($config['max_size']);
|
||||||
|
return ['success' => false, 'errors' => ['file' => "Файл слишком большой. Максимум $max_formatted"]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь к папке
|
||||||
|
$upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id;
|
||||||
|
if (!is_dir($upload_dir)) {
|
||||||
|
mkdir($upload_dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уникальное имя
|
||||||
|
$final_name = self::getUniqueName($upload_dir, $file_name);
|
||||||
|
|
||||||
|
// Сохранение файла
|
||||||
|
$file_path = $upload_dir . '/' . $final_name;
|
||||||
|
if (!file_put_contents($file_path, $file_data)) {
|
||||||
|
return ['success' => false, 'errors' => ['file' => 'Ошибка сохранения файла']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем URL
|
||||||
|
$file_url = self::$base_url . $config['folder'] . '/' . $entity_id . '/' . $final_name;
|
||||||
|
|
||||||
|
// Обновляем file_img в базе
|
||||||
|
$current_files = $entity[$config['field']] ? json_decode($entity[$config['field']], true) : [];
|
||||||
|
$current_files[] = [
|
||||||
|
'url' => $file_url,
|
||||||
|
'name' => $final_name,
|
||||||
|
'size' => $file_size
|
||||||
|
];
|
||||||
|
|
||||||
|
self::updateEntityFiles($config, $entity_id, $current_files);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'file' => [
|
||||||
|
'url' => $file_url,
|
||||||
|
'name' => $final_name,
|
||||||
|
'size' => $file_size
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === УДАЛЕНИЕ ФАЙЛОВ ===
|
||||||
|
public static function delete($entity_type, $entity_id, $file_names) {
|
||||||
|
// Получаем конфигурацию
|
||||||
|
$config = self::getConfig($entity_type);
|
||||||
|
if (!$config) {
|
||||||
|
return ['success' => false, 'errors' => ['entity' => 'Неизвестный тип сущности']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что сущность существует
|
||||||
|
$entity = self::getEntityData($config, $entity_id);
|
||||||
|
if (!$entity) {
|
||||||
|
return ['success' => false, 'errors' => ['entity' => 'Сущность не найдена']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приводим к массиву если передана строка
|
||||||
|
if (!is_array($file_names)) {
|
||||||
|
$file_names = [$file_names];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем текущие файлы
|
||||||
|
$current_files = $entity[$config['field']] ? json_decode($entity[$config['field']], true) : [];
|
||||||
|
$upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id;
|
||||||
|
$deleted = [];
|
||||||
|
|
||||||
|
// Удаляем каждый файл
|
||||||
|
foreach ($file_names as $file_name) {
|
||||||
|
foreach ($current_files as $index => $file) {
|
||||||
|
if ($file['name'] === $file_name) {
|
||||||
|
// Удаляем файл с диска
|
||||||
|
$file_path = $upload_dir . '/' . $file_name;
|
||||||
|
if (file_exists($file_path)) {
|
||||||
|
unlink($file_path);
|
||||||
|
}
|
||||||
|
// Удаляем из массива
|
||||||
|
array_splice($current_files, $index, 1);
|
||||||
|
$deleted[] = $file_name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем в базе
|
||||||
|
self::updateEntityFiles($config, $entity_id, $current_files);
|
||||||
|
|
||||||
|
// Удаляем папку если она пустая
|
||||||
|
if (is_dir($upload_dir) && count(scandir($upload_dir)) === 2) {
|
||||||
|
rmdir($upload_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'deleted' => $deleted];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === УДАЛЕНИЕ ПАПКИ СУЩНОСТИ (при удалении самой сущности) ===
|
||||||
|
public static function deleteFolder($entity_type, $entity_id) {
|
||||||
|
// Получаем конфигурацию
|
||||||
|
$config = self::getConfig($entity_type);
|
||||||
|
if (!$config) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id;
|
||||||
|
|
||||||
|
if (is_dir($upload_dir)) {
|
||||||
|
// Удаляем все файлы в папке
|
||||||
|
$files = glob($upload_dir . '/*');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (is_file($file)) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Удаляем папку
|
||||||
|
rmdir($upload_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -6,24 +6,56 @@ class Project extends BaseEntity {
|
|||||||
|
|
||||||
// Получение всех проектов
|
// Получение всех проектов
|
||||||
public function getAll() {
|
public function getAll() {
|
||||||
return Database::select($this->db_name, [
|
$current_user_id = RestApi::getCurrentUserId();
|
||||||
|
|
||||||
|
$projects = Database::select($this->db_name, [
|
||||||
'id',
|
'id',
|
||||||
'id_order',
|
'id_order',
|
||||||
'name',
|
'name',
|
||||||
'id_ready'
|
'id_ready',
|
||||||
|
'id_admin'
|
||||||
], [
|
], [
|
||||||
'ORDER' => ['id_order' => 'ASC']
|
'ORDER' => ['id_order' => 'ASC']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Обрабатываем id_admin для каждого проекта
|
||||||
|
return array_map(function($project) use ($current_user_id) {
|
||||||
|
$admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : [];
|
||||||
|
|
||||||
|
if ($current_user_id && in_array((int)$current_user_id, $admins, true)) {
|
||||||
|
$project['id_admin'] = true;
|
||||||
|
} else {
|
||||||
|
unset($project['id_admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $project;
|
||||||
|
}, $projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение одного проекта
|
// Получение одного проекта
|
||||||
public static function get($id) {
|
// $current_user_id — ID текущего пользователя для проверки админства
|
||||||
return Database::get('project', [
|
public static function get($id, $current_user_id = null) {
|
||||||
|
$project = Database::get('project', [
|
||||||
'id',
|
'id',
|
||||||
'id_order',
|
'id_order',
|
||||||
'name',
|
'name',
|
||||||
'id_ready'
|
'id_ready',
|
||||||
|
'id_admin'
|
||||||
], ['id' => $id]);
|
], ['id' => $id]);
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
$admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : [];
|
||||||
|
|
||||||
|
// Если передан user_id — проверяем админство
|
||||||
|
if ($current_user_id && in_array((int)$current_user_id, $admins, true)) {
|
||||||
|
$project['id_admin'] = true;
|
||||||
|
} else {
|
||||||
|
// Не админ — убираем поле
|
||||||
|
unset($project['id_admin']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение id_ready (ID колонки "Готово") по ID проекта
|
// Получение id_ready (ID колонки "Готово") по ID проекта
|
||||||
@@ -58,7 +90,10 @@ class Project extends BaseEntity {
|
|||||||
|
|
||||||
// Получение всех данных проекта (проект + колонки + отделы + метки)
|
// Получение всех данных проекта (проект + колонки + отделы + метки)
|
||||||
public static function getProjectData($project_id) {
|
public static function getProjectData($project_id) {
|
||||||
$project = self::get($project_id);
|
// Получаем ID текущего пользователя для проверки админства
|
||||||
|
$current_user_id = RestApi::getCurrentUserId();
|
||||||
|
|
||||||
|
$project = self::get($project_id, $current_user_id);
|
||||||
if (!$project) {
|
if (!$project) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -78,6 +113,17 @@ class Project extends BaseEntity {
|
|||||||
'labels' => $labels
|
'labels' => $labels
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка является ли пользователь админом проекта
|
||||||
|
public static function isAdmin($project_id, $user_id): bool {
|
||||||
|
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||||
|
if (!$project || !$project['id_admin']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = json_decode($project['id_admin'], true) ?: [];
|
||||||
|
return in_array((int)$user_id, $admins, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class Task extends BaseEntity {
|
|||||||
$uploaded_files = [];
|
$uploaded_files = [];
|
||||||
if (!empty($files)) {
|
if (!empty($files)) {
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$result = TaskImage::upload($this->id, $file['data'], $file['name']);
|
$result = FileUpload::upload('task', $this->id, $file['data'], $file['name']);
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$uploaded_files[] = $result['file'];
|
$uploaded_files[] = $result['file'];
|
||||||
}
|
}
|
||||||
@@ -154,24 +154,40 @@ class Task extends BaseEntity {
|
|||||||
// Проверка что задача существует
|
// Проверка что задача существует
|
||||||
self::check_task($id);
|
self::check_task($id);
|
||||||
|
|
||||||
// Удаляем папку с файлами если есть
|
// Удаляем папку с файлами задачи
|
||||||
$upload_dir = __DIR__ . '/../../../public/task/' . $id;
|
FileUpload::deleteFolder('task', $id);
|
||||||
if (is_dir($upload_dir)) {
|
|
||||||
$files = glob($upload_dir . '/*');
|
// Удаляем все комментарии задачи и их файлы
|
||||||
foreach ($files as $file) {
|
$comments = Database::select('comments', ['id'], ['id_task' => $id]);
|
||||||
if (is_file($file)) {
|
if ($comments) {
|
||||||
unlink($file);
|
foreach ($comments as $comment) {
|
||||||
}
|
FileUpload::deleteFolder('comment', $comment['id']);
|
||||||
}
|
}
|
||||||
rmdir($upload_dir);
|
Database::delete('comments', ['id_task' => $id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаляем из базы
|
// Удаляем задачу из базы
|
||||||
Database::delete('cards_task', ['id' => $id]);
|
Database::delete('cards_task', ['id' => $id]);
|
||||||
|
|
||||||
return ['success' => true];
|
return ['success' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ ===
|
||||||
|
|
||||||
|
// Загрузка файла к задаче
|
||||||
|
public static function uploadFile($task_id, $file_base64, $file_name) {
|
||||||
|
// Проверка что задача существует
|
||||||
|
self::check_task($task_id);
|
||||||
|
return FileUpload::upload('task', $task_id, $file_base64, $file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файлов задачи
|
||||||
|
public static function deleteFile($task_id, $file_names) {
|
||||||
|
// Проверка что задача существует
|
||||||
|
self::check_task($task_id);
|
||||||
|
return FileUpload::delete('task', $task_id, $file_names);
|
||||||
|
}
|
||||||
|
|
||||||
// Изменение порядка и колонки задачи (с пересчётом order)
|
// Изменение порядка и колонки задачи (с пересчётом order)
|
||||||
public static function updateOrder($id, $column_id, $to_index) {
|
public static function updateOrder($id, $column_id, $to_index) {
|
||||||
|
|
||||||
@@ -251,8 +267,20 @@ class Task extends BaseEntity {
|
|||||||
'archive'
|
'archive'
|
||||||
], $where);
|
], $where);
|
||||||
|
|
||||||
|
// Получаем количество комментариев для всех задач одним запросом
|
||||||
|
$task_ids = array_column($tasks, 'id');
|
||||||
|
$comments_counts = [];
|
||||||
|
if (!empty($task_ids)) {
|
||||||
|
$counts = Database::query(
|
||||||
|
"SELECT id_task, COUNT(*) as cnt FROM comments WHERE id_task IN (" . implode(',', $task_ids) . ") GROUP BY id_task"
|
||||||
|
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
foreach ($counts as $row) {
|
||||||
|
$comments_counts[$row['id_task']] = (int)$row['cnt'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Декодируем JSON и получаем avatar_url из accounts
|
// Декодируем JSON и получаем avatar_url из accounts
|
||||||
return array_map(function($task) {
|
return array_map(function($task) use ($comments_counts) {
|
||||||
$task['file_img'] = $task['file_img'] ? json_decode($task['file_img'], true) : [];
|
$task['file_img'] = $task['file_img'] ? json_decode($task['file_img'], true) : [];
|
||||||
|
|
||||||
// Получаем avatar_url из accounts по id_account
|
// Получаем avatar_url из accounts по id_account
|
||||||
@@ -263,6 +291,9 @@ class Task extends BaseEntity {
|
|||||||
$task['avatar_img'] = null;
|
$task['avatar_img'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Количество комментариев
|
||||||
|
$task['comments_count'] = $comments_counts[$task['id']] ?? 0;
|
||||||
|
|
||||||
return $task;
|
return $task;
|
||||||
}, $tasks);
|
}, $tasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class TaskImage {
|
|
||||||
|
|
||||||
protected static $db_name = 'cards_task';
|
|
||||||
protected static $upload_path = '/public/task/';
|
|
||||||
|
|
||||||
// Валидация всех данных для загрузки
|
|
||||||
protected static function validate($task_id, $file_base64, $file_name) {
|
|
||||||
// Проверка и получение задачи
|
|
||||||
$task = Task::check_task($task_id);
|
|
||||||
|
|
||||||
// Декодируем base64 (убираем любой data: префикс)
|
|
||||||
$file_data = base64_decode(preg_replace('/^data:[^;]+;base64,/', '', $file_base64));
|
|
||||||
if (!$file_data) {
|
|
||||||
return ['success' => false, 'errors' => ['file' => 'Ошибка декодирования файла']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка расширения
|
|
||||||
$allowed_ext = ['png', 'jpg', 'jpeg', 'zip', 'rar'];
|
|
||||||
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
|
|
||||||
if (!in_array($ext, $allowed_ext)) {
|
|
||||||
return ['success' => false, 'errors' => ['file' => 'Разрешены только PNG, JPG, JPEG, ZIP, RAR']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка размера (10 МБ)
|
|
||||||
$max_size = 10 * 1024 * 1024;
|
|
||||||
if (strlen($file_data) > $max_size) {
|
|
||||||
return ['success' => false, 'errors' => ['file' => 'Файл слишком большой. Максимум 10 МБ']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Всё ок — возвращаем данные
|
|
||||||
return [
|
|
||||||
'task' => $task,
|
|
||||||
'file_data' => $file_data,
|
|
||||||
'is_archive' => in_array($ext, ['zip', 'rar'])
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерация уникального имени файла
|
|
||||||
protected static function getUniqueName($upload_dir, $file_name) {
|
|
||||||
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
|
|
||||||
$base_name = pathinfo($file_name, PATHINFO_FILENAME);
|
|
||||||
$final_name = $file_name;
|
|
||||||
$counter = 1;
|
|
||||||
|
|
||||||
while (file_exists($upload_dir . '/' . $final_name)) {
|
|
||||||
$final_name = $base_name . '_' . $counter . '.' . $ext;
|
|
||||||
$counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $final_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка изображения
|
|
||||||
public static function upload($task_id, $file_base64, $file_name) {
|
|
||||||
// Валидация
|
|
||||||
$validation = self::validate($task_id, $file_base64, $file_name);
|
|
||||||
if (isset($validation['success'])) return $validation;
|
|
||||||
|
|
||||||
$task = $validation['task'];
|
|
||||||
$file_data = $validation['file_data'];
|
|
||||||
|
|
||||||
// Путь к папке
|
|
||||||
$upload_dir = __DIR__ . '/../../../public/task/' . $task_id;
|
|
||||||
if (!is_dir($upload_dir)) {
|
|
||||||
mkdir($upload_dir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уникальное имя
|
|
||||||
$final_name = self::getUniqueName($upload_dir, $file_name);
|
|
||||||
|
|
||||||
// Сохранение файла
|
|
||||||
$file_path = $upload_dir . '/' . $final_name;
|
|
||||||
if (!file_put_contents($file_path, $file_data)) {
|
|
||||||
return ['success' => false, 'errors' => ['file' => 'Ошибка сохранения файла']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Формируем URL
|
|
||||||
$file_url = self::$upload_path . $task_id . '/' . $final_name;
|
|
||||||
$file_size = strlen($file_data);
|
|
||||||
|
|
||||||
// Обновляем file_img в базе
|
|
||||||
$current_files = $task['file_img'] ? json_decode($task['file_img'], true) : [];
|
|
||||||
$current_files[] = [
|
|
||||||
'url' => $file_url,
|
|
||||||
'name' => $final_name,
|
|
||||||
'size' => $file_size
|
|
||||||
];
|
|
||||||
|
|
||||||
Database::update(self::$db_name, [
|
|
||||||
'file_img' => json_encode($current_files, JSON_UNESCAPED_UNICODE)
|
|
||||||
], [
|
|
||||||
'id' => $task_id
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'file' => [
|
|
||||||
'url' => $file_url,
|
|
||||||
'name' => $final_name,
|
|
||||||
'size' => $file_size
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаление изображений (принимает строку или массив имён файлов)
|
|
||||||
public static function delete($task_id, $file_names) {
|
|
||||||
// Проверка и получение задачи
|
|
||||||
$task = Task::check_task($task_id);
|
|
||||||
|
|
||||||
// Приводим к массиву если передана строка
|
|
||||||
if (!is_array($file_names)) {
|
|
||||||
$file_names = [$file_names];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем текущие файлы
|
|
||||||
$current_files = $task['file_img'] ? json_decode($task['file_img'], true) : [];
|
|
||||||
$upload_dir = __DIR__ . '/../../../public/task/' . $task_id;
|
|
||||||
$deleted = [];
|
|
||||||
|
|
||||||
// Удаляем каждый файл
|
|
||||||
foreach ($file_names as $file_name) {
|
|
||||||
// Ищем файл в массиве
|
|
||||||
foreach ($current_files as $index => $file) {
|
|
||||||
if ($file['name'] === $file_name) {
|
|
||||||
// Удаляем файл с диска
|
|
||||||
$file_path = $upload_dir . '/' . $file_name;
|
|
||||||
if (file_exists($file_path)) {
|
|
||||||
unlink($file_path);
|
|
||||||
}
|
|
||||||
// Удаляем из массива
|
|
||||||
array_splice($current_files, $index, 1);
|
|
||||||
$deleted[] = $file_name;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем в базе
|
|
||||||
Database::update(self::$db_name, [
|
|
||||||
'file_img' => json_encode($current_files, JSON_UNESCAPED_UNICODE)
|
|
||||||
], [
|
|
||||||
'id' => $task_id
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Удаляем папку если она пустая
|
|
||||||
if (is_dir($upload_dir) && count(scandir($upload_dir)) === 2) {
|
|
||||||
rmdir($upload_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['success' => true, 'deleted' => $deleted];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
// Часовой пояс сервера
|
||||||
|
date_default_timezone_set('Europe/Moscow');
|
||||||
|
|
||||||
// Подключение классов базы данных
|
// Подключение классов базы данных
|
||||||
require_once __DIR__ . '/class/database/class_Medoo.php';
|
require_once __DIR__ . '/class/database/class_Medoo.php';
|
||||||
require_once __DIR__ . '/class/database/class_Database.php';
|
require_once __DIR__ . '/class/database/class_Database.php';
|
||||||
@@ -11,10 +14,11 @@
|
|||||||
// подключение классов REST API и Entity
|
// подключение классов REST API и Entity
|
||||||
require_once __DIR__ . '/restAPI/class_restApi.php';
|
require_once __DIR__ . '/restAPI/class_restApi.php';
|
||||||
require_once __DIR__ . '/class/enity/class_base.php';
|
require_once __DIR__ . '/class/enity/class_base.php';
|
||||||
|
require_once __DIR__ . '/class/enity/class_fileUpload.php';
|
||||||
require_once __DIR__ . '/class/enity/class_user.php';
|
require_once __DIR__ . '/class/enity/class_user.php';
|
||||||
require_once __DIR__ . '/class/enity/class_project.php';
|
require_once __DIR__ . '/class/enity/class_project.php';
|
||||||
require_once __DIR__ . '/class/enity/class_task.php';
|
require_once __DIR__ . '/class/enity/class_task.php';
|
||||||
require_once __DIR__ . '/class/enity/class_taskImage.php';
|
require_once __DIR__ . '/class/enity/class_comment.php';
|
||||||
|
|
||||||
// Данные подключения к БД
|
// Данные подключения к БД
|
||||||
define('DB_HOST', '192.168.1.9');
|
define('DB_HOST', '192.168.1.9');
|
||||||
@@ -31,8 +35,10 @@
|
|||||||
'/api/user' => __DIR__ . '/../api/user.php',
|
'/api/user' => __DIR__ . '/../api/user.php',
|
||||||
'/api/task' => __DIR__ . '/../api/task.php',
|
'/api/task' => __DIR__ . '/../api/task.php',
|
||||||
'/api/project' => __DIR__ . '/../api/project.php',
|
'/api/project' => __DIR__ . '/../api/project.php',
|
||||||
|
'/api/comment' => __DIR__ . '/../api/comment.php',
|
||||||
|
'/api/server' => __DIR__ . '/../api/server.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
$publicActions = ['auth_login', 'check_session'];
|
$publicActions = ['auth_login', 'check_session', 'get_settings'];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
@@ -18,6 +18,21 @@ class RestApi {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получить ID текущего авторизованного пользователя
|
||||||
|
public static function getCurrentUserId(): ?int {
|
||||||
|
$session = $_COOKIE['session'] ?? null;
|
||||||
|
if (!$session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionData = Database::get('accounts_session', ['id_accounts'], [
|
||||||
|
'keycookies' => $session,
|
||||||
|
'data_closed[>]' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $sessionData ? (int)$sessionData['id_accounts'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -113,7 +113,94 @@ export const taskImageApi = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== COMMENT IMAGES ====================
|
||||||
|
export const commentImageApi = {
|
||||||
|
upload: (comment_id, file_data, file_name) => request('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'upload_image', comment_id, file_data, file_name })
|
||||||
|
}),
|
||||||
|
// Принимает строку (один файл) или массив (несколько файлов)
|
||||||
|
delete: (comment_id, file_names) => request('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete_image', comment_id, file_names })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== USERS ====================
|
// ==================== USERS ====================
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
getAll: () => request('/api/user', { credentials: 'include' })
|
getAll: () => request('/api/user', { credentials: 'include' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SERVER ====================
|
||||||
|
export const serverApi = {
|
||||||
|
// Получение настроек сервера (timezone и т.д.) — публичный action
|
||||||
|
getSettings: () => request('/api/server', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'get_settings' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хранилище серверных настроек
|
||||||
|
export const serverSettings = {
|
||||||
|
timezoneOffset: '+03:00', // дефолт, обновится при загрузке
|
||||||
|
|
||||||
|
// Инициализация — вызвать один раз при старте приложения
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const result = await serverApi.getSettings()
|
||||||
|
if (result.success) {
|
||||||
|
this.timezoneOffset = result.data.timezone_offset
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Не удалось получить настройки сервера:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Парсинг даты с сервера с учётом таймзоны
|
||||||
|
parseDate(dateStr) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
// Добавляем таймзону сервера для корректного парсинга
|
||||||
|
const normalized = dateStr.replace(' ', 'T')
|
||||||
|
// Если уже есть таймзона — не добавляем
|
||||||
|
if (normalized.includes('+') || normalized.includes('Z')) {
|
||||||
|
return new Date(normalized)
|
||||||
|
}
|
||||||
|
return new Date(normalized + this.timezoneOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== COMMENTS ====================
|
||||||
|
export const commentsApi = {
|
||||||
|
// Получение комментариев задачи
|
||||||
|
getByTask: (id_task) => request(`/api/comment?id_task=${id_task}`, { credentials: 'include' }),
|
||||||
|
|
||||||
|
// Создание комментария (id_answer — опционально, для ответа на комментарий)
|
||||||
|
create: (id_task, text, id_answer = null) => request('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'create', id_task, text, id_answer })
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Обновление комментария
|
||||||
|
update: (id, text) => request('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'update', id, text })
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Удаление комментария
|
||||||
|
delete: (id) => request('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete', id })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ const columnsWithCards = computed(() => {
|
|||||||
dueDate: card.date,
|
dueDate: card.date,
|
||||||
dateCreate: card.date_create,
|
dateCreate: card.date_create,
|
||||||
dateClosed: card.date_closed,
|
dateClosed: card.date_closed,
|
||||||
|
comments_count: card.comments_count || 0,
|
||||||
files: card.files || (card.file_img || []).map(f => ({
|
files: card.files || (card.file_img || []).map(f => ({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
url: f.url,
|
url: f.url,
|
||||||
|
|||||||
@@ -1,723 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SlidePanel
|
|
||||||
:show="show"
|
|
||||||
@close="tryClose"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<h2>{{ isNew ? 'Новая задача' : 'Редактирование' }}</h2>
|
|
||||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
|
||||||
Создано: {{ formatDate(card.dateCreate) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default>
|
|
||||||
<FormField label="Название">
|
|
||||||
<TextInput
|
|
||||||
v-model="form.title"
|
|
||||||
placeholder="Введите название задачи"
|
|
||||||
ref="titleInput"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Краткое описание">
|
|
||||||
<TextInput
|
|
||||||
v-model="form.description"
|
|
||||||
placeholder="Краткое описание в одну строку..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Подробное описание">
|
|
||||||
<template #actions>
|
|
||||||
<div class="format-buttons">
|
|
||||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
|
||||||
<i data-lucide="bold"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив (Ctrl+I)">
|
|
||||||
<i data-lucide="italic"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание (Ctrl+U)">
|
|
||||||
<i data-lucide="underline"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<RichTextEditor
|
|
||||||
v-model="form.details"
|
|
||||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
|
||||||
:show-toolbar="false"
|
|
||||||
ref="detailsEditor"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Отдел">
|
|
||||||
<TagsSelect
|
|
||||||
v-model="form.departmentId"
|
|
||||||
:options="departmentOptions"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Приоритет">
|
|
||||||
<TagsSelect
|
|
||||||
v-model="form.labelId"
|
|
||||||
:options="labelOptions"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<FormField label="Срок выполнения">
|
|
||||||
<DatePicker v-model="form.dueDate" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Исполнитель">
|
|
||||||
<SelectDropdown
|
|
||||||
v-model="form.userId"
|
|
||||||
:options="userOptions"
|
|
||||||
searchable
|
|
||||||
placeholder="Без исполнителя"
|
|
||||||
empty-label="Без исполнителя"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Прикреплённые файлы"
|
|
||||||
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
|
|
||||||
:error="fileError"
|
|
||||||
>
|
|
||||||
<FileUploader
|
|
||||||
:files="attachedFiles"
|
|
||||||
:get-full-url="getFullUrl"
|
|
||||||
@add="handleFileAdd"
|
|
||||||
@remove="handleFileRemove"
|
|
||||||
@preview="openImagePreview"
|
|
||||||
@error="fileError = $event"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="footer-left">
|
|
||||||
<button v-if="!isNew" class="btn-icon btn-delete" @click="handleDelete" title="Удалить">
|
|
||||||
<i data-lucide="trash-2"></i>
|
|
||||||
</button>
|
|
||||||
<button v-if="!isNew && canArchive && !isArchived" class="btn-icon btn-archive" @click="handleArchive" title="В архив">
|
|
||||||
<i data-lucide="archive"></i>
|
|
||||||
</button>
|
|
||||||
<button v-if="!isNew && isArchived" class="btn-icon btn-restore" @click="handleRestore" title="Из архива">
|
|
||||||
<i data-lucide="archive-restore"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="footer-right">
|
|
||||||
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
|
||||||
<button class="btn-save" @click="handleSave" :disabled="!form.title.trim() || !form.departmentId || isSaving">
|
|
||||||
<span v-if="isSaving" class="btn-loader"></span>
|
|
||||||
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</SlidePanel>
|
|
||||||
|
|
||||||
<!-- Диалог несохранённых изменений -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="showUnsavedDialog"
|
|
||||||
title="Обнаружены изменения"
|
|
||||||
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
|
|
||||||
confirm-text="Сохранить"
|
|
||||||
:show-discard="true"
|
|
||||||
@confirm="confirmSave"
|
|
||||||
@cancel="cancelClose"
|
|
||||||
@discard="confirmDiscard"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Диалог удаления задачи -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="showDeleteDialog"
|
|
||||||
title="Удалить задачу?"
|
|
||||||
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
|
|
||||||
confirm-text="Удалить"
|
|
||||||
variant="danger"
|
|
||||||
@confirm="confirmDelete"
|
|
||||||
@cancel="showDeleteDialog = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Диалог удаления файла -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="showDeleteFileDialog"
|
|
||||||
title="Удалить изображение?"
|
|
||||||
message="Изображение будет удалено из задачи."
|
|
||||||
confirm-text="Удалить"
|
|
||||||
variant="danger"
|
|
||||||
@confirm="confirmDeleteFile"
|
|
||||||
@cancel="showDeleteFileDialog = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Диалог архивации задачи -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="showArchiveDialog"
|
|
||||||
title="Архивировать задачу?"
|
|
||||||
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
|
|
||||||
confirm-text="В архив"
|
|
||||||
variant="warning"
|
|
||||||
@confirm="confirmArchive"
|
|
||||||
@cancel="showArchiveDialog = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Диалог разархивации задачи -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:show="showRestoreDialog"
|
|
||||||
title="Вернуть из архива?"
|
|
||||||
message="Задача будет возвращена на доску<br>в колонку «Готово»."
|
|
||||||
confirm-text="Вернуть"
|
|
||||||
variant="warning"
|
|
||||||
@confirm="confirmRestore"
|
|
||||||
@cancel="showRestoreDialog = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Модальное окно просмотра изображения -->
|
|
||||||
<ImagePreview
|
|
||||||
:file="previewImage"
|
|
||||||
:get-full-url="getFullUrl"
|
|
||||||
@close="closeImagePreview"
|
|
||||||
@delete="deleteFromPreview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
|
||||||
import DatePicker from './DatePicker.vue'
|
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
|
||||||
import SlidePanel from './ui/SlidePanel.vue'
|
|
||||||
import FormField from './ui/FormField.vue'
|
|
||||||
import TextInput from './ui/TextInput.vue'
|
|
||||||
import RichTextEditor from './ui/RichTextEditor.vue'
|
|
||||||
import SelectDropdown from './ui/SelectDropdown.vue'
|
|
||||||
import TagsSelect from './ui/TagsSelect.vue'
|
|
||||||
import FileUploader from './ui/FileUploader.vue'
|
|
||||||
import ImagePreview from './ui/ImagePreview.vue'
|
|
||||||
import { taskImageApi, getFullUrl } from '../api'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: Boolean,
|
|
||||||
card: Object,
|
|
||||||
columnId: [String, Number],
|
|
||||||
doneColumnId: Number,
|
|
||||||
isArchived: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
departments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
|
|
||||||
|
|
||||||
const isNew = ref(true)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
details: '',
|
|
||||||
departmentId: null,
|
|
||||||
labelId: null,
|
|
||||||
dueDate: '',
|
|
||||||
userId: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const titleInput = ref(null)
|
|
||||||
const detailsEditor = ref(null)
|
|
||||||
|
|
||||||
// Преобразование departments в формат для TagsSelect
|
|
||||||
const departmentOptions = computed(() => {
|
|
||||||
return props.departments.map(dept => ({
|
|
||||||
value: dept.id,
|
|
||||||
label: dept.name_departments,
|
|
||||||
color: dept.color
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Преобразование labels в формат для TagsSelect
|
|
||||||
const labelOptions = computed(() => {
|
|
||||||
return props.labels.map(label => ({
|
|
||||||
value: label.id,
|
|
||||||
label: label.name_labels,
|
|
||||||
icon: label.icon
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Преобразование users в формат для SelectDropdown
|
|
||||||
const userOptions = computed(() => {
|
|
||||||
return props.users.map(user => ({
|
|
||||||
value: user.id,
|
|
||||||
label: user.name,
|
|
||||||
subtitle: user.telegram,
|
|
||||||
avatar: getFullUrl(user.avatar_url)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Прикреплённые файлы
|
|
||||||
const attachedFiles = ref([])
|
|
||||||
const fileError = ref('')
|
|
||||||
|
|
||||||
// Просмотр изображения
|
|
||||||
const previewImage = ref(null)
|
|
||||||
|
|
||||||
// Диалоги
|
|
||||||
const showUnsavedDialog = ref(false)
|
|
||||||
const showDeleteDialog = ref(false)
|
|
||||||
const showDeleteFileDialog = ref(false)
|
|
||||||
const showArchiveDialog = ref(false)
|
|
||||||
const showRestoreDialog = ref(false)
|
|
||||||
const fileToDeleteIndex = ref(null)
|
|
||||||
|
|
||||||
// Начальные значения для отслеживания изменений
|
|
||||||
const initialForm = ref({})
|
|
||||||
const initialFilesCount = ref(0)
|
|
||||||
|
|
||||||
const saveInitialForm = () => {
|
|
||||||
initialForm.value = {
|
|
||||||
title: form.title,
|
|
||||||
description: form.description,
|
|
||||||
details: form.details,
|
|
||||||
departmentId: form.departmentId,
|
|
||||||
labelId: form.labelId,
|
|
||||||
dueDate: form.dueDate,
|
|
||||||
userId: form.userId
|
|
||||||
}
|
|
||||||
initialFilesCount.value = attachedFiles.value.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChanges = computed(() => {
|
|
||||||
return form.title !== initialForm.value.title ||
|
|
||||||
form.description !== initialForm.value.description ||
|
|
||||||
form.details !== initialForm.value.details ||
|
|
||||||
form.departmentId !== initialForm.value.departmentId ||
|
|
||||||
form.labelId !== initialForm.value.labelId ||
|
|
||||||
form.dueDate !== initialForm.value.dueDate ||
|
|
||||||
form.userId !== initialForm.value.userId ||
|
|
||||||
attachedFiles.value.length !== initialFilesCount.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const tryClose = () => {
|
|
||||||
if (hasChanges.value) {
|
|
||||||
showUnsavedDialog.value = true
|
|
||||||
} else {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmSave = () => {
|
|
||||||
showUnsavedDialog.value = false
|
|
||||||
handleSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDiscard = () => {
|
|
||||||
showUnsavedDialog.value = false
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelClose = () => {
|
|
||||||
showUnsavedDialog.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAvatarByUserId = (userId) => {
|
|
||||||
if (!userId) return null
|
|
||||||
const user = props.users.find(u => u.id === userId)
|
|
||||||
return user ? user.avatar_url : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
form.title = ''
|
|
||||||
form.description = ''
|
|
||||||
form.details = ''
|
|
||||||
form.departmentId = null
|
|
||||||
form.labelId = 2 // Нормально по умолчанию
|
|
||||||
form.dueDate = new Date().toISOString().split('T')[0] // Сегодня по умолчанию
|
|
||||||
form.userId = null
|
|
||||||
clearFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearFiles = () => {
|
|
||||||
attachedFiles.value = []
|
|
||||||
fileError.value = ''
|
|
||||||
previewImage.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применить форматирование через редактор
|
|
||||||
const applyFormat = (command) => {
|
|
||||||
detailsEditor.value?.applyFormat(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.show, async (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
isNew.value = !props.card
|
|
||||||
clearFiles()
|
|
||||||
|
|
||||||
if (props.card) {
|
|
||||||
form.title = props.card.title || ''
|
|
||||||
form.description = props.card.description || ''
|
|
||||||
form.details = props.card.details || ''
|
|
||||||
form.departmentId = props.card.departmentId || null
|
|
||||||
form.labelId = props.card.labelId || null
|
|
||||||
form.dueDate = props.card.dueDate || ''
|
|
||||||
form.userId = props.card.accountId || null
|
|
||||||
|
|
||||||
if (props.card.files && props.card.files.length > 0) {
|
|
||||||
attachedFiles.value = props.card.files.map(f => ({
|
|
||||||
name: f.name,
|
|
||||||
size: f.size,
|
|
||||||
type: f.type,
|
|
||||||
preview: f.data || f.url,
|
|
||||||
isNew: false
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
saveInitialForm()
|
|
||||||
|
|
||||||
// Установка содержимого редактора
|
|
||||||
detailsEditor.value?.setContent(form.details)
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
titleInput.value?.$el?.focus()
|
|
||||||
refreshIcons()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!form.title.trim()) return
|
|
||||||
|
|
||||||
isSaving.value = true
|
|
||||||
fileError.value = ''
|
|
||||||
|
|
||||||
if (props.card?.id) {
|
|
||||||
// Загружаем новые файлы
|
|
||||||
const newFiles = attachedFiles.value.filter(f => f.isNew && !f.toDelete)
|
|
||||||
for (const file of newFiles) {
|
|
||||||
const result = await taskImageApi.upload(props.card.id, file.preview, file.name)
|
|
||||||
if (result.success) {
|
|
||||||
file.isNew = false
|
|
||||||
file.name = result.file.name
|
|
||||||
file.preview = result.file.url
|
|
||||||
} else {
|
|
||||||
fileError.value = result.errors?.file || 'Ошибка загрузки файла'
|
|
||||||
isSaving.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем помеченные файлы
|
|
||||||
const filesToDelete = attachedFiles.value.filter(f => f.toDelete && !f.isNew)
|
|
||||||
if (filesToDelete.length > 0) {
|
|
||||||
const fileNames = filesToDelete.map(f => f.name)
|
|
||||||
const result = await taskImageApi.delete(props.card.id, fileNames)
|
|
||||||
if (!result.success) {
|
|
||||||
fileError.value = result.errors?.file || 'Ошибка удаления файла'
|
|
||||||
isSaving.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('save', {
|
|
||||||
title: form.title,
|
|
||||||
description: form.description,
|
|
||||||
details: form.details,
|
|
||||||
departmentId: form.departmentId,
|
|
||||||
labelId: form.labelId,
|
|
||||||
dueDate: form.dueDate,
|
|
||||||
accountId: form.userId,
|
|
||||||
assignee: getAvatarByUserId(form.userId),
|
|
||||||
id: props.card?.id,
|
|
||||||
files: attachedFiles.value
|
|
||||||
.filter(f => !f.toDelete)
|
|
||||||
.map(f => ({
|
|
||||||
name: f.name,
|
|
||||||
size: f.size,
|
|
||||||
type: f.type,
|
|
||||||
data: f.preview
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
showDeleteDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Можно ли архивировать (только если колонка "Готово")
|
|
||||||
const canArchive = computed(() => {
|
|
||||||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleArchive = () => {
|
|
||||||
showArchiveDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmArchive = () => {
|
|
||||||
showArchiveDialog.value = false
|
|
||||||
if (props.card?.id) {
|
|
||||||
emit('archive', props.card.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRestore = () => {
|
|
||||||
showRestoreDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmRestore = () => {
|
|
||||||
showRestoreDialog.value = false
|
|
||||||
if (props.card?.id) {
|
|
||||||
emit('restore', props.card.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
showDeleteDialog.value = false
|
|
||||||
emit('delete', props.card.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const day = date.getDate()
|
|
||||||
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
|
||||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Работа с файлами
|
|
||||||
const handleFileAdd = async (file) => {
|
|
||||||
attachedFiles.value.push(file)
|
|
||||||
await nextTick()
|
|
||||||
refreshIcons()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileRemove = (index) => {
|
|
||||||
fileToDeleteIndex.value = index
|
|
||||||
showDeleteFileDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDeleteFile = () => {
|
|
||||||
if (fileToDeleteIndex.value !== null) {
|
|
||||||
const file = attachedFiles.value[fileToDeleteIndex.value]
|
|
||||||
|
|
||||||
if (file.isNew) {
|
|
||||||
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
|
|
||||||
} else {
|
|
||||||
file.toDelete = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showDeleteFileDialog.value = false
|
|
||||||
fileToDeleteIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const openImagePreview = (file) => {
|
|
||||||
previewImage.value = file
|
|
||||||
nextTick(refreshIcons)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeImagePreview = () => {
|
|
||||||
previewImage.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFromPreview = () => {
|
|
||||||
if (previewImage.value) {
|
|
||||||
const index = attachedFiles.value.findIndex(
|
|
||||||
f => f.name === previewImage.value.name && f.size === previewImage.value.size
|
|
||||||
)
|
|
||||||
if (index !== -1) {
|
|
||||||
closeImagePreview()
|
|
||||||
handleFileRemove(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.header-date {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row > :deep(.field) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn:active {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn i {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.btn-icon.btn-delete {
|
|
||||||
border: 1px solid var(--red);
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-delete:hover {
|
|
||||||
background: var(--red);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-left {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
background: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon i {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-archive {
|
|
||||||
border: 1px solid var(--orange);
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-archive:hover {
|
|
||||||
background: var(--orange);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-restore {
|
|
||||||
border: 1px solid var(--orange);
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-restore:hover {
|
|
||||||
background: var(--orange);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save {
|
|
||||||
padding: 12px 28px;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #000;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-loader {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
423
front_vue/src/components/TaskPanel/CommentForm.vue
Normal file
423
front_vue/src/components/TaskPanel/CommentForm.vue
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comment-form">
|
||||||
|
<!-- Индикатор "ответ на" -->
|
||||||
|
<div v-if="replyingTo" class="reply-indicator">
|
||||||
|
<i data-lucide="corner-down-right"></i>
|
||||||
|
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||||
|
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
|
||||||
|
<template #actions>
|
||||||
|
<div class="format-buttons">
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
|
||||||
|
<i data-lucide="bold"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
|
||||||
|
<i data-lucide="italic"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
|
||||||
|
<i data-lucide="underline"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
|
||||||
|
<i data-lucide="paperclip"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<RichTextEditor
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="$emit('update:modelValue', $event)"
|
||||||
|
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||||
|
:show-toolbar="false"
|
||||||
|
ref="editorRef"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Скрытый input для файлов -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInputRef"
|
||||||
|
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
|
||||||
|
multiple
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Превью прикреплённых файлов -->
|
||||||
|
<div v-if="files.length > 0" class="attached-files">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in files"
|
||||||
|
:key="file.name + '-' + index"
|
||||||
|
class="attached-file"
|
||||||
|
>
|
||||||
|
<div class="attached-file-icon">
|
||||||
|
<i v-if="isArchive(file)" data-lucide="archive"></i>
|
||||||
|
<i v-else data-lucide="image"></i>
|
||||||
|
</div>
|
||||||
|
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-send-comment"
|
||||||
|
@click="$emit('send')"
|
||||||
|
:disabled="!canSend || isSending"
|
||||||
|
>
|
||||||
|
<span v-if="isSending" class="btn-loader"></span>
|
||||||
|
<template v-else>
|
||||||
|
<i data-lucide="send"></i>
|
||||||
|
Отправить
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||||
|
import FormField from '../ui/FormField.vue'
|
||||||
|
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
replyingTo: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isSending: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue', 'send', 'cancel-reply'])
|
||||||
|
|
||||||
|
const editorRef = ref(null)
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
const files = ref([])
|
||||||
|
|
||||||
|
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
|
||||||
|
const archiveExtensions = ['zip', 'rar']
|
||||||
|
const maxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// Можно отправить если есть текст или файлы
|
||||||
|
const canSend = computed(() => {
|
||||||
|
return props.modelValue.trim() || files.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверка расширения файла
|
||||||
|
const getFileExt = (file) => {
|
||||||
|
return file.name?.split('.').pop()?.toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isArchive = (file) => {
|
||||||
|
return archiveExtensions.includes(getFileExt(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowedFile = (file) => {
|
||||||
|
return allowedExtensions.includes(getFileExt(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть диалог выбора файлов
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка выбора файлов
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
const selectedFiles = event.target.files
|
||||||
|
if (selectedFiles) {
|
||||||
|
processFiles(Array.from(selectedFiles))
|
||||||
|
}
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка файлов
|
||||||
|
const processFiles = (fileList) => {
|
||||||
|
for (const file of fileList) {
|
||||||
|
// Проверка типа
|
||||||
|
if (!isAllowedFile(file)) {
|
||||||
|
console.warn(`Файл "${file.name}" не поддерживается.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка размера
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
console.warn(`Файл "${file.name}" слишком большой.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем дубликат
|
||||||
|
const isDuplicate = files.value.some(
|
||||||
|
f => f.name === file.name && f.size === file.size
|
||||||
|
)
|
||||||
|
if (isDuplicate) continue
|
||||||
|
|
||||||
|
// Читаем файл как base64
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
files.value.push({
|
||||||
|
file: file,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
data: e.target.result
|
||||||
|
})
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файла из списка
|
||||||
|
const removeFile = (index) => {
|
||||||
|
files.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить файлы для отправки
|
||||||
|
const getFiles = () => {
|
||||||
|
return files.value.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
data: f.data
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистить файлы
|
||||||
|
const clearFiles = () => {
|
||||||
|
files.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFormat = (command) => {
|
||||||
|
editorRef.value?.applyFormat(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
// Expose для внешнего доступа
|
||||||
|
defineExpose({
|
||||||
|
setContent: (text) => editorRef.value?.setContent(text),
|
||||||
|
focus: () => editorRef.value?.$el?.focus(),
|
||||||
|
applyFormat,
|
||||||
|
getFiles,
|
||||||
|
clearFiles,
|
||||||
|
hasFiles: computed(() => files.value.length > 0)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-form {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send-comment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #000;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send-comment:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send-comment:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send-comment i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loader {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||||
|
border-top-color: #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Индикатор ответа */
|
||||||
|
.reply-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 212, 170, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-cancel {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-cancel i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прикреплённые файлы */
|
||||||
|
.attached-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-icon i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-name {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-remove:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file-remove i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
466
front_vue/src/components/TaskPanel/CommentItem.vue
Normal file
466
front_vue/src/components/TaskPanel/CommentItem.vue
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="comment"
|
||||||
|
:class="{
|
||||||
|
'comment--own': isOwn,
|
||||||
|
'comment--editing': isEditing,
|
||||||
|
'comment--reply': level > 0
|
||||||
|
}"
|
||||||
|
:style="{ marginLeft: level * 24 + 'px' }"
|
||||||
|
>
|
||||||
|
<div class="comment-avatar" :class="{ 'comment-avatar--small': level > 0 }">
|
||||||
|
<img v-if="comment.author_avatar" :src="getFullUrl(comment.author_avatar)" alt="">
|
||||||
|
<i v-else data-lucide="user"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-author">{{ comment.author_name }}</span>
|
||||||
|
<span class="comment-date">{{ formattedDate }}</span>
|
||||||
|
|
||||||
|
<div v-if="!isEditing" class="comment-actions">
|
||||||
|
<IconButton
|
||||||
|
icon="reply"
|
||||||
|
variant="ghost"
|
||||||
|
small
|
||||||
|
title="Ответить"
|
||||||
|
@click="$emit('reply', comment)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
v-if="canEdit"
|
||||||
|
icon="pencil"
|
||||||
|
variant="ghost"
|
||||||
|
small
|
||||||
|
title="Редактировать"
|
||||||
|
@click="$emit('edit', comment)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
v-if="canEdit"
|
||||||
|
icon="trash-2"
|
||||||
|
variant="ghost"
|
||||||
|
small
|
||||||
|
title="Удалить"
|
||||||
|
class="comment-btn-delete"
|
||||||
|
@click="$emit('delete', comment)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Режим редактирования -->
|
||||||
|
<div v-if="isEditing" class="comment-edit-form">
|
||||||
|
<RichTextEditor
|
||||||
|
:modelValue="editText"
|
||||||
|
@update:modelValue="$emit('update:editText', $event)"
|
||||||
|
placeholder="Редактирование комментария..."
|
||||||
|
:show-toolbar="false"
|
||||||
|
ref="editEditor"
|
||||||
|
/>
|
||||||
|
<div class="comment-edit-actions">
|
||||||
|
<button class="btn-comment-cancel" @click="$emit('cancel-edit')">Отмена</button>
|
||||||
|
<button
|
||||||
|
class="btn-comment-save"
|
||||||
|
@click="$emit('save-edit')"
|
||||||
|
:disabled="!editText.trim()"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Обычный вид -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="comment-text" v-html="comment.text"></div>
|
||||||
|
|
||||||
|
<!-- Прикреплённые файлы -->
|
||||||
|
<div v-if="comment.file_img && comment.file_img.length > 0" class="comment-files">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in comment.file_img"
|
||||||
|
:key="file.name + '-' + index"
|
||||||
|
class="comment-file"
|
||||||
|
@click="!isArchiveFile(file) && openPreview(file)"
|
||||||
|
>
|
||||||
|
<template v-if="isArchiveFile(file)">
|
||||||
|
<div class="comment-file-archive">
|
||||||
|
<i data-lucide="archive"></i>
|
||||||
|
<span class="comment-file-ext">.{{ getFileExt(file) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<img v-else :src="getFullUrl(file.url)" :alt="file.name">
|
||||||
|
<a
|
||||||
|
class="comment-file-download"
|
||||||
|
:href="getFullUrl(file.url)"
|
||||||
|
:download="file.name"
|
||||||
|
@click.stop
|
||||||
|
title="Скачать"
|
||||||
|
>
|
||||||
|
<i data-lucide="download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||||
|
import IconButton from '../ui/IconButton.vue'
|
||||||
|
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||||
|
import { serverSettings } from '../../api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
comment: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
isOwn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
canEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isEditing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
editText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
getFullUrl: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['reply', 'edit', 'delete', 'cancel-edit', 'save-edit', 'update:editText', 'preview-file'])
|
||||||
|
|
||||||
|
const editEditor = ref(null)
|
||||||
|
|
||||||
|
const archiveExtensions = ['zip', 'rar']
|
||||||
|
|
||||||
|
// Получить расширение файла
|
||||||
|
const getFileExt = (file) => {
|
||||||
|
return file.name?.split('.').pop()?.toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка является ли файл архивом
|
||||||
|
const isArchiveFile = (file) => {
|
||||||
|
return archiveExtensions.includes(getFileExt(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть превью файла
|
||||||
|
const openPreview = (file) => {
|
||||||
|
emit('preview-file', file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты комментария
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.comment.date_create) return ''
|
||||||
|
// Используем таймзону сервера из настроек
|
||||||
|
const date = serverSettings.parseDate(props.comment.date_create)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return 'только что'
|
||||||
|
if (minutes < 60) return `${minutes} мин. назад`
|
||||||
|
if (hours < 24) return `${hours} ч. назад`
|
||||||
|
if (days < 7) return `${days} дн. назад`
|
||||||
|
|
||||||
|
const day = date.getDate()
|
||||||
|
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||||
|
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${day} ${months[date.getMonth()]} ${time}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Установка контента редактора при начале редактирования
|
||||||
|
watch(() => props.isEditing, async (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
await nextTick()
|
||||||
|
editEditor.value?.setContent(props.editText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
// Expose для установки контента редактора
|
||||||
|
defineExpose({
|
||||||
|
setEditContent: (text) => editEditor.value?.setContent(text)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--own {
|
||||||
|
background: rgba(0, 212, 170, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--own:hover {
|
||||||
|
background: rgba(0, 212, 170, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--editing {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--reply {
|
||||||
|
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar--small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar--small i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header .comment-date {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text :deep(b),
|
||||||
|
.comment-text :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:hover .comment-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-btn-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||||
|
color: var(--red) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Редактирование комментария */
|
||||||
|
.comment-edit-form {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-comment-cancel {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-comment-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-comment-save {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #000;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-comment-save:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-comment-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прикреплённые файлы в комментарии */
|
||||||
|
.comment-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file {
|
||||||
|
position: relative;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-archive {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-archive i {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-ext {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-download {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file:hover .comment-file-download {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-download:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-file-download i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
452
front_vue/src/components/TaskPanel/TaskCommentsTab.vue
Normal file
452
front_vue/src/components/TaskPanel/TaskCommentsTab.vue
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comments-tab">
|
||||||
|
<!-- Список комментариев -->
|
||||||
|
<div class="comments-list" ref="commentsListRef">
|
||||||
|
<div v-if="loading" class="comments-loading">
|
||||||
|
<span class="loader"></span>
|
||||||
|
Загрузка комментариев...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="flatCommentsWithLevel.length === 0" class="comments-empty">
|
||||||
|
<i data-lucide="message-circle"></i>
|
||||||
|
<span>Пока нет комментариев</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommentItem
|
||||||
|
v-else
|
||||||
|
v-for="comment in flatCommentsWithLevel"
|
||||||
|
:key="comment.id"
|
||||||
|
:comment="comment"
|
||||||
|
:level="comment.level"
|
||||||
|
:is-own="comment.id_accounts === currentUserId"
|
||||||
|
:can-edit="canEditComment(comment)"
|
||||||
|
:is-editing="editingCommentId === comment.id"
|
||||||
|
:edit-text="editingCommentText"
|
||||||
|
:get-full-url="getFullUrl"
|
||||||
|
@update:edit-text="editingCommentText = $event"
|
||||||
|
@reply="startReply"
|
||||||
|
@edit="startEditComment"
|
||||||
|
@delete="confirmDeleteComment"
|
||||||
|
@cancel-edit="cancelEditComment"
|
||||||
|
@save-edit="saveEditComment"
|
||||||
|
@preview-file="(file) => previewFile(file, comment)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма добавления комментария -->
|
||||||
|
<CommentForm
|
||||||
|
v-model="newCommentText"
|
||||||
|
:replying-to="replyingTo"
|
||||||
|
:is-sending="isSendingComment"
|
||||||
|
ref="commentFormRef"
|
||||||
|
@send="sendComment"
|
||||||
|
@cancel-reply="cancelReply"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог удаления комментария -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteDialog"
|
||||||
|
title="Удалить комментарий?"
|
||||||
|
message="Комментарий будет удалён навсегда."
|
||||||
|
confirm-text="Удалить"
|
||||||
|
variant="danger"
|
||||||
|
@confirm="deleteComment"
|
||||||
|
@cancel="showDeleteDialog = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
|
||||||
|
import CommentItem from './CommentItem.vue'
|
||||||
|
import CommentForm from './CommentForm.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
taskId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isProjectAdmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['comments-loaded', 'preview-file'])
|
||||||
|
|
||||||
|
// Состояние
|
||||||
|
const comments = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const newCommentText = ref('')
|
||||||
|
const isSendingComment = ref(false)
|
||||||
|
const editingCommentId = ref(null)
|
||||||
|
const editingCommentText = ref('')
|
||||||
|
const commentToDelete = ref(null)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const replyingTo = ref(null)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const commentsListRef = ref(null)
|
||||||
|
const commentFormRef = ref(null)
|
||||||
|
|
||||||
|
// Интервал обновления
|
||||||
|
let refreshInterval = null
|
||||||
|
|
||||||
|
// Построение дерева комментариев
|
||||||
|
const commentsTree = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
const roots = []
|
||||||
|
|
||||||
|
comments.value.forEach(c => {
|
||||||
|
map.set(c.id, { ...c, children: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.value.forEach(c => {
|
||||||
|
const comment = map.get(c.id)
|
||||||
|
if (c.id_answer && map.has(c.id_answer)) {
|
||||||
|
map.get(c.id_answer).children.push(comment)
|
||||||
|
} else {
|
||||||
|
roots.push(comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return roots
|
||||||
|
})
|
||||||
|
|
||||||
|
// Плоский список с уровнями вложенности
|
||||||
|
const flatCommentsWithLevel = computed(() => {
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
const flatten = (items, level = 0) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
result.push({ ...item, level })
|
||||||
|
if (item.children?.length) {
|
||||||
|
flatten(item.children, level + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten(commentsTree.value)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Загрузка комментариев
|
||||||
|
const loadComments = async (silent = false) => {
|
||||||
|
if (!props.taskId) return
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await commentsApi.getByTask(props.taskId)
|
||||||
|
if (result.success) {
|
||||||
|
const newData = result.data
|
||||||
|
const oldData = comments.value
|
||||||
|
|
||||||
|
// Проверяем изменения
|
||||||
|
const hasChanges =
|
||||||
|
newData.length !== oldData.length ||
|
||||||
|
newData.some((newComment, i) => {
|
||||||
|
const oldComment = oldData[i]
|
||||||
|
return !oldComment ||
|
||||||
|
newComment.id !== oldComment.id ||
|
||||||
|
newComment.text !== oldComment.text
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
comments.value = newData
|
||||||
|
emit('comments-loaded', newData.length)
|
||||||
|
await nextTick()
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка загрузки комментариев:', e)
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автообновление
|
||||||
|
const startRefresh = () => {
|
||||||
|
stopRefresh()
|
||||||
|
const interval = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS || 30) * 1000
|
||||||
|
refreshInterval = setInterval(async () => {
|
||||||
|
if (props.active && props.taskId) {
|
||||||
|
await loadComments(true)
|
||||||
|
}
|
||||||
|
}, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRefresh = () => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка комментария
|
||||||
|
const sendComment = async () => {
|
||||||
|
const hasText = newCommentText.value.trim()
|
||||||
|
const files = commentFormRef.value?.getFiles() || []
|
||||||
|
const hasFiles = files.length > 0
|
||||||
|
|
||||||
|
if ((!hasText && !hasFiles) || !props.taskId) return
|
||||||
|
|
||||||
|
isSendingComment.value = true
|
||||||
|
try {
|
||||||
|
const id_answer = replyingTo.value?.id || null
|
||||||
|
// Если нет текста но есть файлы — отправляем пустой текст (или пробел)
|
||||||
|
const text = hasText ? newCommentText.value : ' '
|
||||||
|
const result = await commentsApi.create(props.taskId, text, id_answer)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const commentId = result.comment.id
|
||||||
|
|
||||||
|
// Загружаем файлы к созданному комментарию
|
||||||
|
if (hasFiles) {
|
||||||
|
const uploadedFiles = []
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const uploadResult = await commentImageApi.upload(commentId, file.data, file.name)
|
||||||
|
if (uploadResult.success) {
|
||||||
|
uploadedFiles.push(uploadResult.file)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка загрузки файла:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Обновляем file_img в комментарии
|
||||||
|
result.comment.file_img = uploadedFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
comments.value.push(result.comment)
|
||||||
|
emit('comments-loaded', comments.value.length)
|
||||||
|
newCommentText.value = ''
|
||||||
|
commentFormRef.value?.setContent('')
|
||||||
|
commentFormRef.value?.clearFiles()
|
||||||
|
replyingTo.value = null
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка отправки комментария:', e)
|
||||||
|
} finally {
|
||||||
|
isSendingComment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ответ
|
||||||
|
const startReply = (comment) => {
|
||||||
|
replyingTo.value = comment
|
||||||
|
commentFormRef.value?.focus()
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelReply = () => {
|
||||||
|
replyingTo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактирование
|
||||||
|
const canEditComment = (comment) => {
|
||||||
|
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditComment = (comment) => {
|
||||||
|
editingCommentId.value = comment.id
|
||||||
|
editingCommentText.value = comment.text
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditComment = () => {
|
||||||
|
editingCommentId.value = null
|
||||||
|
editingCommentText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEditComment = async () => {
|
||||||
|
if (!editingCommentText.value.trim() || !editingCommentId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await commentsApi.update(editingCommentId.value, editingCommentText.value)
|
||||||
|
if (result.success) {
|
||||||
|
const index = comments.value.findIndex(c => c.id === editingCommentId.value)
|
||||||
|
if (index !== -1) {
|
||||||
|
comments.value[index] = result.comment
|
||||||
|
}
|
||||||
|
cancelEditComment()
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка обновления комментария:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление
|
||||||
|
const confirmDeleteComment = (comment) => {
|
||||||
|
commentToDelete.value = comment
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async () => {
|
||||||
|
if (!commentToDelete.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await commentsApi.delete(commentToDelete.value.id)
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentToDelete.value.id)
|
||||||
|
emit('comments-loaded', comments.value.length)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка удаления комментария:', e)
|
||||||
|
} finally {
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
commentToDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Превью файла (пробрасываем в родитель)
|
||||||
|
const previewFile = (file, comment) => {
|
||||||
|
// Проверяем права на удаление: автор комментария или админ проекта
|
||||||
|
const canDelete = comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||||||
|
|
||||||
|
emit('preview-file', {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
preview: file.url,
|
||||||
|
source: 'comment',
|
||||||
|
commentId: comment.id,
|
||||||
|
canDelete
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хелперы
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (commentsListRef.value) {
|
||||||
|
commentsListRef.value.scrollTop = commentsListRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс состояния
|
||||||
|
const reset = () => {
|
||||||
|
comments.value = []
|
||||||
|
newCommentText.value = ''
|
||||||
|
editingCommentId.value = null
|
||||||
|
editingCommentText.value = ''
|
||||||
|
replyingTo.value = null
|
||||||
|
stopRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch для активации/деактивации
|
||||||
|
watch(() => props.active, (newVal) => {
|
||||||
|
if (newVal && props.taskId) {
|
||||||
|
if (comments.value.length === 0) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
startRefresh()
|
||||||
|
} else {
|
||||||
|
stopRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch для смены задачи
|
||||||
|
watch(() => props.taskId, (newVal, oldVal) => {
|
||||||
|
if (newVal !== oldVal) {
|
||||||
|
reset()
|
||||||
|
if (props.active && newVal) {
|
||||||
|
loadComments()
|
||||||
|
startRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active && props.taskId) {
|
||||||
|
loadComments()
|
||||||
|
startRefresh()
|
||||||
|
}
|
||||||
|
refreshIcons()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
onUnmounted(stopRefresh)
|
||||||
|
|
||||||
|
// Expose для родителя
|
||||||
|
defineExpose({
|
||||||
|
reset,
|
||||||
|
loadComments,
|
||||||
|
commentsCount: computed(() => comments.value.length)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comments-tab {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px; /* Для outline при редактировании */
|
||||||
|
padding-right: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: calc(100vh - 400px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-loading,
|
||||||
|
.comments-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-empty i {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
408
front_vue/src/components/TaskPanel/TaskEditTab.vue
Normal file
408
front_vue/src/components/TaskPanel/TaskEditTab.vue
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-tab">
|
||||||
|
<FormField label="Название">
|
||||||
|
<TextInput
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="Введите название задачи"
|
||||||
|
ref="titleInputRef"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Краткое описание">
|
||||||
|
<TextInput
|
||||||
|
v-model="form.description"
|
||||||
|
placeholder="Краткое описание в одну строку..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Подробное описание">
|
||||||
|
<template #actions>
|
||||||
|
<div class="format-buttons">
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
||||||
|
<i data-lucide="bold"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив (Ctrl+I)">
|
||||||
|
<i data-lucide="italic"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание (Ctrl+U)">
|
||||||
|
<i data-lucide="underline"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<RichTextEditor
|
||||||
|
v-model="form.details"
|
||||||
|
placeholder="Подробное описание задачи, заметки, ссылки..."
|
||||||
|
:show-toolbar="false"
|
||||||
|
ref="detailsEditorRef"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Отдел">
|
||||||
|
<TagsSelect
|
||||||
|
v-model="form.departmentId"
|
||||||
|
:options="departmentOptions"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Приоритет">
|
||||||
|
<TagsSelect
|
||||||
|
v-model="form.labelId"
|
||||||
|
:options="labelOptions"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<FormField label="Срок выполнения">
|
||||||
|
<DatePicker v-model="form.dueDate" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Исполнитель">
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="form.userId"
|
||||||
|
:options="userOptions"
|
||||||
|
searchable
|
||||||
|
placeholder="Без исполнителя"
|
||||||
|
empty-label="Без исполнителя"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Прикреплённые файлы"
|
||||||
|
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
|
||||||
|
:error="fileError"
|
||||||
|
>
|
||||||
|
<FileUploader
|
||||||
|
:files="attachedFiles"
|
||||||
|
:get-full-url="getFullUrl"
|
||||||
|
@add="handleFileAdd"
|
||||||
|
@remove="handleFileRemove"
|
||||||
|
@preview="$emit('preview-image', $event)"
|
||||||
|
@error="fileError = $event"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Диалог удаления файла -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteFileDialog"
|
||||||
|
title="Удалить изображение?"
|
||||||
|
message="Изображение будет удалено из задачи."
|
||||||
|
confirm-text="Удалить"
|
||||||
|
variant="danger"
|
||||||
|
@confirm="confirmDeleteFile"
|
||||||
|
@cancel="showDeleteFileDialog = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||||||
|
import FormField from '../ui/FormField.vue'
|
||||||
|
import TextInput from '../ui/TextInput.vue'
|
||||||
|
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||||
|
import SelectDropdown from '../ui/SelectDropdown.vue'
|
||||||
|
import TagsSelect from '../ui/TagsSelect.vue'
|
||||||
|
import FileUploader from '../ui/FileUploader.vue'
|
||||||
|
import DatePicker from '../DatePicker.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { getFullUrl } from '../../api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
departments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['preview-image'])
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const titleInputRef = ref(null)
|
||||||
|
const detailsEditorRef = ref(null)
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
details: '',
|
||||||
|
departmentId: null,
|
||||||
|
labelId: null,
|
||||||
|
dueDate: '',
|
||||||
|
userId: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Files
|
||||||
|
const attachedFiles = ref([])
|
||||||
|
const fileError = ref('')
|
||||||
|
const showDeleteFileDialog = ref(false)
|
||||||
|
const fileToDeleteIndex = ref(null)
|
||||||
|
|
||||||
|
// Initial state for change tracking
|
||||||
|
const initialForm = ref({})
|
||||||
|
const initialFilesCount = ref(0)
|
||||||
|
|
||||||
|
// Options for selects
|
||||||
|
const departmentOptions = computed(() => {
|
||||||
|
return props.departments.map(dept => ({
|
||||||
|
value: dept.id,
|
||||||
|
label: dept.name_departments,
|
||||||
|
color: dept.color
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelOptions = computed(() => {
|
||||||
|
return props.labels.map(label => ({
|
||||||
|
value: label.id,
|
||||||
|
label: label.name_labels,
|
||||||
|
icon: label.icon
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOptions = computed(() => {
|
||||||
|
return props.users.map(user => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name,
|
||||||
|
subtitle: user.telegram,
|
||||||
|
avatar: getFullUrl(user.avatar_url)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Change tracking
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return form.title !== initialForm.value.title ||
|
||||||
|
form.description !== initialForm.value.description ||
|
||||||
|
form.details !== initialForm.value.details ||
|
||||||
|
form.departmentId !== initialForm.value.departmentId ||
|
||||||
|
form.labelId !== initialForm.value.labelId ||
|
||||||
|
form.dueDate !== initialForm.value.dueDate ||
|
||||||
|
form.userId !== initialForm.value.userId ||
|
||||||
|
attachedFiles.value.length !== initialFilesCount.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const saveInitialForm = () => {
|
||||||
|
initialForm.value = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
details: form.details,
|
||||||
|
departmentId: form.departmentId,
|
||||||
|
labelId: form.labelId,
|
||||||
|
dueDate: form.dueDate,
|
||||||
|
userId: form.userId
|
||||||
|
}
|
||||||
|
initialFilesCount.value = attachedFiles.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.title = ''
|
||||||
|
form.description = ''
|
||||||
|
form.details = ''
|
||||||
|
form.departmentId = null
|
||||||
|
form.labelId = 2 // Нормально по умолчанию
|
||||||
|
form.dueDate = new Date().toISOString().split('T')[0]
|
||||||
|
form.userId = null
|
||||||
|
clearFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFiles = () => {
|
||||||
|
attachedFiles.value = []
|
||||||
|
fileError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromCard = (card) => {
|
||||||
|
if (card) {
|
||||||
|
form.title = card.title || ''
|
||||||
|
form.description = card.description || ''
|
||||||
|
form.details = card.details || ''
|
||||||
|
form.departmentId = card.departmentId || null
|
||||||
|
form.labelId = card.labelId || null
|
||||||
|
form.dueDate = card.dueDate || ''
|
||||||
|
form.userId = card.accountId || null
|
||||||
|
|
||||||
|
if (card.files && card.files.length > 0) {
|
||||||
|
attachedFiles.value = card.files.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
size: f.size,
|
||||||
|
type: f.type,
|
||||||
|
preview: f.data || f.url,
|
||||||
|
isNew: false
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
attachedFiles.value = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFormat = (command) => {
|
||||||
|
detailsEditorRef.value?.applyFormat(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvatarByUserId = (userId) => {
|
||||||
|
if (!userId) return null
|
||||||
|
const user = props.users.find(u => u.id === userId)
|
||||||
|
return user ? user.avatar_url : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// File handlers
|
||||||
|
const handleFileAdd = async (file) => {
|
||||||
|
attachedFiles.value.push(file)
|
||||||
|
await nextTick()
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileRemove = (index) => {
|
||||||
|
fileToDeleteIndex.value = index
|
||||||
|
showDeleteFileDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteFile = () => {
|
||||||
|
if (fileToDeleteIndex.value !== null) {
|
||||||
|
const file = attachedFiles.value[fileToDeleteIndex.value]
|
||||||
|
|
||||||
|
if (file.isNew) {
|
||||||
|
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
|
||||||
|
} else {
|
||||||
|
file.toDelete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showDeleteFileDialog.value = false
|
||||||
|
fileToDeleteIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data for saving
|
||||||
|
const getFormData = () => {
|
||||||
|
return {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
details: form.details,
|
||||||
|
departmentId: form.departmentId,
|
||||||
|
labelId: form.labelId,
|
||||||
|
dueDate: form.dueDate,
|
||||||
|
accountId: form.userId,
|
||||||
|
assignee: getAvatarByUserId(form.userId),
|
||||||
|
files: attachedFiles.value
|
||||||
|
.filter(f => !f.toDelete)
|
||||||
|
.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
size: f.size,
|
||||||
|
type: f.type,
|
||||||
|
data: f.preview
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNewFiles = () => {
|
||||||
|
return attachedFiles.value.filter(f => f.isNew && !f.toDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFilesToDelete = () => {
|
||||||
|
return attachedFiles.value.filter(f => f.toDelete && !f.isNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDeletedFiles = () => {
|
||||||
|
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusTitle = async () => {
|
||||||
|
await nextTick()
|
||||||
|
titleInputRef.value?.$el?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDetailsContent = (content) => {
|
||||||
|
detailsEditorRef.value?.setContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
// Expose for parent
|
||||||
|
defineExpose({
|
||||||
|
form,
|
||||||
|
attachedFiles,
|
||||||
|
fileError,
|
||||||
|
hasChanges,
|
||||||
|
loadFromCard,
|
||||||
|
resetForm,
|
||||||
|
clearFiles,
|
||||||
|
saveInitialForm,
|
||||||
|
getFormData,
|
||||||
|
getNewFiles,
|
||||||
|
getFilesToDelete,
|
||||||
|
removeDeletedFiles,
|
||||||
|
focusTitle,
|
||||||
|
setDetailsContent
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row > :deep(.field) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
482
front_vue/src/components/TaskPanel/TaskPanel.vue
Normal file
482
front_vue/src/components/TaskPanel/TaskPanel.vue
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<SlidePanel
|
||||||
|
:show="show"
|
||||||
|
@close="tryClose"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2>{{ isNew ? 'Новая задача' : 'Редактирование' }}</h2>
|
||||||
|
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||||
|
Создано: {{ formatDate(card.dateCreate) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Вкладки (только для существующих задач) -->
|
||||||
|
<TabsPanel
|
||||||
|
v-if="!isNew"
|
||||||
|
v-model="activeTab"
|
||||||
|
:tabs="tabsConfig"
|
||||||
|
class="header-tabs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<!-- Вкладка редактирования -->
|
||||||
|
<TaskEditTab
|
||||||
|
v-show="activeTab === 'edit' || isNew"
|
||||||
|
ref="editTabRef"
|
||||||
|
:card="card"
|
||||||
|
:departments="departments"
|
||||||
|
:labels="labels"
|
||||||
|
:users="users"
|
||||||
|
@preview-image="openImagePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Вкладка комментариев -->
|
||||||
|
<TaskCommentsTab
|
||||||
|
v-show="activeTab === 'comments'"
|
||||||
|
ref="commentsTabRef"
|
||||||
|
:task-id="card?.id"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
:is-project-admin="isProjectAdmin"
|
||||||
|
:active="activeTab === 'comments'"
|
||||||
|
@comments-loaded="commentsCount = $event"
|
||||||
|
@preview-file="openImagePreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="footer-left">
|
||||||
|
<IconButton
|
||||||
|
v-if="!isNew"
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
title="Удалить"
|
||||||
|
@click="handleDelete"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
v-if="!isNew && canArchive && !isArchived"
|
||||||
|
icon="archive"
|
||||||
|
variant="warning"
|
||||||
|
title="В архив"
|
||||||
|
@click="handleArchive"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
v-if="!isNew && isArchived"
|
||||||
|
icon="archive-restore"
|
||||||
|
variant="warning"
|
||||||
|
title="Из архива"
|
||||||
|
@click="handleRestore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
||||||
|
<button
|
||||||
|
class="btn-save"
|
||||||
|
@click="handleSave"
|
||||||
|
:disabled="!canSave || isSaving"
|
||||||
|
>
|
||||||
|
<span v-if="isSaving" class="btn-loader"></span>
|
||||||
|
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<!-- Диалог несохранённых изменений -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showUnsavedDialog"
|
||||||
|
title="Обнаружены изменения"
|
||||||
|
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
|
||||||
|
confirm-text="Сохранить"
|
||||||
|
:show-discard="true"
|
||||||
|
@confirm="confirmSave"
|
||||||
|
@cancel="cancelClose"
|
||||||
|
@discard="confirmDiscard"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог удаления задачи -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteDialog"
|
||||||
|
title="Удалить задачу?"
|
||||||
|
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
|
||||||
|
confirm-text="Удалить"
|
||||||
|
variant="danger"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="showDeleteDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог архивации задачи -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showArchiveDialog"
|
||||||
|
title="Архивировать задачу?"
|
||||||
|
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
|
||||||
|
confirm-text="В архив"
|
||||||
|
variant="warning"
|
||||||
|
@confirm="confirmArchive"
|
||||||
|
@cancel="showArchiveDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог разархивации задачи -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showRestoreDialog"
|
||||||
|
title="Вернуть из архива?"
|
||||||
|
message="Задача будет возвращена на доску<br>в колонку «Готово»."
|
||||||
|
confirm-text="Вернуть"
|
||||||
|
variant="warning"
|
||||||
|
@confirm="confirmRestore"
|
||||||
|
@cancel="showRestoreDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Модальное окно просмотра изображения -->
|
||||||
|
<ImagePreview
|
||||||
|
:file="previewImage"
|
||||||
|
:get-full-url="getFullUrl"
|
||||||
|
:show-delete="previewImage?.source === 'comment' ? previewImage?.canDelete : true"
|
||||||
|
@close="closeImagePreview"
|
||||||
|
@delete="deleteFromPreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||||||
|
import SlidePanel from '../ui/SlidePanel.vue'
|
||||||
|
import TabsPanel from '../ui/TabsPanel.vue'
|
||||||
|
import IconButton from '../ui/IconButton.vue'
|
||||||
|
import ImagePreview from '../ui/ImagePreview.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import TaskEditTab from './TaskEditTab.vue'
|
||||||
|
import TaskCommentsTab from './TaskCommentsTab.vue'
|
||||||
|
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
card: Object,
|
||||||
|
columnId: [String, Number],
|
||||||
|
doneColumnId: Number,
|
||||||
|
isArchived: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
departments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
isProjectAdmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isNew = ref(true)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const activeTab = ref('edit')
|
||||||
|
const commentsCount = ref(0)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const editTabRef = ref(null)
|
||||||
|
const commentsTabRef = ref(null)
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const showUnsavedDialog = ref(false)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const showArchiveDialog = ref(false)
|
||||||
|
const showRestoreDialog = ref(false)
|
||||||
|
|
||||||
|
// Image preview
|
||||||
|
const previewImage = ref(null)
|
||||||
|
|
||||||
|
// Tabs config
|
||||||
|
const tabsConfig = computed(() => [
|
||||||
|
{ id: 'edit', icon: 'pencil', title: 'Редактирование' },
|
||||||
|
{ id: 'comments', icon: 'message-circle', title: 'Комментарии', badge: commentsCount.value || null }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Can save
|
||||||
|
const canSave = computed(() => {
|
||||||
|
const form = editTabRef.value?.form
|
||||||
|
return form?.title?.trim() && form?.departmentId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Can archive (только если колонка "Готово")
|
||||||
|
const canArchive = computed(() => {
|
||||||
|
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const day = date.getDate()
|
||||||
|
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close handling
|
||||||
|
const tryClose = () => {
|
||||||
|
if (editTabRef.value?.hasChanges) {
|
||||||
|
showUnsavedDialog.value = true
|
||||||
|
} else {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSave = () => {
|
||||||
|
showUnsavedDialog.value = false
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDiscard = () => {
|
||||||
|
showUnsavedDialog.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelClose = () => {
|
||||||
|
showUnsavedDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave.value) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
editTabRef.value.fileError = ''
|
||||||
|
|
||||||
|
if (props.card?.id) {
|
||||||
|
// Upload new files
|
||||||
|
const newFiles = editTabRef.value.getNewFiles()
|
||||||
|
for (const file of newFiles) {
|
||||||
|
const result = await taskImageApi.upload(props.card.id, file.preview, file.name)
|
||||||
|
if (result.success) {
|
||||||
|
file.isNew = false
|
||||||
|
file.name = result.file.name
|
||||||
|
file.preview = result.file.url
|
||||||
|
} else {
|
||||||
|
editTabRef.value.fileError = result.errors?.file || 'Ошибка загрузки файла'
|
||||||
|
isSaving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete marked files
|
||||||
|
const filesToDelete = editTabRef.value.getFilesToDelete()
|
||||||
|
if (filesToDelete.length > 0) {
|
||||||
|
const fileNames = filesToDelete.map(f => f.name)
|
||||||
|
const result = await taskImageApi.delete(props.card.id, fileNames)
|
||||||
|
if (!result.success) {
|
||||||
|
editTabRef.value.fileError = result.errors?.file || 'Ошибка удаления файла'
|
||||||
|
isSaving.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editTabRef.value.removeDeletedFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = editTabRef.value.getFormData()
|
||||||
|
emit('save', {
|
||||||
|
...formData,
|
||||||
|
id: props.card?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const handleDelete = () => {
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
emit('delete', props.card.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive
|
||||||
|
const handleArchive = () => {
|
||||||
|
showArchiveDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmArchive = () => {
|
||||||
|
showArchiveDialog.value = false
|
||||||
|
if (props.card?.id) {
|
||||||
|
emit('archive', props.card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
const handleRestore = () => {
|
||||||
|
showRestoreDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRestore = () => {
|
||||||
|
showRestoreDialog.value = false
|
||||||
|
if (props.card?.id) {
|
||||||
|
emit('restore', props.card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image preview
|
||||||
|
const openImagePreview = (file) => {
|
||||||
|
previewImage.value = file
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeImagePreview = () => {
|
||||||
|
previewImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFromPreview = async () => {
|
||||||
|
if (!previewImage.value) return
|
||||||
|
|
||||||
|
// Удаление файла из комментария
|
||||||
|
if (previewImage.value.source === 'comment') {
|
||||||
|
const { commentId, name } = previewImage.value
|
||||||
|
try {
|
||||||
|
const result = await commentImageApi.delete(commentId, name)
|
||||||
|
if (result.success) {
|
||||||
|
closeImagePreview()
|
||||||
|
// Перезагружаем комментарии
|
||||||
|
commentsTabRef.value?.loadComments()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка удаления файла:', e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файла из задачи (старая логика)
|
||||||
|
const files = editTabRef.value?.attachedFiles || []
|
||||||
|
const index = files.findIndex(
|
||||||
|
f => f.name === previewImage.value.name && f.size === previewImage.value.size
|
||||||
|
)
|
||||||
|
if (index !== -1) {
|
||||||
|
closeImagePreview()
|
||||||
|
// Trigger file remove in edit tab
|
||||||
|
editTabRef.value?.handleFileRemove?.(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch show
|
||||||
|
watch(() => props.show, async (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
isNew.value = !props.card
|
||||||
|
activeTab.value = 'edit'
|
||||||
|
commentsCount.value = props.card?.comments_count || 0
|
||||||
|
previewImage.value = null
|
||||||
|
|
||||||
|
// Reset comments tab
|
||||||
|
commentsTabRef.value?.reset()
|
||||||
|
|
||||||
|
// Load form data
|
||||||
|
await nextTick()
|
||||||
|
editTabRef.value?.loadFromCard(props.card)
|
||||||
|
editTabRef.value?.setDetailsContent(props.card?.details || '')
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
editTabRef.value?.saveInitialForm()
|
||||||
|
editTabRef.value?.focusTitle()
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tabs {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #000;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loader {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||||
|
border-top-color: #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
front_vue/src/components/TaskPanel/index.js
Normal file
8
front_vue/src/components/TaskPanel/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as TaskPanel } from './TaskPanel.vue'
|
||||||
|
export { default as TaskEditTab } from './TaskEditTab.vue'
|
||||||
|
export { default as TaskCommentsTab } from './TaskCommentsTab.vue'
|
||||||
|
export { default as CommentItem } from './CommentItem.vue'
|
||||||
|
export { default as CommentForm } from './CommentForm.vue'
|
||||||
|
|
||||||
|
// Default export
|
||||||
|
export { default } from './TaskPanel.vue'
|
||||||
182
front_vue/src/components/ui/IconButton.vue
Normal file
182
front_vue/src/components/ui/IconButton.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
:class="[`icon-btn--${variant}`, { 'icon-btn--small': small }]"
|
||||||
|
:title="title"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('click', $event)"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="icon-btn-loader"></span>
|
||||||
|
<i v-else :data-lucide="icon"></i>
|
||||||
|
<span v-if="label" class="icon-btn-label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator: (v) => ['default', 'danger', 'warning', 'primary', 'ghost'].includes(v)
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['click'])
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Если есть лейбл — растягиваем кнопку */
|
||||||
|
.icon-btn:has(.icon-btn-label) {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small */
|
||||||
|
.icon-btn--small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--small i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--small:has(.icon-btn-label) {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
.icon-btn--default {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--default:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--danger {
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--danger:hover {
|
||||||
|
background: var(--red);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--warning {
|
||||||
|
border: 1px solid var(--orange);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--warning:hover {
|
||||||
|
background: var(--orange);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--ghost {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--ghost:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled */
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loader */
|
||||||
|
.icon-btn-loader {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
front_vue/src/components/ui/TabsPanel.vue
Normal file
108
front_vue/src/components/ui/TabsPanel.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tabs-panel">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: modelValue === tab.id }"
|
||||||
|
@click="$emit('update:modelValue', tab.id)"
|
||||||
|
:title="tab.title || tab.label"
|
||||||
|
>
|
||||||
|
<i v-if="tab.icon" :data-lucide="tab.icon"></i>
|
||||||
|
<span v-if="tab.label && showLabels" class="tab-label">{{ tab.label }}</span>
|
||||||
|
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
// { id, icon?, label?, title?, badge? }
|
||||||
|
},
|
||||||
|
showLabels: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tabs-panel {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab i,
|
||||||
|
.tab :deep(svg) {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,10 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { serverSettings } from './api'
|
||||||
|
|
||||||
|
// Инициализация серверных настроек (timezone и т.д.)
|
||||||
|
serverSettings.init()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { projectsApi, usersApi } from '../api'
|
import { projectsApi, usersApi, authApi } from '../api'
|
||||||
|
|
||||||
export const useProjectsStore = defineStore('projects', () => {
|
export const useProjectsStore = defineStore('projects', () => {
|
||||||
// ==================== СОСТОЯНИЕ ====================
|
// ==================== СОСТОЯНИЕ ====================
|
||||||
@@ -11,6 +11,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
const users = ref([])
|
const users = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
const currentUser = ref(null) // Текущий авторизованный пользователь
|
||||||
|
|
||||||
// Текущий проект (из localStorage)
|
// Текущий проект (из localStorage)
|
||||||
const savedProjectId = localStorage.getItem('currentProjectId')
|
const savedProjectId = localStorage.getItem('currentProjectId')
|
||||||
@@ -30,6 +31,16 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
return project ? Number(project.id_ready) : null
|
return project ? Number(project.id_ready) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ID текущего пользователя
|
||||||
|
const currentUserId = computed(() => currentUser.value?.id || null)
|
||||||
|
|
||||||
|
// Является ли текущий пользователь админом проекта
|
||||||
|
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
||||||
|
const isProjectAdmin = computed(() => {
|
||||||
|
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||||
|
return project?.id_admin === true
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== ДЕЙСТВИЯ ====================
|
// ==================== ДЕЙСТВИЯ ====================
|
||||||
// Инициализация (загрузка проектов + данных активного)
|
// Инициализация (загрузка проектов + данных активного)
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -74,6 +85,14 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
const usersData = await usersApi.getAll()
|
const usersData = await usersApi.getAll()
|
||||||
if (usersData.success) users.value = usersData.data
|
if (usersData.success) users.value = usersData.data
|
||||||
|
|
||||||
|
// Загружаем текущего пользователя
|
||||||
|
const authData = await authApi.check()
|
||||||
|
if (authData.success && authData.user) {
|
||||||
|
// Находим полные данные пользователя (с id) из списка users
|
||||||
|
const fullUser = users.value.find(u => u.username === authData.user.username)
|
||||||
|
currentUser.value = fullUser || authData.user
|
||||||
|
}
|
||||||
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка инициализации:', error)
|
console.error('Ошибка инициализации:', error)
|
||||||
@@ -108,6 +127,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
columns.value = projectData.data.columns
|
columns.value = projectData.data.columns
|
||||||
departments.value = projectData.data.departments
|
departments.value = projectData.data.departments
|
||||||
labels.value = projectData.data.labels
|
labels.value = projectData.data.labels
|
||||||
|
|
||||||
|
// Обновляем id_admin в списке проектов (сервер возвращает true если текущий пользователь админ)
|
||||||
|
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||||
|
if (project && projectData.data.project?.id_admin === true) {
|
||||||
|
project.id_admin = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки данных проекта:', error)
|
console.error('Ошибка загрузки данных проекта:', error)
|
||||||
@@ -122,6 +147,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
columns.value = []
|
columns.value = []
|
||||||
users.value = []
|
users.value = []
|
||||||
currentProjectId.value = null
|
currentProjectId.value = null
|
||||||
|
currentUser.value = null
|
||||||
initialized.value = false
|
initialized.value = false
|
||||||
localStorage.removeItem('currentProjectId')
|
localStorage.removeItem('currentProjectId')
|
||||||
localStorage.removeItem('currentProjectName')
|
localStorage.removeItem('currentProjectName')
|
||||||
@@ -137,9 +163,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
loading,
|
loading,
|
||||||
initialized,
|
initialized,
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
|
currentUser,
|
||||||
// Геттеры
|
// Геттеры
|
||||||
currentProject,
|
currentProject,
|
||||||
doneColumnId,
|
doneColumnId,
|
||||||
|
currentUserId,
|
||||||
|
isProjectAdmin,
|
||||||
// Действия
|
// Действия
|
||||||
init,
|
init,
|
||||||
selectProject,
|
selectProject,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@
|
|||||||
:departments="store.departments"
|
:departments="store.departments"
|
||||||
:labels="store.labels"
|
:labels="store.labels"
|
||||||
:users="store.users"
|
:users="store.users"
|
||||||
|
:current-user-id="store.currentUserId"
|
||||||
|
:is-project-admin="store.isProjectAdmin"
|
||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@save="handleSaveTask"
|
@save="handleSaveTask"
|
||||||
@delete="handleDeleteTask"
|
@delete="handleDeleteTask"
|
||||||
@@ -105,7 +107,7 @@ import { ref, computed, watch, onMounted } from 'vue'
|
|||||||
import Sidebar from '../components/Sidebar.vue'
|
import Sidebar from '../components/Sidebar.vue'
|
||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import ArchiveCard from '../components/ArchiveCard.vue'
|
import ArchiveCard from '../components/ArchiveCard.vue'
|
||||||
import TaskPanel from '../components/TaskPanel.vue'
|
import TaskPanel from '../components/TaskPanel'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
@@ -156,6 +158,7 @@ const fetchCards = async () => {
|
|||||||
dateClosed: card.date_closed,
|
dateClosed: card.date_closed,
|
||||||
columnId: card.column_id,
|
columnId: card.column_id,
|
||||||
order: card.order ?? 0,
|
order: card.order ?? 0,
|
||||||
|
comments_count: card.comments_count || 0,
|
||||||
files: card.files || (card.file_img || []).map(f => ({
|
files: card.files || (card.file_img || []).map(f => ({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
url: f.url,
|
url: f.url,
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
:departments="store.departments"
|
:departments="store.departments"
|
||||||
:labels="store.labels"
|
:labels="store.labels"
|
||||||
:users="store.users"
|
:users="store.users"
|
||||||
|
:current-user-id="store.currentUserId"
|
||||||
|
:is-project-admin="store.isProjectAdmin"
|
||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@save="handleSaveTask"
|
@save="handleSaveTask"
|
||||||
@delete="handleDeleteTask"
|
@delete="handleDeleteTask"
|
||||||
@@ -92,7 +94,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
|
|||||||
import Sidebar from '../components/Sidebar.vue'
|
import Sidebar from '../components/Sidebar.vue'
|
||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import Board from '../components/Board.vue'
|
import Board from '../components/Board.vue'
|
||||||
import TaskPanel from '../components/TaskPanel.vue'
|
import TaskPanel from '../components/TaskPanel'
|
||||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { cardsApi } from '../api'
|
import { cardsApi } from '../api'
|
||||||
|
|||||||
Reference in New Issue
Block a user