1
0

Обновление бека+фронт

MVP версия - которая уже готова к работе
This commit is contained in:
2026-01-12 01:11:32 +07:00
parent a9c146b192
commit 456876f837
16 changed files with 894 additions and 75 deletions

112
backend/api/task.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST') {
$data = RestApi::getInput();
$action = $data['action'] ?? null;
$task = new Task();
// Получение колонок
if ($action === 'get_columns') {
$result = $task->getColumns();
RestApi::response(['success' => true, 'data' => $result]);
}
// Получение департаментов
if ($action === 'get_departments') {
$result = $task->getDepartments();
RestApi::response(['success' => true, 'data' => $result]);
}
// Получение меток
if ($action === 'get_labels') {
$result = $task->getLabels();
RestApi::response(['success' => true, 'data' => $result]);
}
// Загрузка изображения
if ($action === 'upload_image') {
$task_id = $data['task_id'] ?? null;
$file_base64 = $data['file_data'] ?? null;
$file_name = $data['file_name'] ?? null;
$result = TaskImage::upload($task_id, $file_base64, $file_name);
RestApi::response($result);
}
// Удаление изображений (принимает file_names массив или file_name строку)
if ($action === 'delete_image') {
$task_id = $data['task_id'] ?? null;
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
$result = TaskImage::delete($task_id, $file_names);
RestApi::response($result);
}
// Изменение порядка и колонки задачи
if ($action === 'update_order') {
$id = $data['id'] ?? null;
$column_id = $data['column_id'] ?? null;
$to_index = $data['to_index'] ?? 0;
$result = Task::updateOrder($id, $column_id, $to_index);
RestApi::response($result);
}
// Обновление задачи
if ($action === 'update') {
$task->id = $data['id'] ?? null;
$task->id_department = $data['id_department'] ?? null;
$task->id_label = $data['id_label'] ?? null;
$task->id_account = $data['id_account'] ?? null;
$task->column_id = $data['column_id'] ?? null;
$task->order = $data['order'] ?? null;
$task->date = $data['date'] ?? null;
$task->title = $data['title'] ?? '';
$task->descript = $data['descript'] ?? '';
$task->descript_full = $data['descript_full'] ?? '';
$result = $task->update();
RestApi::response($result);
}
// Создание задачи
if ($action === 'create') {
$task->id_department = $data['id_department'] ?? null;
$task->id_label = $data['id_label'] ?? null;
$task->id_account = $data['id_account'] ?? null;
$task->column_id = $data['column_id'] ?? null;
$task->order = $data['order'] ?? 0;
$task->date = $data['date'] ?? null;
$task->title = $data['title'] ?? '';
$task->descript = $data['descript'] ?? '';
$task->descript_full = $data['descript_full'] ?? '';
$files = $data['files'] ?? [];
$result = $task->create($files);
RestApi::response($result);
}
// Удаление задачи
if ($action === 'delete') {
$id = $data['id'] ?? null;
$result = Task::delete($id);
RestApi::response($result);
}
// Метод не указан
if (!$action) {
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
}
}
if ($method === 'GET') {
// Получение всех задач
$task = new Task();
$tasks = $task->getAll();
RestApi::response(['success' => true, 'data' => $tasks]);
}
?>

View File

