Инициализация проекта
Загрузка проекта на GIT
This commit is contained in:
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Taskboard
|
||||||
|
|
||||||
|
Kanban-доска для управления задачами команды.
|
||||||
|
|
||||||
|
- 📋 Kanban-доска с колонками и карточками задач
|
||||||
|
- 👥 Страница команды с профилями участников
|
||||||
|
- 🏷️ Метки и департаменты для категоризации задач
|
||||||
|
- 📎 Прикрепление изображений к задачам
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- Vue 3
|
||||||
|
- PHP
|
||||||
|
- MySQL
|
||||||
40
backend/app/class/database/class_Database.php
Normal file
40
backend/app/class/database/class_Database.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Medoo\Medoo;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
public static $db;
|
||||||
|
|
||||||
|
// Магический метод для вызова методов класса Medoo
|
||||||
|
public static function __callStatic($name, $arguments) {
|
||||||
|
if (!self::$db) {
|
||||||
|
self::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return call_user_func_array([self::$db, $name], $arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация подключения к БД
|
||||||
|
public static function init() {
|
||||||
|
if (!self::$db) {
|
||||||
|
try {
|
||||||
|
self::$db = new Medoo([
|
||||||
|
'type' => 'mysql',
|
||||||
|
'host' => DB_HOST,
|
||||||
|
'database' => DB_NAME,
|
||||||
|
'username' => DB_USER,
|
||||||
|
'password' => DB_PASS,
|
||||||
|
'port' => DB_PORT,
|
||||||
|
'charset' => DB_CHARSET,
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'error' => \PDO::ERRMODE_EXCEPTION
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
die('Ошибка подключения к БД: ' . $e->getMessage());
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$db;
|
||||||
|
}
|
||||||
|
}
|
||||||
2311
backend/app/class/database/class_Medoo.php
Normal file
2311
backend/app/class/database/class_Medoo.php
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/app/config.php
Normal file
19
backend/app/config.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Подключение классов базы данных
|
||||||
|
require_once __DIR__ . '/class/database/class_Medoo.php';
|
||||||
|
require_once __DIR__ . '/class/database/class_Database.php';
|
||||||
|
|
||||||
|
// Подключение классов Функций
|
||||||
|
require_once __DIR__ . '/functions/function.php';
|
||||||
|
require_once __DIR__ . '/functions/routing.php';
|
||||||
|
|
||||||
|
// Данные подключения к БД
|
||||||
|
define('DB_HOST', '192.168.1.9');
|
||||||
|
define('DB_USER', 'root');
|
||||||
|
define('DB_PASS', 'root');
|
||||||
|
define('DB_NAME', 'taskboard');
|
||||||
|
define('DB_PORT', 3306);
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
?>
|
||||||
4
backend/app/functions/function.php
Normal file
4
backend/app/functions/function.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
?>
|
||||||
19
backend/app/functions/routing.php
Normal file
19
backend/app/functions/routing.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Функция роутинга API
|
||||||
|
function handleRouting($routes = []) {
|
||||||
|
|
||||||
|
$request = $_SERVER['REQUEST_URI'];
|
||||||
|
$path = parse_url($request, PHP_URL_PATH);
|
||||||
|
|
||||||
|
if (isset($routes[$path])) {
|
||||||
|
|
||||||
|
$file_path = $routes[$path];
|
||||||
|
global $_POST, $_FILES, $_SERVER, $_GET;
|
||||||
|
include $file_path;
|
||||||
|
exit;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
16
backend/index.php
Normal file
16
backend/index.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app/config.php';
|
||||||
|
|
||||||
|
// Игнорируем запросы к favicon.ico и другим статическим файлам
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$accounts = Database::select('accounts', '*');
|
||||||
|
|
||||||
|
echo json_encode([$accounts]);
|
||||||
5
front_vue/.gitignore
vendored
Normal file
5
front_vue/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
17
front_vue/index.html
Normal file
17
front_vue/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TaskBoard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- Конфиг загружается до приложения (можно менять без перебилда) -->
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1297
front_vue/package-lock.json
generated
Normal file
1297
front_vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
front_vue/package.json
Normal file
18
front_vue/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "taskboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vue": "^3.5.26",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
front_vue/public/config.js
Normal file
5
front_vue/public/config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Конфигурация приложения
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
API_BASE: 'http://192.168.1.6'
|
||||||
|
}
|
||||||
|
|
||||||
41
front_vue/src/App.vue
Normal file
41
front_vue/src/App.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Сброс стилей */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS переменные (цветовая палитра) */
|
||||||
|
:root {
|
||||||
|
--bg-body: #111113;
|
||||||
|
--bg-sidebar: #161618;
|
||||||
|
--bg-main: #111113;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-card-hover: rgba(255, 255, 255, 0.07);
|
||||||
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-secondary: #1a1a1f;
|
||||||
|
--accent: #00d4aa;
|
||||||
|
--accent-soft: rgba(0, 212, 170, 0.12);
|
||||||
|
--blue: #60a5fa;
|
||||||
|
--orange: #fbbf24;
|
||||||
|
--pink: #f472b6;
|
||||||
|
--red: #f87171;
|
||||||
|
--green: #34d399;
|
||||||
|
--text-primary: #f4f4f5;
|
||||||
|
--text-secondary: #a1a1a6;
|
||||||
|
--text-muted: #6b6b70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Базовые стили body */
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', -apple-system, sans-serif;
|
||||||
|
background: var(--bg-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
front_vue/src/api.js
Normal file
61
front_vue/src/api.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Базовый URL API (берётся из внешнего config.js)
|
||||||
|
const API_BASE = window.APP_CONFIG?.API_BASE || 'http://localhost'
|
||||||
|
|
||||||
|
// Базовая функция запроса
|
||||||
|
const request = async (endpoint, options = {}) => {
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, options)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AUTH ====================
|
||||||
|
export const authApi = {
|
||||||
|
login: (data) => request('/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}),
|
||||||
|
check: () => request('/check-auth', { credentials: 'include' }),
|
||||||
|
logout: () => request('/logout', { credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEPARTMENTS ====================
|
||||||
|
export const departmentsApi = {
|
||||||
|
getAll: () => request('/departments', { credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LABELS ====================
|
||||||
|
export const labelsApi = {
|
||||||
|
getAll: () => request('/labels', { credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== COLUMNS ====================
|
||||||
|
export const columnsApi = {
|
||||||
|
getAll: () => request('/columns', { credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CARDS ====================
|
||||||
|
export const cardsApi = {
|
||||||
|
getAll: () => request('/cards', { credentials: 'include' }),
|
||||||
|
create: (data) => request('/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}),
|
||||||
|
update: (id, data) => request(`/cards/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}),
|
||||||
|
delete: (id) => request(`/cards/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USERS ====================
|
||||||
|
export const usersApi = {
|
||||||
|
getAll: () => request('/users', { credentials: 'include' })
|
||||||
|
}
|
||||||
217
front_vue/src/components/Board.vue
Normal file
217
front_vue/src/components/Board.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="board">
|
||||||
|
<div class="columns">
|
||||||
|
<Column
|
||||||
|
v-for="column in filteredColumns"
|
||||||
|
:key="column.id"
|
||||||
|
:column="column"
|
||||||
|
:departments="departments"
|
||||||
|
:labels="labels"
|
||||||
|
@drop-card="handleDropCard"
|
||||||
|
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||||||
|
@create-task="emit('create-task', column.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||||||
|
import Column from './Column.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeDepartment: Number,
|
||||||
|
departments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['stats-updated', 'open-task', 'create-task'])
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
// Локальная копия карточек для optimistic UI
|
||||||
|
const localCards = ref([])
|
||||||
|
|
||||||
|
// Синхронизируем с props при загрузке данных
|
||||||
|
watch(() => props.cards, (newCards) => {
|
||||||
|
if (newCards.length > 0 && localCards.value.length === 0) {
|
||||||
|
// Первая загрузка - копируем данные и добавляем order если нет
|
||||||
|
localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({
|
||||||
|
...card,
|
||||||
|
order: card.order ?? idx
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Собираем колонки с карточками (используем localCards, сортируем по order)
|
||||||
|
const columnsWithCards = computed(() => {
|
||||||
|
return props.columns.map(col => ({
|
||||||
|
id: col.id,
|
||||||
|
title: col.name,
|
||||||
|
color: col.color,
|
||||||
|
cards: localCards.value
|
||||||
|
.filter(card => card.column_id === col.id)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map(card => ({
|
||||||
|
id: card.id_card,
|
||||||
|
title: card.title,
|
||||||
|
description: card.descript,
|
||||||
|
details: card.descript_full,
|
||||||
|
departmentId: card.id_department,
|
||||||
|
labelId: card.id_label,
|
||||||
|
assignee: card.avatar_img,
|
||||||
|
dueDate: card.date,
|
||||||
|
dateCreate: card.date_create,
|
||||||
|
files: card.files || (card.file_img || []).map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
url: f.url,
|
||||||
|
size: f.size,
|
||||||
|
preview: f.url
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Фильтруем колонки по активному отделу
|
||||||
|
const filteredColumns = computed(() => {
|
||||||
|
if (!props.activeDepartment) return columnsWithCards.value
|
||||||
|
|
||||||
|
return columnsWithCards.value.map(col => ({
|
||||||
|
...col,
|
||||||
|
cards: col.cards.filter(card => card.departmentId === props.activeDepartment)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTotalTasks = computed(() => {
|
||||||
|
return filteredColumns.value.reduce((sum, col) => sum + col.cards.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const inProgressTasks = computed(() => {
|
||||||
|
const col = filteredColumns.value.find(c => c.id === 3) // В работе
|
||||||
|
return col ? col.cards.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedTasks = computed(() => {
|
||||||
|
const col = filteredColumns.value.find(c => c.id === 5) // Готово
|
||||||
|
return col ? col.cards.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Отправляем статистику в родителя
|
||||||
|
watch([filteredTotalTasks, inProgressTasks, completedTasks], () => {
|
||||||
|
emit('stats-updated', {
|
||||||
|
total: filteredTotalTasks.value,
|
||||||
|
inProgress: inProgressTasks.value,
|
||||||
|
done: completedTasks.value
|
||||||
|
})
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleDropCard = ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||||||
|
const card = localCards.value.find(c => c.id_card === cardId)
|
||||||
|
if (!card) return
|
||||||
|
|
||||||
|
// Меняем колонку
|
||||||
|
card.column_id = toColumnId
|
||||||
|
|
||||||
|
// Получаем карточки целевой колонки (без перемещаемой)
|
||||||
|
const columnCards = localCards.value
|
||||||
|
.filter(c => c.column_id === toColumnId && c.id_card !== cardId)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// Вставляем карточку в нужную позицию и пересчитываем order
|
||||||
|
columnCards.splice(toIndex, 0, card)
|
||||||
|
columnCards.forEach((c, idx) => {
|
||||||
|
c.order = idx
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: отправить изменение на сервер
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генератор id для новых карточек
|
||||||
|
let nextCardId = 100
|
||||||
|
|
||||||
|
// Методы для модалки
|
||||||
|
const saveTask = (taskData, columnId) => {
|
||||||
|
if (taskData.id) {
|
||||||
|
// Редактирование существующей карточки
|
||||||
|
const card = localCards.value.find(c => c.id_card === taskData.id)
|
||||||
|
if (card) {
|
||||||
|
card.title = taskData.title
|
||||||
|
card.descript = taskData.description
|
||||||
|
card.descript_full = taskData.details
|
||||||
|
card.id_department = taskData.departmentId
|
||||||
|
card.id_label = taskData.labelId
|
||||||
|
card.date = taskData.dueDate
|
||||||
|
card.avatar_img = taskData.assignee
|
||||||
|
card.files = taskData.files || []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создание новой карточки (в конец колонки)
|
||||||
|
const columnCards = localCards.value.filter(c => c.column_id === columnId)
|
||||||
|
const maxOrder = columnCards.length > 0
|
||||||
|
? Math.max(...columnCards.map(c => c.order)) + 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
localCards.value.push({
|
||||||
|
id_card: nextCardId++,
|
||||||
|
id_department: taskData.departmentId,
|
||||||
|
id_label: taskData.labelId,
|
||||||
|
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: taskData.files || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: отправить на сервер
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTask = (cardId, columnId) => {
|
||||||
|
const index = localCards.value.findIndex(c => c.id_card === cardId)
|
||||||
|
if (index !== -1) {
|
||||||
|
localCards.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: отправить на сервер
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ saveTask, deleteTask })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.board {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
281
front_vue/src/components/Card.vue
Normal file
281
front_vue/src/components/Card.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor }"
|
||||||
|
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="tag-group">
|
||||||
|
<span v-if="cardLabel" class="label-icon">{{ cardLabel.icon }}</span>
|
||||||
|
<span
|
||||||
|
v-if="cardDepartment"
|
||||||
|
class="tag"
|
||||||
|
:style="{ color: cardDepartment.color }"
|
||||||
|
>
|
||||||
|
{{ cardDepartment.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
|
||||||
|
<i data-lucide="image-plus"></i>
|
||||||
|
</span>
|
||||||
|
<div v-if="card.assignee" class="assignee">
|
||||||
|
<img
|
||||||
|
v-if="isAvatarUrl(card.assignee)"
|
||||||
|
:src="card.assignee"
|
||||||
|
alt="avatar"
|
||||||
|
class="assignee-img"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ card.assignee }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title">{{ card.title }}</h3>
|
||||||
|
|
||||||
|
<p v-if="card.description" class="card-description">
|
||||||
|
{{ card.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<span v-if="card.dateCreate" class="date-create">
|
||||||
|
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="card.dueDate" class="due-date" :class="dueDateStatus">
|
||||||
|
{{ daysLeftText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: Object,
|
||||||
|
columnId: [String, Number],
|
||||||
|
index: Number,
|
||||||
|
departments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// defineEmits(['delete']) - будет при раскрытии карточки
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
const handleDragStart = (e) => {
|
||||||
|
isDragging.value = true
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('cardId', props.card.id.toString())
|
||||||
|
e.dataTransfer.setData('columnId', props.columnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем отдел по id
|
||||||
|
const cardDepartment = computed(() => {
|
||||||
|
if (!props.card.departmentId) return null
|
||||||
|
return props.departments.find(d => d.id === props.card.departmentId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем лейбл по id
|
||||||
|
const cardLabel = computed(() => {
|
||||||
|
if (!props.card.labelId) return null
|
||||||
|
return props.labels.find(l => l.id === props.card.labelId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Цвет лейбла для фона карточки
|
||||||
|
const cardLabelColor = computed(() => {
|
||||||
|
return cardLabel.value?.color || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDateWithYear = (dateStr) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const day = date.getDate()
|
||||||
|
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||||
|
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDaysLeft = () => {
|
||||||
|
if (!props.card.dueDate) return null
|
||||||
|
const due = new Date(props.card.dueDate)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
due.setHours(0, 0, 0, 0)
|
||||||
|
return Math.round((due - today) / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDateStatus = computed(() => {
|
||||||
|
const days = getDaysLeft()
|
||||||
|
if (days === null) return ''
|
||||||
|
if (days < 0) return 'overdue'
|
||||||
|
if (days <= 2) return 'soon'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const daysLeftText = computed(() => {
|
||||||
|
const days = getDaysLeft()
|
||||||
|
if (days === null) return ''
|
||||||
|
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
|
||||||
|
if (days === 0) return 'Сегодня'
|
||||||
|
if (days === 1) return 'Завтра'
|
||||||
|
return `Осталось: ${days} дн.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAvatarUrl = (value) => {
|
||||||
|
return value && (value.startsWith('http://') || value.startsWith('https://'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.has-label-color {
|
||||||
|
background: color-mix(in srgb, var(--label-bg) 15%, var(--bg-card));
|
||||||
|
border-left: 3px solid var(--label-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.has-label-color:hover {
|
||||||
|
background: color-mix(in srgb, var(--label-bg) 20%, var(--bg-card-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--blue);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-create {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-indicator i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date.soon {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date.overdue {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
243
front_vue/src/components/Column.vue
Normal file
243
front_vue/src/components/Column.vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
:class="{ 'drag-over': isDragOver }"
|
||||||
|
ref="columnRef"
|
||||||
|
>
|
||||||
|
<div class="column-header">
|
||||||
|
<div class="column-title-row">
|
||||||
|
<span class="column-dot" :style="{ background: column.color }"></span>
|
||||||
|
<h2 class="column-title">{{ column.title }}</h2>
|
||||||
|
<span class="column-count">{{ column.cards.length }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="column-add" @click="emit('create-task')">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards" ref="cardsRef">
|
||||||
|
<template v-for="(card, index) in column.cards" :key="card.id">
|
||||||
|
<!-- Индикатор перед карточкой -->
|
||||||
|
<div v-if="isDragOver && dropIndex === index" class="drop-indicator"></div>
|
||||||
|
<Card
|
||||||
|
:card="card"
|
||||||
|
:column-id="column.id"
|
||||||
|
:index="index"
|
||||||
|
:departments="departments"
|
||||||
|
:labels="labels"
|
||||||
|
@click="emit('open-task', card)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Индикатор в конце списка -->
|
||||||
|
<div v-if="isDragOver && dropIndex === column.cards.length" class="drop-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUpdated } from 'vue'
|
||||||
|
import Card from './Card.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
column: Object,
|
||||||
|
departments: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['drop-card', 'open-task', 'create-task'])
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
|
||||||
|
const columnRef = ref(null)
|
||||||
|
const cardsRef = ref(null)
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
const dropIndex = ref(-1)
|
||||||
|
let dragEnterCounter = 0
|
||||||
|
|
||||||
|
const handleDragEnter = (e) => {
|
||||||
|
dragEnterCounter++
|
||||||
|
isDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateDropIndex = (clientY) => {
|
||||||
|
if (!cardsRef.value) return props.column.cards.length
|
||||||
|
|
||||||
|
const cardElements = cardsRef.value.querySelectorAll('.card')
|
||||||
|
let index = props.column.cards.length
|
||||||
|
|
||||||
|
for (let i = 0; i < cardElements.length; i++) {
|
||||||
|
const rect = cardElements[i].getBoundingClientRect()
|
||||||
|
const cardMiddle = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
if (clientY < cardMiddle) {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
isDragOver.value = true
|
||||||
|
dropIndex.value = calculateDropIndex(e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e) => {
|
||||||
|
dragEnterCounter--
|
||||||
|
if (dragEnterCounter === 0) {
|
||||||
|
isDragOver.value = false
|
||||||
|
dropIndex.value = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
const cardId = parseInt(e.dataTransfer.getData('cardId'))
|
||||||
|
const fromColumnId = e.dataTransfer.getData('columnId')
|
||||||
|
const toIndex = calculateDropIndex(e.clientY)
|
||||||
|
|
||||||
|
dragEnterCounter = 0
|
||||||
|
isDragOver.value = false
|
||||||
|
dropIndex.value = -1
|
||||||
|
|
||||||
|
emit('drop-card', {
|
||||||
|
cardId,
|
||||||
|
fromColumnId,
|
||||||
|
toColumnId: props.column.id,
|
||||||
|
toIndex
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.column {
|
||||||
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.drag-over .cards {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-add {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-add i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-add:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-indicator {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 4px 0;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
202
front_vue/src/components/ConfirmDialog.vue
Normal file
202
front_vue/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="dialog">
|
||||||
|
<div v-if="show" class="dialog-overlay" @click.self="handleCancel">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<p v-html="message"></p>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button class="btn-cancel" @click="handleCancel">
|
||||||
|
{{ cancelText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showDiscard"
|
||||||
|
class="btn-discard"
|
||||||
|
@click="handleDiscard"
|
||||||
|
>
|
||||||
|
{{ discardText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-confirm"
|
||||||
|
:class="variant"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
{{ confirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Подтверждение'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: 'Вы уверены?'
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Подтвердить'
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Отмена'
|
||||||
|
},
|
||||||
|
discardText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Не сохранять'
|
||||||
|
},
|
||||||
|
showDiscard: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// Варианты: 'default', 'danger', 'warning'
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
emit('discard')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-discard {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-discard:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background: #00e6b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.warning {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.warning:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.dialog-enter-active,
|
||||||
|
.dialog-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-active .dialog,
|
||||||
|
.dialog-leave-active .dialog {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from,
|
||||||
|
.dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from .dialog,
|
||||||
|
.dialog-leave-to .dialog {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
423
front_vue/src/components/DatePicker.vue
Normal file
423
front_vue/src/components/DatePicker.vue
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<template>
|
||||||
|
<div class="datepicker" ref="datepickerRef">
|
||||||
|
<div class="datepicker-trigger" @click="toggleCalendar">
|
||||||
|
<i data-lucide="calendar"></i>
|
||||||
|
<span v-if="modelValue">{{ formatDisplayDate(modelValue) }}</span>
|
||||||
|
<span v-else class="placeholder">Выберите дату</span>
|
||||||
|
<button v-if="modelValue" class="clear-btn" @click.stop="clearDate">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div v-if="isOpen" class="calendar">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button class="nav-btn" @click="prevMonth">
|
||||||
|
<i data-lucide="chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="current-month">{{ monthNames[currentMonth] }} {{ currentYear }}</span>
|
||||||
|
<button class="nav-btn" @click="nextMonth">
|
||||||
|
<i data-lucide="chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-weekdays">
|
||||||
|
<span v-for="day in weekdays" :key="day">{{ day }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-days">
|
||||||
|
<button
|
||||||
|
v-for="day in calendarDays"
|
||||||
|
:key="day.key"
|
||||||
|
class="day"
|
||||||
|
:class="{
|
||||||
|
'other-month': !day.currentMonth,
|
||||||
|
'today': day.isToday,
|
||||||
|
'selected': day.isSelected
|
||||||
|
}"
|
||||||
|
@click="selectDate(day)"
|
||||||
|
>
|
||||||
|
{{ day.day }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-footer">
|
||||||
|
<button class="today-btn" @click="selectToday">Сегодня</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: String
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const datepickerRef = ref(null)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const currentMonth = ref(new Date().getMonth())
|
||||||
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
|
||||||
|
const weekdays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDisplayDate = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const day = date.getDate()
|
||||||
|
const month = monthNames[date.getMonth()]
|
||||||
|
const year = date.getFullYear()
|
||||||
|
return `${day} ${month} ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarDays = computed(() => {
|
||||||
|
const days = []
|
||||||
|
const firstDay = new Date(currentYear.value, currentMonth.value, 1)
|
||||||
|
const lastDay = new Date(currentYear.value, currentMonth.value + 1, 0)
|
||||||
|
|
||||||
|
// День недели первого числа (0 = воскресенье, переводим в понедельник = 0)
|
||||||
|
let startDay = firstDay.getDay() - 1
|
||||||
|
if (startDay < 0) startDay = 6
|
||||||
|
|
||||||
|
// Дни предыдущего месяца
|
||||||
|
const prevMonthLastDay = new Date(currentYear.value, currentMonth.value, 0).getDate()
|
||||||
|
for (let i = startDay - 1; i >= 0; i--) {
|
||||||
|
const day = prevMonthLastDay - i
|
||||||
|
days.push({
|
||||||
|
key: `prev-${day}`,
|
||||||
|
day,
|
||||||
|
currentMonth: false,
|
||||||
|
date: new Date(currentYear.value, currentMonth.value - 1, day),
|
||||||
|
isToday: false,
|
||||||
|
isSelected: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни текущего месяца
|
||||||
|
const today = new Date()
|
||||||
|
const selectedDate = props.modelValue ? new Date(props.modelValue) : null
|
||||||
|
|
||||||
|
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||||
|
const date = new Date(currentYear.value, currentMonth.value, day)
|
||||||
|
const isToday = date.toDateString() === today.toDateString()
|
||||||
|
const isSelected = selectedDate && date.toDateString() === selectedDate.toDateString()
|
||||||
|
|
||||||
|
days.push({
|
||||||
|
key: `curr-${day}`,
|
||||||
|
day,
|
||||||
|
currentMonth: true,
|
||||||
|
date,
|
||||||
|
isToday,
|
||||||
|
isSelected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни следующего месяца (заполняем до 42 ячеек = 6 недель)
|
||||||
|
const remaining = 42 - days.length
|
||||||
|
for (let day = 1; day <= remaining; day++) {
|
||||||
|
days.push({
|
||||||
|
key: `next-${day}`,
|
||||||
|
day,
|
||||||
|
currentMonth: false,
|
||||||
|
date: new Date(currentYear.value, currentMonth.value + 1, day),
|
||||||
|
isToday: false,
|
||||||
|
isSelected: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleCalendar = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
if (isOpen.value) {
|
||||||
|
// Устанавливаем текущий месяц на выбранную дату или сегодня
|
||||||
|
if (props.modelValue) {
|
||||||
|
const date = new Date(props.modelValue)
|
||||||
|
currentMonth.value = date.getMonth()
|
||||||
|
currentYear.value = date.getFullYear()
|
||||||
|
} else {
|
||||||
|
currentMonth.value = new Date().getMonth()
|
||||||
|
currentYear.value = new Date().getFullYear()
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
if (window.lucide) window.lucide.createIcons()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (currentMonth.value === 0) {
|
||||||
|
currentMonth.value = 11
|
||||||
|
currentYear.value--
|
||||||
|
} else {
|
||||||
|
currentMonth.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (currentMonth.value === 11) {
|
||||||
|
currentMonth.value = 0
|
||||||
|
currentYear.value++
|
||||||
|
} else {
|
||||||
|
currentMonth.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDate = (day) => {
|
||||||
|
const date = day.date
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dayNum = String(date.getDate()).padStart(2, '0')
|
||||||
|
emit('update:modelValue', `${year}-${month}-${dayNum}`)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectToday = () => {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(today.getDate()).padStart(2, '0')
|
||||||
|
emit('update:modelValue', `${year}-${month}-${day}`)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearDate = () => {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (datepickerRef.value && !datepickerRef.value.contains(e.target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
if (window.lucide) window.lucide.createIcons()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isOpen, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (window.lucide) window.lucide.createIcons()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.datepicker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-trigger:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-trigger i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-trigger .placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #1e1e24;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays span {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.other-month {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.today {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.selected {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day.selected:hover {
|
||||||
|
background: #00e6b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
front_vue/src/components/Header.vue
Normal file
121
front_vue/src/components/Header.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="title-row">
|
||||||
|
<h1 class="page-title">{{ title }}</h1>
|
||||||
|
<!-- Слот для фильтров (на одной строке с заголовком) -->
|
||||||
|
<slot name="filters"></slot>
|
||||||
|
</div>
|
||||||
|
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- Слот для статистики и прочего -->
|
||||||
|
<slot name="stats"></slot>
|
||||||
|
<button class="logout-btn" @click="logout" title="Выйти">
|
||||||
|
<i data-lucide="log-out"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authApi } from '../api'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authApi.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 24px 32px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
front_vue/src/components/Sidebar.vue
Normal file
98
front_vue/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<!-- Логотип -->
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<i data-lucide="layout-grid"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Меню навигации -->
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<router-link to="/" class="nav-item" :class="{ active: $route.path === '/' }">
|
||||||
|
<i data-lucide="kanban"></i>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/team" class="nav-item" :class="{ active: $route.path === '/team' }">
|
||||||
|
<i data-lucide="users"></i>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 72px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo i {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(0, 212, 170, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1551
front_vue/src/components/TaskPanel.vue
Normal file
1551
front_vue/src/components/TaskPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
5
front_vue/src/main.js
Normal file
5
front_vue/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
57
front_vue/src/router.js
Normal file
57
front_vue/src/router.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import MainApp from './views/MainApp.vue'
|
||||||
|
import LoginPage from './views/LoginPage.vue'
|
||||||
|
import TeamPage from './views/TeamPage.vue'
|
||||||
|
import { authApi } from './api'
|
||||||
|
|
||||||
|
// Проверка авторизации
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const data = await authApi.check()
|
||||||
|
return data.status === 'ok'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'main',
|
||||||
|
component: MainApp,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/team',
|
||||||
|
name: 'team',
|
||||||
|
component: TeamPage,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginPage
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guard — проверка авторизации
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const isAuth = await checkAuth()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !isAuth) {
|
||||||
|
// Не авторизован — на логин
|
||||||
|
next('/login')
|
||||||
|
} else if (to.path === '/login' && isAuth) {
|
||||||
|
// Уже авторизован — на главную
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
187
front_vue/src/views/LoginPage.vue
Normal file
187
front_vue/src/views/LoginPage.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<!-- Логотип -->
|
||||||
|
<div class="login-logo">
|
||||||
|
<i data-lucide="layout-grid"></i>
|
||||||
|
<span>TaskBoard</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="login-form">
|
||||||
|
<!-- Поле логина -->
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="login"
|
||||||
|
placeholder="Логин"
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поле пароля -->
|
||||||
|
<div class="field">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ошибка -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка входа -->
|
||||||
|
<button type="submit" class="login-btn" :disabled="loading">
|
||||||
|
<span v-if="loading">Вход...</span>
|
||||||
|
<span v-else>Войти</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authApi } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const login = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await authApi.login({
|
||||||
|
login: login.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error.value = 'Неверный логин или пароль'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Ошибка подключения к серверу'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-body);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo i {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo span {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #000;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover:not(:disabled) {
|
||||||
|
background: #00e6b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
284
front_vue/src/views/MainApp.vue
Normal file
284
front_vue/src/views/MainApp.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<!-- Боковая панель навигации -->
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<!-- Шапка с заголовком, фильтрами и статистикой -->
|
||||||
|
<Header title="Доска задач">
|
||||||
|
<template #filters>
|
||||||
|
<div class="filters">
|
||||||
|
<button
|
||||||
|
class="filter-tag"
|
||||||
|
:class="{ active: activeDepartment === null }"
|
||||||
|
@click="activeDepartment = null"
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="dept in departments"
|
||||||
|
:key="dept.id"
|
||||||
|
class="filter-tag"
|
||||||
|
:class="{ active: activeDepartment === dept.id }"
|
||||||
|
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
|
||||||
|
>
|
||||||
|
{{ dept.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #stats>
|
||||||
|
<div class="header-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ stats.total }}</span>
|
||||||
|
<span class="stat-label">задач</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ stats.inProgress }}</span>
|
||||||
|
<span class="stat-label">в работе</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ stats.done }}</span>
|
||||||
|
<span class="stat-label">готово</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<!-- Доска с колонками и карточками -->
|
||||||
|
<main class="main">
|
||||||
|
<Board
|
||||||
|
ref="boardRef"
|
||||||
|
:active-department="activeDepartment"
|
||||||
|
:departments="departments"
|
||||||
|
:labels="labels"
|
||||||
|
:columns="columns"
|
||||||
|
:cards="cards"
|
||||||
|
@stats-updated="stats = $event"
|
||||||
|
@open-task="openTaskPanel"
|
||||||
|
@create-task="openNewTaskPanel"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Панель редактирования/создания задачи -->
|
||||||
|
<TaskPanel
|
||||||
|
:show="panelOpen"
|
||||||
|
:card="editingCard"
|
||||||
|
:column-id="editingColumnId"
|
||||||
|
:departments="departments"
|
||||||
|
:labels="labels"
|
||||||
|
@close="closePanel"
|
||||||
|
@save="handleSaveTask"
|
||||||
|
@delete="handleDeleteTask"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Sidebar from '../components/Sidebar.vue'
|
||||||
|
import Header from '../components/Header.vue'
|
||||||
|
import Board from '../components/Board.vue'
|
||||||
|
import TaskPanel from '../components/TaskPanel.vue'
|
||||||
|
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
||||||
|
|
||||||
|
// Активный фильтр по отделу (null = все)
|
||||||
|
const activeDepartment = ref(null)
|
||||||
|
// Статистика для шапки
|
||||||
|
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||||
|
// Данные из API
|
||||||
|
const departments = ref([])
|
||||||
|
const labels = ref([])
|
||||||
|
const columns = ref([])
|
||||||
|
const cards = ref([])
|
||||||
|
|
||||||
|
// Загрузка всех данных из API параллельно
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [departmentsData, labelsData, columnsData, cardsData] = await Promise.all([
|
||||||
|
departmentsApi.getAll(),
|
||||||
|
labelsApi.getAll(),
|
||||||
|
columnsApi.getAll(),
|
||||||
|
cardsApi.getAll()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (departmentsData.success) departments.value = departmentsData.data
|
||||||
|
if (labelsData.success) labels.value = labelsData.data
|
||||||
|
if (columnsData.success) columns.value = columnsData.data
|
||||||
|
if (cardsData.success) cards.value = cardsData.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход из системы
|
||||||
|
// Ссылка на компонент Board для вызова его методов
|
||||||
|
const boardRef = ref(null)
|
||||||
|
// Состояние панели редактирования
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
// Редактируемая карточка (null = создание новой)
|
||||||
|
const editingCard = ref(null)
|
||||||
|
// ID колонки для новой/редактируемой карточки
|
||||||
|
const editingColumnId = ref(null)
|
||||||
|
|
||||||
|
// Открыть панель для редактирования существующей задачи
|
||||||
|
const openTaskPanel = ({ card, columnId }) => {
|
||||||
|
editingCard.value = card
|
||||||
|
editingColumnId.value = columnId
|
||||||
|
panelOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть панель для создания новой задачи
|
||||||
|
const openNewTaskPanel = (columnId) => {
|
||||||
|
editingCard.value = null
|
||||||
|
editingColumnId.value = columnId
|
||||||
|
panelOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть панель и сбросить состояние
|
||||||
|
const closePanel = () => {
|
||||||
|
panelOpen.value = false
|
||||||
|
editingCard.value = null
|
||||||
|
editingColumnId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранить задачу через Board компонент
|
||||||
|
const handleSaveTask = (taskData) => {
|
||||||
|
boardRef.value?.saveTask(taskData, editingColumnId.value)
|
||||||
|
closePanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить задачу через Board компонент
|
||||||
|
const handleDeleteTask = (cardId) => {
|
||||||
|
boardRef.value?.deleteTask(cardId, editingColumnId.value)
|
||||||
|
closePanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при монтировании
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Контейнер приложения */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Основная область контента */
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер фильтров */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка фильтра */
|
||||||
|
.filter-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tag:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Активный фильтр */
|
||||||
|
.filter-tag.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Блок статистики */
|
||||||
|
.header-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Разделитель между статами */
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Основная область с доской */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 36px 36px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стилизация горизонтального скроллбара */
|
||||||
|
.main::-webkit-scrollbar {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 212, 170, 0.4);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 212, 170, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
223
front_vue/src/views/TeamPage.vue
Normal file
223
front_vue/src/views/TeamPage.vue
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<Header title="Команда" subtitle="Наша команда специалистов" />
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="team-grid">
|
||||||
|
<div
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="team-card"
|
||||||
|
>
|
||||||
|
<div class="card-avatar">
|
||||||
|
<img :src="user.avatar_url" :alt="user.name">
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<h3 class="card-name">{{ user.name }}</h3>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span v-if="user.department" class="card-department">{{ user.department }}</span>
|
||||||
|
<span class="card-username">@{{ user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||||
|
target="_blank"
|
||||||
|
class="card-telegram"
|
||||||
|
>
|
||||||
|
<i data-lucide="send"></i>
|
||||||
|
{{ user.telegram }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUpdated } from 'vue'
|
||||||
|
import Sidebar from '../components/Sidebar.vue'
|
||||||
|
import Header from '../components/Header.vue'
|
||||||
|
import { usersApi } from '../api'
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const data = await usersApi.getAll()
|
||||||
|
if (data.success) {
|
||||||
|
users.value = data.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки команды:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUsers()
|
||||||
|
refreshIcons()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUpdated(refreshIcons)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 80px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-department {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-telegram {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 136, 204, 0.15);
|
||||||
|
color: #0088cc;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-telegram:hover {
|
||||||
|
background: rgba(0, 136, 204, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-telegram i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
front_vue/vite.config.js
Normal file
10
front_vue/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user