1
0

Инициализация проекта

Загрузка проекта на GIT
This commit is contained in:
2026-01-11 15:01:35 +07:00
commit 301e179160
28 changed files with 7769 additions and 0 deletions

5
front_vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Dependencies
node_modules/
# Build output
dist/

17
front_vue/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

18
front_vue/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,5 @@
// Конфигурация приложения
window.APP_CONFIG = {
API_BASE: 'http://192.168.1.6'
}

41
front_vue/src/App.vue Normal file
View 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
View 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' })
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

5
front_vue/src/main.js Normal file
View 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
View 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

View 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>

View 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>

View 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
View 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
}
})