@@ -0,0 +1,248 @@
<?php
class Task extends BaseEntity {
protected $db_name = 'cards_task';
// Свойства задачи
public $id;
public $id_department;
public $id_label;
public $order;
public $column_id;
public $date;
public $id_account;
public $title;
public $descript;
public $descript_full;
// Валидация данных
protected function validate() {
static::$error_message = [];
if (!$this->id) {
$this->addError('id', 'ID задачи не указан');
}
if (!$this->title) {
$this->addError('title', 'Название не может быть пустым');
}
return $this->getErrors();
}
// Валидация данных (для create)
protected function validateCreate() {
static::$error_message = [];
if (!$this->title) {
$this->addError('title', 'Название не может быть пустым');
}
if (!$this->column_id) {
$this->addError('column_id', 'Колонка не указана');
}
if (!$this->id_department) {
$this->addError('id_department', 'Департамент не указан');
}
return $this->getErrors();
}
// Создание задачи (с файлами)
public function create($files = []) {
// Валидация
if ($errors = $this->validateCreate()) {
return $errors;
}
// Вставляем в базу
Database::insert($this->db_name, [
'id_department' => $this->id_department,
'id_label' => $this->id_label,
'order' => $this->order ?? 0,
'column_id' => $this->column_id,
'date' => $this->date ?: null,
'id_account' => $this->id_account,
'title' => $this->title,
'descript' => $this->descript ?: null,
'descript_full' => $this->descript_full ?: null,
'date_create' => date('Y-m-d H:i:s'),
'file_img' => '[]'
]);
// Получаем ID созданной задачи
$this->id = Database::id();
// Загружаем файлы если есть
$uploaded_files = [];
if (!empty($files)) {
foreach ($files as $file) {
$result = TaskImage::upload($this->id, $file['data'], $file['name']);
if ($result['success']) {
$uploaded_files[] = $result['file'];
}
}
}
return [
'success' => true,
'id' => $this->id,
'files' => $uploaded_files
];
}
// Обновление задачи
public function update() {
// Валидация
if ($errors = $this->validate()) {
return $errors;
}
// Проверка что задача существует
$task = Database::get($this->db_name, ['id'], ['id' => $this->id]);
if (!$task) {
$this->addError('task', 'Задача не найдена');
return $this->getErrors();
}
// Обновляем в БД
Database::update($this->db_name, [
'id_department' => $this->id_department,
'id_label' => $this->id_label,
'order' => $this->order,
'column_id' => $this->column_id,
'date' => $this->date ?: null,
'id_account' => $this->id_account,
'title' => $this->title,
'descript' => $this->descript ?: null,
'descript_full' => $this->descript_full ?: null
], [
'id' => $this->id
]);
return ['success' => true];
}
// Удаление задачи
public static function delete($id) {
// Проверка что задача существует
self::check_task($id);
// Удаляем папку с файлами если есть
$upload_dir = __DIR__ . '/../../../public/img/task/' . $id;
if (is_dir($upload_dir)) {
$files = glob($upload_dir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
rmdir($upload_dir);
}
// Удаляем из базы
Database::delete('cards_task', ['id' => $id]);
return ['success' => true];
}
// Изменение порядка и колонки задачи (с пересчётом order)
public static function updateOrder($id, $column_id, $to_index) {
// Проверка что задача существует
self::check_task($id);
// Получаем все карточки целевой колонки (кроме перемещаемой)
$cards = Database::select('cards_task', ['id', 'order'], [
'column_id' => $column_id,
'id[!]' => $id,
'ORDER' => ['order' => 'ASC']
]) ?? [];
// Вставляем перемещаемую карточку в нужную позицию
array_splice($cards, $to_index, 0, [['id' => $id]]);
// Пересчитываем order для всех карточек
foreach ($cards as $index => $card) {
Database::update('cards_task', [
'order' => $index,
'column_id' => $column_id
], [
'id' => $card['id']
]);
}
return ['success' => true];
}
// Получение всех задач
public function getAll() {
$tasks = Database::select($this->db_name, [
'id',
'id_department',
'id_label',
'id_account',
'order',
'column_id',
'date',
'date_create',
'file_img',
'title',
'descript',
'descript_full'
]);
// Декодируем JSON и получаем avatar_url из accounts
return array_map(function($task) {
$task['file_img'] = json_decode($task['file_img'], true) ?? [];
// Получаем avatar_url из accounts по id_account
if ($task['id_account']) {
$account = Database::get('accounts', ['avatar_url'], ['id' => $task['id_account']]);
$task['avatar_img'] = $account['avatar_url'] ?? null;
} else {
$task['avatar_img'] = null;
}
return $task;
}, $tasks);
}
// Получение всех колонок
public function getColumns() {
return Database::select('columns', [
'id',
'name_columns',
'color'
]);
}
// Получение всех департаментов
public function getDepartments() {
return Database::select('departments', [
'id',
'name_departments',
'color'
]);
}
// Получение всех меток
public function getLabels() {
return Database::select('labels', [
'id',
'name_labels',
'icon',
'color'
]);
}
// Проверка и получение задачи (при ошибке — сразу ответ и exit)
public static function check_task($task_id) {
$task = Database::get('cards_task', '*', ['id' => $task_id]);
if (!$task_id || !$task) {
RestApi::response(['success' => false, 'errors' => ['task' => 'Задача не найдена']], 400);
}
return $task;
}
}
?>

View File

@@ -0,0 +1,163 @@
<?php
class TaskImage {
protected static $db_name = 'cards_task';
protected static $upload_path = '/public/img/task/';
// Валидация всех данных для загрузки
protected static function validate($task_id, $file_base64, $file_name) {
// Проверка и получение задачи
$task = Task::check_task($task_id);
// Декодируем base64
$file_data = base64_decode(preg_replace('/^data:image\/\w+;base64,/', '', $file_base64));
if (!$file_data) {
return ['success' => false, 'errors' => ['file' => 'Ошибка декодирования файла']];
}
// Проверка расширения
$allowed_ext = ['png', 'jpg', 'jpeg'];
$ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_ext)) {
return ['success' => false, 'errors' => ['file' => 'Разрешены только PNG, JPG, JPEG']];
}
// Проверка размера (10 МБ)
$max_size = 10 * 1024 * 1024;
if (strlen($file_data) > $max_size) {
return ['success' => false, 'errors' => ['file' => 'Файл слишком большой. Максимум 10 МБ']];
}
// Проверка MIME типа
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($file_data);
$allowed_mime = ['image/png', 'image/jpeg'];
if (!in_array($mime, $allowed_mime)) {
return ['success' => false, 'errors' => ['file' => 'Недопустимый тип файла']];
}
// Всё ок — возвращаем данные
return [
'task' => $task,
'file_data' => $file_data
];
}
// Генерация уникального имени файла
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/img/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 = 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 = json_decode($task['file_img'], true) ?? [];
$upload_dir = __DIR__ . '/../../../public/img/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];
}
}
?>

View File

@@ -58,6 +58,7 @@ class Account extends BaseEntity {
// Получение всех пользователей // Получение всех пользователей
public function getAll() { public function getAll() {
return Database::select($this->db_name, [ return Database::select($this->db_name, [
'id',
'id_department', 'id_department',
'name', 'name',
'username', 'username',

View File

@@ -12,6 +12,8 @@
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_user.php'; require_once __DIR__ . '/class/enity/class_user.php';
require_once __DIR__ . '/class/enity/class_task.php';
require_once __DIR__ . '/class/enity/class_taskImage.php';
// Данные подключения к БД // Данные подключения к БД
define('DB_HOST', '192.168.1.9'); define('DB_HOST', '192.168.1.9');
@@ -26,6 +28,9 @@
$routes = [ $routes = [
'/api/user' => 'api/user.php', '/api/user' => 'api/user.php',
'/api/task' => 'api/task.php',
]; ];
$publicActions = ['auth_login', 'check_session'];
?> ?>

View File

@@ -1,5 +1,34 @@
<?php <?php
// Игнорирование статических файлов
function ignore_favicon() {
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (preg_match('/\.(ico|png|jpg|jpeg|gif|css|js|svg|woff|woff2|ttf|eot)$/i', $requestUri)) {
http_response_code(404);
exit;
}
}
// Проверка авторизации для API
function check_ApiAuth($publicActions = []) {
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($requestUri, '/api/') !== false) {
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? null;
// Публичные действия — без авторизации
if (!in_array($action, $publicActions)) {
$account = new Account();
$result = $account->check_session($_COOKIE['session'] ?? null);
if (!$result['success']) {
RestApi::response($result, 403);
}
}
}
}
// Функция роутинга API // Функция роутинга API
function handleRouting($routes = []) { function handleRouting($routes = []) {

View File

@@ -15,14 +15,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit; exit;
} }
// Игнорируем запросы к favicon.ico и другим статическим файлам ignore_favicon();
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (preg_match('/\.(ico|png|jpg|jpeg|gif|css|js|svg|woff|woff2|ttf|eot)$/i', $requestUri)) {
http_response_code(404);
exit;
}
check_ApiAuth($publicActions);
handleRouting($routes); handleRouting($routes);
?> ?>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -31,37 +31,77 @@ export const authApi = {
// ==================== DEPARTMENTS ==================== // ==================== DEPARTMENTS ====================
export const departmentsApi = { export const departmentsApi = {
getAll: () => request('/departments', { credentials: 'include' }) getAll: () => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_departments' })
})
} }
// ==================== LABELS ==================== // ==================== LABELS ====================
export const labelsApi = { export const labelsApi = {
getAll: () => request('/labels', { credentials: 'include' }) getAll: () => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_labels' })
})
} }
// ==================== COLUMNS ==================== // ==================== COLUMNS ====================
export const columnsApi = { export const columnsApi = {
getAll: () => request('/columns', { credentials: 'include' }) getAll: () => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_columns' })
})
} }
// ==================== CARDS ==================== // ==================== CARDS ====================
export const cardsApi = { export const cardsApi = {
getAll: () => request('/cards', { credentials: 'include' }), getAll: () => request('/api/task', { credentials: 'include' }),
create: (data) => request('/cards', { updateOrder: (id, column_id, to_index) => request('/api/task', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify({ action: 'update_order', id, column_id, to_index })
}), }),
update: (id, data) => request(`/cards/${id}`, { create: (data) => request('/api/task', {
method: 'PUT', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify({ action: 'create', ...data })
}), }),
delete: (id) => request(`/cards/${id}`, { update: (data) => request('/api/task', {
method: 'DELETE', method: 'POST',
credentials: 'include' credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', ...data })
}),
delete: (id) => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id })
})
}
// ==================== TASK IMAGES ====================
export const taskImageApi = {
upload: (task_id, file_data, file_name) => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upload_image', task_id, file_data, file_name })
}),
// Принимает строку (один файл) или массив (несколько файлов)
delete: (task_id, file_names) => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_image', task_id, file_names })
}) })
} }

View File

@@ -18,6 +18,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUpdated, watch } from 'vue' import { ref, computed, onMounted, onUpdated, watch } from 'vue'
import Column from './Column.vue' import Column from './Column.vue'
import { cardsApi } from '../api'
const props = defineProps({ const props = defineProps({
activeDepartment: Number, activeDepartment: Number,
@@ -68,18 +69,19 @@ watch(() => props.cards, (newCards) => {
const columnsWithCards = computed(() => { const columnsWithCards = computed(() => {
return props.columns.map(col => ({ return props.columns.map(col => ({
id: col.id, id: col.id,
title: col.name, title: col.name_columns,
color: col.color, color: col.color,
cards: localCards.value cards: localCards.value
.filter(card => card.column_id === col.id) .filter(card => card.column_id === col.id)
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map(card => ({ .map(card => ({
id: card.id_card, id: card.id,
title: card.title, title: card.title,
description: card.descript, description: card.descript,
details: card.descript_full, details: card.descript_full,
departmentId: card.id_department, departmentId: card.id_department,
labelId: card.id_label, labelId: card.id_label,
accountId: card.id_account,
assignee: card.avatar_img, assignee: card.avatar_img,
dueDate: card.date, dueDate: card.date,
dateCreate: card.date_create, dateCreate: card.date_create,
@@ -126,35 +128,36 @@ watch([filteredTotalTasks, inProgressTasks, completedTasks], () => {
}) })
}, { immediate: true }) }, { immediate: true })
const handleDropCard = ({ cardId, fromColumnId, toColumnId, toIndex }) => { const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) => {
const card = localCards.value.find(c => c.id_card === cardId) const card = localCards.value.find(c => c.id === cardId)
if (!card) return if (!card) return
// Меняем колонку // Локально обновляем для мгновенного отклика
card.column_id = toColumnId card.column_id = toColumnId
// Получаем карточки целевой колонки (без перемещаемой) // Получаем карточки целевой колонки (без перемещаемой)
const columnCards = localCards.value const columnCards = localCards.value
.filter(c => c.column_id === toColumnId && c.id_card !== cardId) .filter(c => c.column_id === toColumnId && c.id !== cardId)
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
// Вставляем карточку в нужную позицию и пересчитываем order // Вставляем карточку в нужную позицию и пересчитываем order локально
columnCards.splice(toIndex, 0, card) columnCards.splice(toIndex, 0, card)
columnCards.forEach((c, idx) => { columnCards.forEach((c, idx) => {
c.order = idx c.order = idx
}) })
// TODO: отправить изменение на сервер // Отправляем на сервер (сервер сам пересчитает order для всех)
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
} }
// Генератор id для новых карточек // Генератор id для новых карточек
let nextCardId = 100 let nextCardId = 100
// Методы для модалки // Методы для модалки
const saveTask = (taskData, columnId) => { const saveTask = async (taskData, columnId) => {
if (taskData.id) { if (taskData.id) {
// Редактирование существующей карточки // Редактирование существующей карточки
const card = localCards.value.find(c => c.id_card === taskData.id) const card = localCards.value.find(c => c.id === taskData.id)
if (card) { if (card) {
card.title = taskData.title card.title = taskData.title
card.descript = taskData.description card.descript = taskData.description
@@ -162,8 +165,23 @@ const saveTask = (taskData, columnId) => {
card.id_department = taskData.departmentId card.id_department = taskData.departmentId
card.id_label = taskData.labelId card.id_label = taskData.labelId
card.date = taskData.dueDate card.date = taskData.dueDate
card.id_account = taskData.accountId
card.avatar_img = taskData.assignee card.avatar_img = taskData.assignee
card.files = taskData.files || [] card.files = taskData.files || []
// Отправляем на сервер
await cardsApi.update({
id: taskData.id,
id_department: taskData.departmentId,
id_label: taskData.labelId,
id_account: taskData.accountId,
column_id: card.column_id,
order: card.order,
date: taskData.dueDate,
title: taskData.title,
descript: taskData.description,
descript_full: taskData.details
})
} }
} else { } else {
// Создание новой карточки (в конец колонки) // Создание новой карточки (в конец колонки)
@@ -172,32 +190,52 @@ const saveTask = (taskData, columnId) => {
? Math.max(...columnCards.map(c => c.order)) + 1 ? Math.max(...columnCards.map(c => c.order)) + 1
: 0 : 0
localCards.value.push({ // Отправляем на сервер
id_card: nextCardId++, const result = await cardsApi.create({
id_department: taskData.departmentId, id_department: taskData.departmentId,
id_label: taskData.labelId, id_label: taskData.labelId,
id_account: taskData.accountId,
column_id: columnId,
order: maxOrder,
date: taskData.dueDate,
title: taskData.title, title: taskData.title,
descript: taskData.description, descript: taskData.description,
descript_full: taskData.details, descript_full: taskData.details,
avatar_img: taskData.assignee,
column_id: columnId,
date: taskData.dueDate,
date_create: new Date().toISOString().split('T')[0],
order: maxOrder,
files: taskData.files || [] files: taskData.files || []
}) })
}
// TODO: отправить на сервер if (result.success) {
// Добавляем локально с ID от сервера
localCards.value.push({
id: parseInt(result.id),
id_department: taskData.departmentId,
id_label: taskData.labelId,
id_account: taskData.accountId,
title: taskData.title,
descript: taskData.description,
descript_full: taskData.details,
avatar_img: taskData.assignee,
column_id: columnId,
date: taskData.dueDate,
date_create: new Date().toISOString().split('T')[0],
order: maxOrder,
files: result.files || []
})
}
}
} }
const deleteTask = (cardId, columnId) => { const deleteTask = async (cardId, columnId) => {
const index = localCards.value.findIndex(c => c.id_card === cardId) // Удаляем на сервере
if (index !== -1) { const result = await cardsApi.delete(cardId)
localCards.value.splice(index, 1)
}
// TODO: отправить на сервер if (result.success) {
// Удаляем локально
const index = localCards.value.findIndex(c => c.id === cardId)
if (index !== -1) {
localCards.value.splice(index, 1)
}
}
} }
defineExpose({ saveTask, deleteTask }) defineExpose({ saveTask, deleteTask })

View File

@@ -15,7 +15,7 @@
class="tag" class="tag"
:style="{ color: cardDepartment.color }" :style="{ color: cardDepartment.color }"
> >
{{ cardDepartment.name }} {{ cardDepartment.name_departments }}
</span> </span>
</div> </div>
<div class="header-right"> <div class="header-right">

View File

@@ -60,7 +60,7 @@
:style="{ '--tag-color': dept.color }" :style="{ '--tag-color': dept.color }"
@click="selectDepartment(dept.id)" @click="selectDepartment(dept.id)"
> >
{{ dept.name }} {{ dept.name_departments }}
</button> </button>
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@
@click="selectLabel(label.id)" @click="selectLabel(label.id)"
> >
<span class="priority-icon">{{ label.icon }}</span> <span class="priority-icon">{{ label.icon }}</span>
{{ label.name }} {{ label.name_labels }}
</button> </button>
</div> </div>
</div> </div>
@@ -152,7 +152,7 @@
<!-- Пустая зона drag & drop (когда нет файлов) --> <!-- Пустая зона drag & drop (когда нет файлов) -->
<div <div
v-if="attachedFiles.length === 0" v-if="visibleFiles.length === 0"
class="file-dropzone" class="file-dropzone"
:class="{ 'dragover': isDragging }" :class="{ 'dragover': isDragging }"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@@ -179,6 +179,7 @@
v-for="(file, index) in attachedFiles" v-for="(file, index) in attachedFiles"
:key="file.name + '-' + file.size" :key="file.name + '-' + file.size"
class="file-preview-item" class="file-preview-item"
v-show="!file.toDelete"
> >
<div class="file-actions"> <div class="file-actions">
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать"> <button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
@@ -189,7 +190,7 @@
</button> </button>
</div> </div>
<div class="file-thumbnail" @click="openImagePreview(file)"> <div class="file-thumbnail" @click="openImagePreview(file)">
<img :src="file.preview" :alt="file.name"> <img :src="getFullUrl(file.preview)" :alt="file.name">
</div> </div>
</div> </div>
@@ -216,8 +217,9 @@
</button> </button>
<div class="footer-right"> <div class="footer-right">
<button class="btn-cancel" @click="tryClose">Отмена</button> <button class="btn-cancel" @click="tryClose">Отмена</button>
<button class="btn-save" @click="handleSave" :disabled="!form.title.trim()"> <button class="btn-save" @click="handleSave" :disabled="!form.title.trim() || !form.departmentId || isSaving">
{{ isNew ? 'Создать' : 'Сохранить' }} <span v-if="isSaving" class="btn-loader"></span>
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -272,7 +274,7 @@
<i data-lucide="x"></i> <i data-lucide="x"></i>
</button> </button>
</div> </div>
<img :src="previewImage.preview" :alt="previewImage.name" class="image-preview-full"> <img :src="getFullUrl(previewImage.preview)" :alt="previewImage.name" class="image-preview-full">
<div class="image-preview-info"> <div class="image-preview-info">
<span class="preview-name">{{ previewImage.name }}</span> <span class="preview-name">{{ previewImage.name }}</span>
<span class="preview-size">{{ formatFileSize(previewImage.size) }}</span> <span class="preview-size">{{ formatFileSize(previewImage.size) }}</span>
@@ -288,7 +290,7 @@
import { ref, reactive, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue' import { ref, reactive, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
import DatePicker from './DatePicker.vue' import DatePicker from './DatePicker.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import { usersApi } from '../api' import { usersApi, taskImageApi } from '../api'
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
@@ -309,6 +311,7 @@ const emit = defineEmits(['close', 'save', 'delete'])
const isNew = ref(true) const isNew = ref(true)
const users = ref([]) const users = ref([])
const usersLoading = ref(false) const usersLoading = ref(false)
const isSaving = ref(false)
const form = reactive({ const form = reactive({
title: '', title: '',
@@ -334,6 +337,19 @@ const isDragging = ref(false)
const fileError = ref('') const fileError = ref('')
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'] const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg']
const maxFileSize = 10 * 1024 * 1024 // 10 MB const maxFileSize = 10 * 1024 * 1024 // 10 MB
const API_BASE = window.APP_CONFIG?.API_BASE || ''
// Формирование полного URL (добавляет домен к относительным путям)
const getFullUrl = (url) => {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url
}
return API_BASE + url
}
// Видимые файлы (без помеченных на удаление)
const visibleFiles = computed(() => attachedFiles.value.filter(f => !f.toDelete))
// Просмотр изображения // Просмотр изображения
const previewImage = ref(null) const previewImage = ref(null)
@@ -536,8 +552,8 @@ watch(() => props.show, async (newVal) => {
form.departmentId = props.card.departmentId || null form.departmentId = props.card.departmentId || null
form.labelId = props.card.labelId || null form.labelId = props.card.labelId || null
form.dueDate = props.card.dueDate || '' form.dueDate = props.card.dueDate || ''
// Находим userId по avatar_url // Используем accountId напрямую
form.userId = findUserIdByAvatar(props.card.assignee) form.userId = props.card.accountId || null
// Загружаем существующие файлы если есть // Загружаем существующие файлы если есть
if (props.card.files && props.card.files.length > 0) { if (props.card.files && props.card.files.length > 0) {
@@ -545,7 +561,8 @@ watch(() => props.show, async (newVal) => {
name: f.name, name: f.name,
size: f.size, size: f.size,
type: f.type, type: f.type,
preview: f.data || f.url preview: f.data || f.url,
isNew: false // Файл уже на сервере
})) }))
} }
} else { } else {
@@ -562,8 +579,45 @@ watch(() => props.show, async (newVal) => {
} }
}) })
const handleSave = () => { const handleSave = async () => {
if (!form.title.trim()) return 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', { emit('save', {
title: form.title, title: form.title,
description: form.description, description: form.description,
@@ -571,15 +625,20 @@ const handleSave = () => {
departmentId: form.departmentId, departmentId: form.departmentId,
labelId: form.labelId, labelId: form.labelId,
dueDate: form.dueDate, dueDate: form.dueDate,
accountId: form.userId,
assignee: getAvatarByUserId(form.userId), assignee: getAvatarByUserId(form.userId),
id: props.card?.id, id: props.card?.id,
files: attachedFiles.value.map(f => ({ files: attachedFiles.value
name: f.name, .filter(f => !f.toDelete)
size: f.size, .map(f => ({
type: f.type, name: f.name,
data: f.preview size: f.size,
})) type: f.type,
data: f.preview
}))
}) })
isSaving.value = false
} }
const handleDelete = () => { const handleDelete = () => {
@@ -653,7 +712,8 @@ const processFiles = (files) => {
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,
preview: e.target.result preview: e.target.result,
isNew: true // Флаг что файл новый и не загружен на сервер
}) })
await nextTick() await nextTick()
refreshIcons() refreshIcons()
@@ -669,7 +729,15 @@ const removeFile = (index) => {
const confirmDeleteFile = () => { const confirmDeleteFile = () => {
if (fileToDeleteIndex.value !== null) { if (fileToDeleteIndex.value !== null) {
attachedFiles.value.splice(fileToDeleteIndex.value, 1) const file = attachedFiles.value[fileToDeleteIndex.value]
if (file.isNew) {
// Новый файл — просто удаляем из массива
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
} else {
// Существующий файл — помечаем для удаления при сохранении
file.toDelete = true
}
} }
showDeleteFileDialog.value = false showDeleteFileDialog.value = false
fileToDeleteIndex.value = null fileToDeleteIndex.value = null
@@ -709,6 +777,7 @@ const deleteFromPreview = () => {
} }
const downloadFile = async (file) => { const downloadFile = async (file) => {
const url = getFullUrl(file.preview)
try { try {
// Если это data URL (base64) - скачиваем напрямую // Если это data URL (base64) - скачиваем напрямую
if (file.preview.startsWith('data:')) { if (file.preview.startsWith('data:')) {
@@ -720,21 +789,21 @@ const downloadFile = async (file) => {
document.body.removeChild(link) document.body.removeChild(link)
} else { } else {
// Для внешних URL - загружаем как blob // Для внешних URL - загружаем как blob
const response = await fetch(file.preview) const response = await fetch(url)
const blob = await response.blob() const blob = await response.blob()
const url = window.URL.createObjectURL(blob) const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = blobUrl
link.download = file.name link.download = file.name
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(blobUrl)
} }
} catch (error) { } catch (error) {
console.error('Ошибка скачивания:', error) console.error('Ошибка скачивания:', error)
// Fallback - открыть в новой вкладке // Fallback - открыть в новой вкладке
window.open(file.preview, '_blank') window.open(url, '_blank')
} }
} }
@@ -1229,6 +1298,21 @@ onUpdated(refreshIcons)
cursor: not-allowed; 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);
}
}
/* Transition */ /* Transition */
.panel-enter-active, .panel-enter-active,
.panel-leave-active { .panel-leave-active {

View File

@@ -23,7 +23,7 @@
:class="{ active: activeDepartment === dept.id }" :class="{ active: activeDepartment === dept.id }"
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id" @click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
> >
{{ dept.name }} {{ dept.name_departments }}
</button> </button>
</div> </div>
</template> </template>
@@ -146,14 +146,14 @@ const closePanel = () => {
} }
// Сохранить задачу через Board компонент // Сохранить задачу через Board компонент
const handleSaveTask = (taskData) => { const handleSaveTask = async (taskData) => {
boardRef.value?.saveTask(taskData, editingColumnId.value) await boardRef.value?.saveTask(taskData, editingColumnId.value)
closePanel() closePanel()
} }
// Удалить задачу через Board компонент // Удалить задачу через Board компонент
const handleDeleteTask = (cardId) => { const handleDeleteTask = async (cardId) => {
boardRef.value?.deleteTask(cardId, editingColumnId.value) await boardRef.value?.deleteTask(cardId, editingColumnId.value)
closePanel() closePanel()
} }

104
taskboard.sql Normal file
View File

@@ -0,0 +1,104 @@
/*
Navicat Premium Dump SQL
Source Server : MySQL_Home
Source Server Type : MySQL
Source Server Version : 90200 (9.2.0)
Source Host : 192.168.1.9:3306
Source Schema : taskboard
Target Server Type : MySQL
Target Server Version : 90200 (9.2.0)
File Encoding : 65001
Date: 12/01/2026 01:11:18
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for accounts
-- ----------------------------
DROP TABLE IF EXISTS `accounts`;
CREATE TABLE `accounts` (
`id` int NOT NULL AUTO_INCREMENT,
`id_department` int NULL DEFAULT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`telegram` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for accounts_session
-- ----------------------------
DROP TABLE IF EXISTS `accounts_session`;
CREATE TABLE `accounts_session` (
`id` int NOT NULL AUTO_INCREMENT,
`id_accounts` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`keycookies` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`data_create` datetime NULL DEFAULT NULL,
`data_closed` datetime NULL DEFAULT NULL,
`ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 19 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for cards_task
-- ----------------------------
DROP TABLE IF EXISTS `cards_task`;
CREATE TABLE `cards_task` (
`id` int NOT NULL AUTO_INCREMENT,
`id_department` int NULL DEFAULT NULL,
`id_label` int NULL DEFAULT NULL,
`id_account` int NULL DEFAULT NULL,
`order` int NULL DEFAULT NULL,
`column_id` int NULL DEFAULT NULL,
`date` datetime NULL DEFAULT NULL,
`date_create` datetime NULL DEFAULT NULL,
`file_img` json NULL,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`descript` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`descript_full` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for columns
-- ----------------------------
DROP TABLE IF EXISTS `columns`;
CREATE TABLE `columns` (
`id` int NOT NULL AUTO_INCREMENT,
`name_columns` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`color` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for departments
-- ----------------------------
DROP TABLE IF EXISTS `departments`;
CREATE TABLE `departments` (
`id` int NOT NULL AUTO_INCREMENT,
`name_departments` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for labels
-- ----------------------------
DROP TABLE IF EXISTS `labels`;
CREATE TABLE `labels` (
`id` int NOT NULL AUTO_INCREMENT,
`name_labels` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`icon` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;