Initial commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Игнорируем node_modules при копировании
|
||||
play-life-web/node_modules
|
||||
play-life-web/dist
|
||||
play-life-web/.git
|
||||
play-life-backend/.git
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
*.log
|
||||
main
|
||||
dist/
|
||||
node_modules/
|
||||
*.tar
|
||||
81
BUILD_INSTRUCTIONS.md
Normal file
81
BUILD_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Инструкция по сборке единого Docker образа
|
||||
|
||||
Этот проект содержит единый Dockerfile для сборки frontend и backend в один образ.
|
||||
|
||||
## Структура
|
||||
|
||||
- `Dockerfile` - единый Dockerfile для сборки frontend и backend
|
||||
- `nginx-unified.conf` - конфигурация nginx для единого образа
|
||||
- `supervisord.conf` - конфигурация supervisor для запуска nginx и backend
|
||||
- `build-and-save.sh` - скрипт для сборки и сохранения в tar (Linux/Mac)
|
||||
- `build-and-save.ps1` - скрипт для сборки и сохранения в tar (Windows PowerShell)
|
||||
|
||||
## Сборка образа
|
||||
|
||||
### Linux/Mac:
|
||||
```bash
|
||||
./build-and-save.sh
|
||||
```
|
||||
|
||||
### Windows PowerShell:
|
||||
```powershell
|
||||
.\build-and-save.ps1
|
||||
```
|
||||
|
||||
### Вручную:
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t play-life-unified:latest .
|
||||
|
||||
# Сохранение в tar
|
||||
docker save play-life-unified:latest -o play-life-unified.tar
|
||||
```
|
||||
|
||||
## Загрузка образа на другой машине
|
||||
|
||||
```bash
|
||||
docker load -i play-life-unified.tar
|
||||
```
|
||||
|
||||
## Запуск контейнера
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 80:80 \
|
||||
--env-file .env \
|
||||
--name play-life \
|
||||
play-life-unified:latest
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Создайте файл `.env` на основе `env.example` с необходимыми переменными:
|
||||
|
||||
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||
- `DB_USER` - пользователь БД
|
||||
- `DB_PASSWORD` - пароль БД
|
||||
- `DB_NAME` - имя БД
|
||||
- `TELEGRAM_BOT_TOKEN` - токен Telegram бота (опционально)
|
||||
- `TELEGRAM_CHAT_ID` - ID чата Telegram (опционально)
|
||||
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для webhook (опционально)
|
||||
- `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально)
|
||||
|
||||
**Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend.
|
||||
|
||||
## Проверка работы
|
||||
|
||||
После запуска контейнера:
|
||||
|
||||
- Frontend доступен по адресу: `http://localhost`
|
||||
- API доступен через nginx: `http://localhost/api/...`
|
||||
- Admin панель: `http://localhost/admin.html`
|
||||
|
||||
## Логи
|
||||
|
||||
Логи доступны через supervisor:
|
||||
```bash
|
||||
docker exec play-life cat /var/log/supervisor/backend.out.log
|
||||
docker exec play-life cat /var/log/supervisor/nginx.out.log
|
||||
```
|
||||
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# Multi-stage build для единого образа frontend + backend
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY play-life-web/package*.json ./
|
||||
RUN npm ci
|
||||
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||
COPY play-life-web/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.21-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY play-life-backend/ .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM alpine:latest
|
||||
|
||||
# Устанавливаем необходимые пакеты
|
||||
RUN apk --no-cache add \
|
||||
ca-certificates \
|
||||
nginx \
|
||||
supervisor \
|
||||
curl
|
||||
|
||||
# Создаем директории
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем собранный frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Копируем собранный backend
|
||||
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||
|
||||
# Копируем конфигурацию nginx
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Копируем конфигурацию supervisor для запуска backend
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Создаем директории для логов
|
||||
RUN mkdir -p /var/log/supervisor && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/run
|
||||
|
||||
# Открываем порт 80
|
||||
EXPOSE 80
|
||||
|
||||
# Запускаем supervisor, который запустит nginx и backend
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
||||
297
ENV_SETUP.md
Normal file
297
ENV_SETUP.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Настройка единого .env файла
|
||||
|
||||
Все приложения проекта используют единый файл `.env` в корне проекта.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Скопируйте файл `.env.example` в `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Отредактируйте `.env` и укажите свои значения:
|
||||
```bash
|
||||
nano .env
|
||||
# или
|
||||
vim .env
|
||||
```
|
||||
|
||||
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||
|
||||
## Структура переменных окружения
|
||||
|
||||
### Database Configuration
|
||||
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||
|
||||
### Backend Server Configuration
|
||||
- `PORT` - порт бэкенд сервера (по умолчанию: 8080)
|
||||
- В production всегда используется порт 8080 внутри контейнера
|
||||
- Nginx автоматически проксирует запросы к `http://backend:8080`
|
||||
|
||||
### Frontend Configuration (play-life-web)
|
||||
- `VITE_PORT` - порт для dev-сервера Vite (по умолчанию: 3000)
|
||||
- `WEB_PORT` - порт для production контейнера (по умолчанию: 3001)
|
||||
|
||||
**Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`.
|
||||
|
||||
### Telegram Bot Configuration (опционально)
|
||||
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
|
||||
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
|
||||
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при старте сервера на `<TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram`. Если не указан, webhook нужно настраивать вручную.
|
||||
|
||||
**Примеры значений:**
|
||||
- Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443)
|
||||
- Локальная разработка с ngrok: `https://abc123.ngrok.io` (порт не нужен)
|
||||
- Прямой доступ на нестандартном порту: `http://your-server:8080` (порт обязателен)
|
||||
|
||||
### Todoist Webhook Configuration (опционально)
|
||||
- `TODOIST_WEBHOOK_SECRET` - секрет для проверки подлинности webhook от Todoist (если задан, все запросы должны содержать заголовок `X-Todoist-Webhook-Secret` с этим значением)
|
||||
|
||||
## Настройка интеграции с Todoist
|
||||
|
||||
Интеграция с Todoist позволяет автоматически обрабатывать закрытые задачи и добавлять их в базу данных play-life.
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. При закрытии задачи в Todoist отправляется webhook на ваш сервер
|
||||
2. Сервер извлекает `title` (content) и `description` из закрытой задачи
|
||||
3. Склеивает их в один текст: `title + "\n" + description`
|
||||
4. Обрабатывает текст через существующую логику `processMessage`, которая:
|
||||
- Парсит ноды в формате `**[Project][+/-][Score]**`
|
||||
- Сохраняет данные в базу данных
|
||||
- Отправляет уведомление в Telegram (если настроено)
|
||||
|
||||
### Настройка webhook в Todoist
|
||||
|
||||
1. Откройте настройки Todoist: https://todoist.com/app/settings/integrations
|
||||
2. Перейдите в раздел "Webhooks" или "Integrations"
|
||||
3. Создайте новый webhook:
|
||||
- **URL**: `http://your-server:8080/webhook/todoist`
|
||||
- Для локальной разработки: `http://localhost:8080/webhook/todoist`
|
||||
- Для production: укажите публичный URL вашего сервера
|
||||
- **Event**: выберите `item:completed` (закрытие задачи)
|
||||
4. Сохраните webhook
|
||||
|
||||
### Безопасность (опционально)
|
||||
|
||||
Для защиты webhook от несанкционированного доступа:
|
||||
|
||||
1. Установите секрет в `.env`:
|
||||
```bash
|
||||
TODOIST_WEBHOOK_SECRET=your_secret_key_here
|
||||
```
|
||||
|
||||
2. Настройте Todoist для отправки секрета в заголовке:
|
||||
- В настройках webhook добавьте заголовок: `X-Todoist-Webhook-Secret: your_secret_key_here`
|
||||
- Или используйте встроенные механизмы безопасности Todoist, если они доступны
|
||||
|
||||
**Примечание**: Если `TODOIST_WEBHOOK_SECRET` не задан, проверка секрета не выполняется.
|
||||
|
||||
### Формат задач в Todoist
|
||||
|
||||
Для корректной обработки задачи должны содержать ноды в формате:
|
||||
```
|
||||
**[ProjectName][+/-][Score]**
|
||||
```
|
||||
|
||||
Примеры:
|
||||
- `**[Work]+5.5**` - добавить 5.5 баллов к проекту "Work"
|
||||
- `**[Health]-2.0**` - вычесть 2.0 баллов из проекта "Health"
|
||||
|
||||
Ноды можно размещать как в `title` (content), так и в `description` задачи. Они будут обработаны при закрытии задачи.
|
||||
|
||||
### Тестирование
|
||||
|
||||
Для тестирования интеграции:
|
||||
|
||||
1. Создайте задачу в Todoist с нодами, например:
|
||||
- Title: `Test task`
|
||||
- Description: `**[TestProject]+10.0**`
|
||||
|
||||
2. Закройте задачу в Todoist
|
||||
|
||||
3. Проверьте логи сервера - должно появиться сообщение:
|
||||
```
|
||||
Processing Todoist task: title='Test task', description='**[TestProject]+10.0**'
|
||||
Successfully processed Todoist task, found 1 nodes
|
||||
```
|
||||
|
||||
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||
|
||||
|
||||
## Использование
|
||||
|
||||
### Локальная разработка
|
||||
|
||||
Все приложения автоматически читают переменные из корневого `.env` файла:
|
||||
|
||||
- **play-life-backend**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||
- **play-life-web**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Для запуска всех приложений в одном образе используйте корневой `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
Все сервисы автоматически загружают переменные из корневого `.env` файла.
|
||||
|
||||
### Отдельные приложения
|
||||
|
||||
Если нужно запустить отдельные приложения, они также будут использовать корневой `.env`:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd play-life-backend
|
||||
docker-compose up
|
||||
|
||||
# Frontend
|
||||
cd play-life-web
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Приоритет переменных окружения
|
||||
|
||||
1. Переменные окружения системы (высший приоритет)
|
||||
2. Локальный `.env` в директории приложения
|
||||
3. Корневой `.env` файл
|
||||
4. Значения по умолчанию в коде
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Изменение порта базы данных
|
||||
|
||||
```bash
|
||||
# В .env
|
||||
DB_PORT=5433
|
||||
```
|
||||
|
||||
### Изменение порта бэкенда
|
||||
|
||||
```bash
|
||||
# В .env
|
||||
PORT=9090
|
||||
```
|
||||
|
||||
### Изменение порта фронтенда
|
||||
|
||||
```bash
|
||||
# В .env
|
||||
VITE_PORT=4000 # для development
|
||||
WEB_PORT=4001 # для production Docker контейнера
|
||||
```
|
||||
|
||||
После изменения `.env` файла перезапустите соответствующие сервисы.
|
||||
|
||||
## Настройка интеграции с Telegram (webhook для сообщений пользователя)
|
||||
|
||||
Интеграция с Telegram позволяет автоматически обрабатывать сообщения, отправленные пользователем в чат бота, и добавлять их в базу данных play-life.
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. Пользователь отправляет сообщение в чат с ботом в Telegram
|
||||
2. Telegram отправляет webhook на ваш сервер с информацией о сообщении и entities (форматирование)
|
||||
3. Сервер извлекает жирный текст из entities (type === 'bold')
|
||||
4. Парсит жирный текст по формату `project+/-score` (без `**`)
|
||||
5. Обрабатывает текст и сохраняет данные в базу данных
|
||||
6. **НЕ отправляет сообщение обратно в Telegram** (в отличие от других интеграций)
|
||||
|
||||
### Отличия от других интеграций
|
||||
|
||||
- **Формат нод**: `project+/-score` (без `**`), например: `Work+5.5` или `Health-2.0`
|
||||
- **Определение жирного текста**: через entities от Telegram, а не через markdown `**`
|
||||
- **Без обратной отправки**: сообщение не отправляется обратно в Telegram
|
||||
|
||||
### Настройка webhook в Telegram
|
||||
|
||||
#### Автоматическая настройка (рекомендуется)
|
||||
|
||||
1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram
|
||||
2. Получите токен бота и добавьте его в `.env`:
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
**Важно о портах:**
|
||||
- Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать
|
||||
- Если сервер работает на нестандартном порту и доступен напрямую, укажите порт: `http://your-server:8080`
|
||||
- Если используется reverse proxy (nginx, etc.), указывайте внешний URL без порта: `https://your-domain.com`
|
||||
|
||||
3. Запустите сервер - webhook будет настроен автоматически при старте!
|
||||
|
||||
Для локальной разработки можно использовать ngrok или аналогичный сервис:
|
||||
```bash
|
||||
# Установите ngrok: https://ngrok.com/
|
||||
ngrok http 8080
|
||||
# Используйте полученный URL в TELEGRAM_WEBHOOK_BASE_URL (без порта)
|
||||
# Например: TELEGRAM_WEBHOOK_BASE_URL=https://abc123.ngrok.io
|
||||
```
|
||||
|
||||
4. Проверьте логи сервера - должно появиться сообщение:
|
||||
```
|
||||
Telegram webhook configured successfully: https://abc123.ngrok.io/webhook/telegram
|
||||
```
|
||||
|
||||
#### Ручная настройка (если не указан TELEGRAM_WEBHOOK_BASE_URL)
|
||||
|
||||
Если вы не указали `TELEGRAM_WEBHOOK_BASE_URL`, webhook нужно настроить вручную:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"url": "http://your-server:8080/webhook/telegram"
|
||||
}'
|
||||
```
|
||||
|
||||
Проверьте, что webhook установлен:
|
||||
```bash
|
||||
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"
|
||||
```
|
||||
|
||||
### Формат сообщений в Telegram
|
||||
|
||||
Для корректной обработки сообщения должны содержать жирный текст в формате:
|
||||
```
|
||||
project+/-score
|
||||
```
|
||||
|
||||
Примеры:
|
||||
- `Work+5.5` (жирным) - добавить 5.5 баллов к проекту "Work"
|
||||
- `Health-2.0` (жирным) - вычесть 2.0 баллов из проекта "Health"
|
||||
|
||||
**Важно**: Текст должен быть выделен жирным шрифтом в Telegram (через форматирование сообщения, не через `**`).
|
||||
|
||||
### Тестирование
|
||||
|
||||
Для тестирования интеграции:
|
||||
|
||||
1. Откройте чат с вашим ботом в Telegram
|
||||
2. Отправьте сообщение с жирным текстом в формате `project+/-score`, например:
|
||||
- Напишите: `Test message`
|
||||
- Выделите `Work+10.0` жирным шрифтом (через форматирование)
|
||||
- Отправьте сообщение
|
||||
|
||||
3. Проверьте логи сервера - должно появиться сообщение:
|
||||
```
|
||||
Processing Telegram message: text='Test message', entities count=1
|
||||
Successfully processed Telegram message, found 1 nodes
|
||||
```
|
||||
|
||||
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||
|
||||
### Примечания
|
||||
|
||||
- Webhook должен быть доступен из интернета (для production используйте публичный URL)
|
||||
- Для локальной разработки используйте ngrok или аналогичный сервис для туннелирования
|
||||
- Сообщения обрабатываются только если содержат жирный текст в правильном формате
|
||||
- Сообщения **не отправляются обратно** в Telegram (в отличие от других интеграций)
|
||||
|
||||
25
build-and-save.ps1
Normal file
25
build-and-save.ps1
Normal file
@@ -0,0 +1,25 @@
|
||||
# PowerShell скрипт для сборки единого Docker образа и сохранения в tar
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$IMAGE_NAME = "play-life-unified"
|
||||
$IMAGE_TAG = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "latest" }
|
||||
$TAR_FILE = if ($env:TAR_FILE) { $env:TAR_FILE } else { "play-life-unified.tar" }
|
||||
|
||||
Write-Host "🔨 Сборка единого Docker образа..." -ForegroundColor Cyan
|
||||
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||
|
||||
Write-Host "💾 Сохранение образа в tar файл..." -ForegroundColor Cyan
|
||||
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||
|
||||
$fileSize = (Get-Item "${TAR_FILE}").Length / 1MB
|
||||
Write-Host "✅ Образ успешно сохранен в ${TAR_FILE}" -ForegroundColor Green
|
||||
Write-Host "📦 Размер файла: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Для загрузки образа на другой машине используйте:" -ForegroundColor Yellow
|
||||
Write-Host " docker load -i ${TAR_FILE}" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Для запуска контейнера используйте:" -ForegroundColor Yellow
|
||||
Write-Host " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}" -ForegroundColor White
|
||||
|
||||
26
build-and-save.sh
Normal file
26
build-and-save.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для сборки единого Docker образа и сохранения в tar
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_NAME="play-life-unified"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
TAR_FILE="${TAR_FILE:-play-life-unified.tar}"
|
||||
|
||||
echo "🔨 Сборка единого Docker образа..."
|
||||
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||
|
||||
echo "💾 Сохранение образа в tar файл..."
|
||||
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||
|
||||
echo "✅ Образ успешно сохранен в ${TAR_FILE}"
|
||||
echo "📦 Размер файла: $(du -h ${TAR_FILE} | cut -f1)"
|
||||
|
||||
echo ""
|
||||
echo "Для загрузки образа на другой машине используйте:"
|
||||
echo " docker load -i ${TAR_FILE}"
|
||||
echo ""
|
||||
echo "Для запуска контейнера используйте:"
|
||||
echo " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
version: '3.8'
|
||||
|
||||
# Единый docker-compose для всех приложений в одном образе
|
||||
# Использует корневой .env файл
|
||||
|
||||
services:
|
||||
# База данных PostgreSQL
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-playeng}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Backend сервер (Go)
|
||||
backend:
|
||||
build:
|
||||
context: ./play-life-backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-playeng}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
DB_NAME: ${DB_NAME:-playeng}
|
||||
PORT: ${PORT:-8080}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./play-life-backend/migrations:/migrations
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Frontend приложение play-life-web
|
||||
play-life-web:
|
||||
build:
|
||||
context: ./play-life-web
|
||||
dockerfile: Dockerfile
|
||||
container_name: play-life-web
|
||||
ports:
|
||||
- "${WEB_PORT:-3001}:80"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: play-life-network
|
||||
|
||||
61
env.example
Normal file
61
env.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# ============================================
|
||||
# Единый файл конфигурации для всех проектов
|
||||
# Backend и Play-Life-Web
|
||||
# ============================================
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=playeng
|
||||
DB_PASSWORD=playeng
|
||||
DB_NAME=playeng
|
||||
|
||||
# ============================================
|
||||
# Backend Server Configuration
|
||||
# ============================================
|
||||
# Порт для backend сервера (по умолчанию: 8080)
|
||||
# В production всегда используется порт 8080 внутри контейнера
|
||||
PORT=8080
|
||||
|
||||
# ============================================
|
||||
# Play Life Web Configuration
|
||||
# ============================================
|
||||
# Порт для frontend приложения play-life-web
|
||||
WEB_PORT=3001
|
||||
|
||||
# ============================================
|
||||
# Telegram Bot Configuration (optional)
|
||||
# ============================================
|
||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
||||
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||
# Look for "chat":{"id":123456789} - that number is your chat ID
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
# Base URL для автоматической настройки webhook
|
||||
# Примеры:
|
||||
# - Для production с HTTPS: https://your-domain.com
|
||||
# - Для локальной разработки с ngrok: https://abc123.ngrok.io
|
||||
# - Для прямого доступа на нестандартном порту: http://your-server:8080
|
||||
# Webhook будет настроен автоматически при старте сервера на: <TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram
|
||||
# Если не указан, webhook нужно настраивать вручную
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
|
||||
|
||||
# ============================================
|
||||
# Todoist Webhook Configuration (optional)
|
||||
# ============================================
|
||||
# Секрет для проверки подлинности webhook от Todoist
|
||||
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
||||
# Оставьте пустым, если не хотите использовать проверку секрета
|
||||
TODOIST_WEBHOOK_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Scheduler Configuration
|
||||
# ============================================
|
||||
# Часовой пояс для планировщика задач (например: Europe/Moscow, America/New_York, UTC)
|
||||
# Используется для автоматической фиксации целей на неделю каждый понедельник в 6:00
|
||||
# По умолчанию: UTC
|
||||
# Список доступных часовых поясов: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
TIMEZONE=UTC
|
||||
|
||||
76
nginx-unified.conf
Normal file
76
nginx-unified.conf
Normal file
@@ -0,0 +1,76 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# Proxy API requests to backend (localhost внутри контейнера)
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy webhook endpoints to backend
|
||||
location /webhook/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy daily-report endpoints to backend
|
||||
location /daily-report/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy other API endpoints to backend
|
||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|message/post|weekly_goals/setup|admin|admin\.html)$ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Include server configurations
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
|
||||
34
play-life-backend/.gitignore
vendored
Normal file
34
play-life-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Environment variables with secrets
|
||||
.env
|
||||
|
||||
# Go build artifacts
|
||||
main
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
57
play-life-backend/Dockerfile
Normal file
57
play-life-backend/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Multi-stage build для единого образа frontend + backend
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY play-life-web/package*.json ./
|
||||
RUN npm ci
|
||||
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||
COPY play-life-web/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.21-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY play-life-backend/ .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM alpine:latest
|
||||
|
||||
# Устанавливаем необходимые пакеты
|
||||
RUN apk --no-cache add \
|
||||
ca-certificates \
|
||||
nginx \
|
||||
supervisor \
|
||||
curl
|
||||
|
||||
# Создаем директории
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем собранный frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Копируем собранный backend
|
||||
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||
|
||||
# Копируем конфигурацию nginx
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Копируем конфигурацию supervisor для запуска backend
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Создаем директории для логов
|
||||
RUN mkdir -p /var/log/supervisor && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/run
|
||||
|
||||
# Открываем порт 80
|
||||
EXPOSE 80
|
||||
|
||||
# Запускаем supervisor, который запустит nginx и backend
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
76
play-life-backend/ENV_SETUP.md
Normal file
76
play-life-backend/ENV_SETUP.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Настройка переменных окружения
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Скопируйте файл `env.example` в `.env`:
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
2. Откройте `.env` и заполните реальные значения:
|
||||
```bash
|
||||
nano .env
|
||||
# или
|
||||
vim .env
|
||||
```
|
||||
|
||||
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Обязательные (для работы приложения)
|
||||
|
||||
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||
- `PORT` - порт сервера (по умолчанию: 8080)
|
||||
|
||||
### Опциональные (для Telegram интеграции)
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
|
||||
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
|
||||
|
||||
## Использование в коде
|
||||
|
||||
Приложение автоматически читает переменные окружения через `os.Getenv()`.
|
||||
|
||||
Для загрузки `.env` файла в локальной разработке можно использовать:
|
||||
|
||||
### Вариант 1: Установить переменные вручную
|
||||
```bash
|
||||
export DB_PASSWORD=your_password
|
||||
export TELEGRAM_BOT_TOKEN=your_token
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Вариант 2: Использовать библиотеку godotenv (рекомендуется)
|
||||
|
||||
1. Установить библиотеку:
|
||||
```bash
|
||||
go get github.com/joho/godotenv
|
||||
```
|
||||
|
||||
2. Добавить в начало `main()`:
|
||||
```go
|
||||
import "github.com/joho/godotenv"
|
||||
|
||||
func main() {
|
||||
// Загрузить .env файл
|
||||
godotenv.Load()
|
||||
// ... остальной код
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 3: Использовать docker-compose
|
||||
|
||||
В `docker-compose.yml` уже настроена передача переменных окружения из `.env` файла.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- ✅ Файл `.env` добавлен в `.gitignore`
|
||||
- ✅ Файл `env.example` содержит только шаблоны без реальных значений
|
||||
- ✅ Никогда не коммитьте `.env` в git
|
||||
- ✅ Используйте разные токены для dev/prod окружений
|
||||
|
||||
358
play-life-backend/admin.html
Normal file
358
play-life-backend/admin.html
Normal file
@@ -0,0 +1,358 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Play Life Backend - Admin Panel</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card textarea {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.card button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.card button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.result h3 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.result pre {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result.success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.result.loading {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎯 Play Life Backend - Admin Panel</h1>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Message Post Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
📨 Message Post
|
||||
<span class="status" id="messageStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
|
||||
|
||||
Пример:
|
||||
Сегодня работал над проектами:
|
||||
**Frontend+15.5**
|
||||
**Backend+8.0**
|
||||
**Design-2.5**"></textarea>
|
||||
<button onclick="sendMessage()">Отправить сообщение</button>
|
||||
<div id="messageResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Report Trigger Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
📈 Daily Report Trigger
|
||||
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<p style="margin-bottom: 15px; color: #666;">
|
||||
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59).
|
||||
</p>
|
||||
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
||||
<div id="dailyReportResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Goals Setup Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
🎯 Weekly Goals Setup
|
||||
<span class="status" id="goalsStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<p style="margin-bottom: 15px; color: #666;">
|
||||
Нажмите кнопку для установки целей на текущую неделю на основе медианы за последние 3 месяца (с отправкой в чат). Обычно срабатывает автоматически в начале недели.
|
||||
</p>
|
||||
<button onclick="setupWeeklyGoals()">Обновить цели</button>
|
||||
<div id="goalsResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getApiUrl() {
|
||||
// Автоматически определяем URL текущего хоста
|
||||
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function showStatus(elementId, status, text) {
|
||||
const statusEl = document.getElementById(elementId);
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = `status ${status}`;
|
||||
statusEl.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function hideStatus(elementId) {
|
||||
document.getElementById(elementId).style.display = 'none';
|
||||
}
|
||||
|
||||
function showResult(elementId, data, isError = false, isLoading = false) {
|
||||
const resultEl = document.getElementById(elementId);
|
||||
resultEl.innerHTML = '';
|
||||
|
||||
if (isLoading) {
|
||||
resultEl.innerHTML = '<div class="result loading"><h3>⏳ Загрузка...</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = `result ${isError ? 'error' : 'success'}`;
|
||||
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = isError ? '❌ Ошибка' : '✅ Успешно';
|
||||
div.appendChild(h3);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
div.appendChild(pre);
|
||||
|
||||
resultEl.appendChild(div);
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = document.getElementById('messageText').value.trim();
|
||||
if (!text) {
|
||||
alert('Пожалуйста, введите сообщение');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('messageStatus', 'loading', 'Отправка...');
|
||||
showResult('messageResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/message/post`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
text: text
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('messageStatus', 'success', 'Успешно');
|
||||
showResult('messageResult', data, false);
|
||||
} else {
|
||||
showStatus('messageStatus', 'error', 'Ошибка');
|
||||
showResult('messageResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('messageStatus', 'error', 'Ошибка');
|
||||
showResult('messageResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupWeeklyGoals() {
|
||||
showStatus('goalsStatus', 'loading', 'Обновление...');
|
||||
showResult('goalsResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('goalsStatus', 'success', 'Успешно');
|
||||
showResult('goalsResult', data, false);
|
||||
} else {
|
||||
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||
showResult('goalsResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||
showResult('goalsResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDailyReport() {
|
||||
showStatus('dailyReportStatus', 'loading', 'Отправка...');
|
||||
showResult('dailyReportResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('dailyReportStatus', 'success', 'Успешно');
|
||||
showResult('dailyReportResult', data, false);
|
||||
} else {
|
||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||
showResult('dailyReportResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||
showResult('dailyReportResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
|
||||
document.getElementById('messageText').addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
play-life-backend/docker-compose.yml
Normal file
43
play-life-backend/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-playeng}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
env_file:
|
||||
- ../.env
|
||||
- .env # Локальный .env имеет приоритет
|
||||
|
||||
backend:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-playeng}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
DB_NAME: ${DB_NAME:-playeng}
|
||||
PORT: ${PORT:-8080}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrations:/migrations
|
||||
env_file:
|
||||
- ../.env
|
||||
- .env # Локальный .env имеет приоритет
|
||||
|
||||
17
play-life-backend/env.example
Normal file
17
play-life-backend/env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=playeng
|
||||
DB_PASSWORD=playeng
|
||||
DB_NAME=playeng
|
||||
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
|
||||
# Telegram Bot Configuration (optional - for direct Telegram integration)
|
||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
||||
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||
# Look for "chat":{"id":123456789} - that number is your chat ID
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
14
play-life-backend/go.mod
Normal file
14
play-life-backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module play-eng-backend
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
)
|
||||
8
play-life-backend/go.sum
Normal file
8
play-life-backend/go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
3672
play-life-backend/main.go
Normal file
3672
play-life-backend/main.go
Normal file
File diff suppressed because it is too large
Load Diff
105
play-life-backend/migrations/001_create_schema.sql
Normal file
105
play-life-backend/migrations/001_create_schema.sql
Normal file
@@ -0,0 +1,105 @@
|
||||
-- Migration: Create database schema for play-life project
|
||||
-- This script creates all tables and materialized views needed for the project
|
||||
|
||||
-- ============================================
|
||||
-- Table: projects
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
priority SMALLINT,
|
||||
CONSTRAINT unique_project_name UNIQUE (name)
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- Table: entries
|
||||
-- ============================================
|
||||
-- This table stores entries with creation dates
|
||||
-- Used in weekly_report_mv for grouping by week
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- Table: nodes
|
||||
-- ============================================
|
||||
-- This table stores nodes linked to projects and entries
|
||||
-- Contains score information used in weekly reports
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
score NUMERIC(8,4)
|
||||
);
|
||||
|
||||
-- Create index on project_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id);
|
||||
-- Create index on entry_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id);
|
||||
|
||||
-- ============================================
|
||||
-- Table: weekly_goals
|
||||
-- ============================================
|
||||
-- This table stores weekly goals for projects
|
||||
CREATE TABLE IF NOT EXISTS weekly_goals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
goal_year INTEGER NOT NULL,
|
||||
goal_week INTEGER NOT NULL,
|
||||
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||
max_goal_score NUMERIC(10,4),
|
||||
actual_score NUMERIC(10,4) DEFAULT 0,
|
||||
priority SMALLINT,
|
||||
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
|
||||
);
|
||||
|
||||
-- Create index on project_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id);
|
||||
|
||||
-- ============================================
|
||||
-- Materialized View: weekly_report_mv
|
||||
-- ============================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS weekly_report_mv AS
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
agg.report_year,
|
||||
agg.report_week,
|
||||
-- Используем COALESCE для установки total_score в 0.0000, если нет данных (NULL)
|
||||
COALESCE(agg.total_score, 0.0000) AS total_score
|
||||
FROM
|
||||
projects p
|
||||
LEFT JOIN
|
||||
(
|
||||
-- 1. Предварительная агрегация: суммируем score по неделям
|
||||
SELECT
|
||||
n.project_id,
|
||||
EXTRACT(YEAR FROM e.created_date)::INTEGER AS report_year,
|
||||
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||
SUM(n.score) AS total_score
|
||||
FROM
|
||||
nodes n
|
||||
JOIN
|
||||
entries e ON n.entry_id = e.id
|
||||
GROUP BY
|
||||
1, 2, 3
|
||||
) agg
|
||||
-- 2. Присоединяем агрегированные данные ко ВСЕМ проектам
|
||||
ON p.id = agg.project_id
|
||||
ORDER BY
|
||||
p.id, agg.report_year, agg.report_week;
|
||||
|
||||
-- Create index on materialized view for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||
ON weekly_report_mv(project_id, report_year, report_week);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE projects IS 'Projects table storing project information with priority';
|
||||
COMMENT ON TABLE entries IS 'Entries table storing entry creation timestamps';
|
||||
COMMENT ON TABLE nodes IS 'Nodes table linking projects, entries and storing scores';
|
||||
COMMENT ON TABLE weekly_goals IS 'Weekly goals for projects';
|
||||
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project';
|
||||
|
||||
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration: Add dictionaries table and dictionary_id to words
|
||||
-- This script creates the dictionaries table and adds dictionary_id field to words table
|
||||
|
||||
-- ============================================
|
||||
-- Table: dictionaries
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS dictionaries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Insert default dictionary "Все слова" with id = 0
|
||||
-- Note: PostgreSQL SERIAL starts from 1, so we need to use a workaround
|
||||
-- First, set the sequence to allow inserting 0, then insert, then reset sequence
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Set sequence to -1 so next value will be 0
|
||||
PERFORM setval('dictionaries_id_seq', -1, false);
|
||||
|
||||
-- Insert the default dictionary with id = 0
|
||||
INSERT INTO dictionaries (id, name)
|
||||
VALUES (0, 'Все слова')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Set the sequence to start from 1 (so next auto-increment will be 1)
|
||||
PERFORM setval('dictionaries_id_seq', 1, false);
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- Alter words table: Add dictionary_id column
|
||||
-- ============================================
|
||||
ALTER TABLE words
|
||||
ADD COLUMN IF NOT EXISTS dictionary_id INTEGER DEFAULT 0 REFERENCES dictionaries(id);
|
||||
|
||||
-- Update all existing words to have dictionary_id = 0
|
||||
UPDATE words
|
||||
SET dictionary_id = 0
|
||||
WHERE dictionary_id IS NULL;
|
||||
|
||||
-- Make dictionary_id NOT NULL after setting default values
|
||||
ALTER TABLE words
|
||||
ALTER COLUMN dictionary_id SET NOT NULL,
|
||||
ALTER COLUMN dictionary_id SET DEFAULT 0;
|
||||
|
||||
-- Create index on dictionary_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE dictionaries IS 'Dictionaries table storing dictionary information';
|
||||
COMMENT ON COLUMN words.dictionary_id IS 'Reference to dictionary. Default is 0 (Все слова)';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Remove UNIQUE constraint from words.name
|
||||
-- This script removes the unique constraint on the name column in the words table
|
||||
|
||||
-- Drop the unique constraint on words.name if it exists
|
||||
ALTER TABLE words
|
||||
DROP CONSTRAINT IF EXISTS words_name_key;
|
||||
|
||||
-- Also try to drop constraint if it was created with different name
|
||||
ALTER TABLE words
|
||||
DROP CONSTRAINT IF EXISTS words_name_unique;
|
||||
|
||||
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration: Add config_dictionaries table (many-to-many relationship)
|
||||
-- This script creates the config_dictionaries table linking configs and dictionaries
|
||||
|
||||
-- ============================================
|
||||
-- Table: config_dictionaries
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS config_dictionaries (
|
||||
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
||||
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (config_id, dictionary_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE config_dictionaries IS 'Many-to-many relationship table linking configs and dictionaries. If no dictionaries are selected for a config, all dictionaries will be used.';
|
||||
|
||||
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Migration: Fix weekly_report_mv to use ISOYEAR instead of YEAR
|
||||
-- This fixes incorrect week calculations at year boundaries
|
||||
-- Date: 2024
|
||||
|
||||
-- Drop existing materialized view
|
||||
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||
|
||||
-- Recreate materialized view with ISOYEAR
|
||||
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||
SELECT
|
||||
n.project_id,
|
||||
-- 🔑 ГЛАВНОЕ ИСПРАВЛЕНИЕ: Используем ISOYEAR
|
||||
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||
SUM(n.score) AS total_score
|
||||
FROM
|
||||
nodes n
|
||||
JOIN
|
||||
entries e ON n.entry_id = e.id
|
||||
GROUP BY
|
||||
1, 2, 3
|
||||
WITH DATA;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||
ON weekly_report_mv(project_id, report_year, report_week);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations';
|
||||
|
||||
81
play-life-backend/migrations/README.md
Normal file
81
play-life-backend/migrations/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Database Migrations
|
||||
|
||||
Этот каталог содержит SQL миграции для создания структуры базы данных проекта play-life.
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание базы данных с нуля
|
||||
|
||||
Выполните миграцию для создания всех таблиц и представлений:
|
||||
|
||||
```bash
|
||||
psql -U your_user -d your_database -f 001_create_schema.sql
|
||||
```
|
||||
|
||||
Или через docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_schema.sql
|
||||
```
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблицы
|
||||
|
||||
1. **projects** - Проекты
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `name` (VARCHAR(255) NOT NULL, UNIQUE)
|
||||
- `priority` (SMALLINT)
|
||||
|
||||
2. **entries** - Записи с текстом и датами создания
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `text` (TEXT NOT NULL)
|
||||
- `created_date` (TIMESTAMP WITH TIME ZONE NOT NULL, DEFAULT CURRENT_TIMESTAMP)
|
||||
|
||||
3. **nodes** - Узлы, связывающие проекты и записи
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||
- `entry_id` (INTEGER NOT NULL, FK -> entries.id ON DELETE CASCADE)
|
||||
- `score` (NUMERIC(8,4))
|
||||
|
||||
4. **weekly_goals** - Недельные цели для проектов
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||
- `goal_year` (INTEGER NOT NULL)
|
||||
- `goal_week` (INTEGER NOT NULL)
|
||||
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
||||
- `max_goal_score` (NUMERIC(10,4))
|
||||
- `actual_score` (NUMERIC(10,4), DEFAULT 0)
|
||||
- `priority` (SMALLINT)
|
||||
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||
|
||||
### Materialized View
|
||||
|
||||
- **weekly_report_mv** - Агрегированные данные по неделям для каждого проекта
|
||||
- `project_id` (INTEGER)
|
||||
- `report_year` (INTEGER)
|
||||
- `report_week` (INTEGER)
|
||||
- `total_score` (NUMERIC)
|
||||
|
||||
## Обновление Materialized View
|
||||
|
||||
После изменения данных в таблицах `nodes` или `entries`, необходимо обновить materialized view:
|
||||
|
||||
```sql
|
||||
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||
```
|
||||
|
||||
## Связи между таблицами
|
||||
|
||||
- `nodes.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
- `nodes.entry_id` → `entries.id` (ON DELETE CASCADE)
|
||||
- `weekly_goals.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
|
||||
## Индексы
|
||||
|
||||
Созданы индексы для оптимизации запросов:
|
||||
- `idx_nodes_project_id` на `nodes(project_id)`
|
||||
- `idx_nodes_entry_id` на `nodes(entry_id)`
|
||||
- `idx_weekly_goals_project_id` на `weekly_goals(project_id)`
|
||||
- `idx_weekly_report_mv_project_year_week` на `weekly_report_mv(project_id, report_year, report_week)`
|
||||
|
||||
20
play-life-backend/start_backend.sh
Normal file
20
play-life-backend/start_backend.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Настройки подключения к БД (можно изменить через переменные окружения)
|
||||
export DB_HOST=${DB_HOST:-localhost}
|
||||
export DB_PORT=${DB_PORT:-5432}
|
||||
export DB_USER=${DB_USER:-postgres}
|
||||
export DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
export DB_NAME=${DB_NAME:-playlife}
|
||||
export PORT=${PORT:-8080}
|
||||
|
||||
echo "Starting backend server..."
|
||||
echo "DB_HOST: $DB_HOST"
|
||||
echo "DB_PORT: $DB_PORT"
|
||||
echo "DB_USER: $DB_USER"
|
||||
echo "DB_NAME: $DB_NAME"
|
||||
echo "PORT: $PORT"
|
||||
echo ""
|
||||
|
||||
go run main.go
|
||||
12
play-life-web/.dockerignore
Normal file
12
play-life-web/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
30
play-life-web/.gitignore
vendored
Normal file
30
play-life-web/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
30
play-life-web/Dockerfile
Normal file
30
play-life-web/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
105
play-life-web/README.md
Normal file
105
play-life-web/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# PlayLifeWeb
|
||||
|
||||
Веб-приложение для отображения статистики проектов.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Текущая неделя**: Отображение статистики на текущий момент с ProgressBar для каждого проекта
|
||||
- **Полная статистика**: График нарастающей статистики по всем проектам
|
||||
|
||||
## Технологии
|
||||
|
||||
- React 18
|
||||
- Vite
|
||||
- Chart.js (react-chartjs-2)
|
||||
- Tailwind CSS
|
||||
- Docker
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Локальная разработка
|
||||
|
||||
1. Установите зависимости:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Запустите dev-сервер:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу `http://localhost:3000`
|
||||
|
||||
### Сборка для production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Запуск через Docker
|
||||
|
||||
1. Создайте файл `.env` в корне проекта (можно скопировать из `env.example`):
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
2. Соберите образ:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
3. Запустите контейнер:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу `http://localhost:3000`
|
||||
|
||||
**Примечание:** API запросы автоматически проксируются к бэкенду через nginx. Не требуется настройка URL API.
|
||||
|
||||
### Остановка Docker контейнера
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
play-life-web/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── CurrentWeek.jsx # Компонент текущей недели
|
||||
│ │ ├── FullStatistics.jsx # Компонент полной статистики
|
||||
│ │ └── ProjectProgressBar.jsx # Компонент ProgressBar
|
||||
│ ├── App.jsx # Главный компонент приложения
|
||||
│ ├── main.jsx # Точка входа
|
||||
│ └── index.css # Глобальные стили
|
||||
├── Dockerfile # Docker образ
|
||||
├── docker-compose.yml # Docker Compose конфигурация
|
||||
├── nginx.conf # Nginx конфигурация
|
||||
└── package.json # Зависимости проекта
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Приложение использует относительные пути для API запросов. Проксирование настроено автоматически:
|
||||
|
||||
- **Development**: Vite dev server проксирует запросы к `http://localhost:8080`
|
||||
- **Production**: Nginx проксирует запросы к бэкенд контейнеру
|
||||
|
||||
Endpoints, которые используются:
|
||||
- `/playlife-feed` - данные текущей недели
|
||||
- `/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b` - полная статистика
|
||||
- `/projects` - список проектов
|
||||
- `/project/priority` - обновление приоритетов проектов
|
||||
- `/api/*` - остальные API endpoints (слова, конфигурации, тесты)
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
- ProgressBar отображает текущее значение (`total_score`) и выделяет диапазон целей (`min_goal_score` - `max_goal_score`)
|
||||
- График полной статистики показывает нарастающую сумму баллов по неделям
|
||||
- Все проекты отображаются на одном графике с разными цветами
|
||||
- Адаптивный дизайн для различных размеров экранов
|
||||
|
||||
42
play-life-web/build-and-save.sh
Normal file
42
play-life-web/build-and-save.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Добавляем Docker в PATH
|
||||
export PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH"
|
||||
|
||||
echo "Ожидание запуска Docker daemon..."
|
||||
# Ждем до 60 секунд, пока Docker daemon запустится
|
||||
for i in {1..60}; do
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
echo "Docker daemon запущен!"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "Ошибка: Docker daemon не запустился. Пожалуйста, запустите Docker Desktop вручную."
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Сборка Docker образа..."
|
||||
docker build \
|
||||
-t play-life-web:latest .
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Образ успешно собран!"
|
||||
echo "Сохранение образа в play-life-web.tar..."
|
||||
docker save play-life-web:latest -o play-life-web.tar
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Образ успешно сохранен в play-life-web.tar"
|
||||
ls -lh play-life-web.tar
|
||||
else
|
||||
echo "Ошибка при сохранении образа"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Ошибка при сборке образа"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
29
play-life-web/build-docker-image.sh
Normal file
29
play-life-web/build-docker-image.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для сборки Docker образа и сохранения в .tar файл
|
||||
|
||||
IMAGE_NAME="play-life-web"
|
||||
IMAGE_TAG="latest"
|
||||
TAR_FILE="play-life-web.tar"
|
||||
|
||||
echo "Сборка Docker образа..."
|
||||
docker build \
|
||||
-t "$IMAGE_NAME:$IMAGE_TAG" .
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Образ успешно собран!"
|
||||
echo "Сохранение образа в $TAR_FILE..."
|
||||
docker save "$IMAGE_NAME:$IMAGE_TAG" -o "$TAR_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Образ успешно сохранен в $TAR_FILE"
|
||||
ls -lh "$TAR_FILE"
|
||||
else
|
||||
echo "Ошибка при сохранении образа"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Ошибка при сборке образа"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
21
play-life-web/docker-compose.yml
Normal file
21
play-life-web/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
play-life-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: play-life-web
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- play-life-network
|
||||
env_file:
|
||||
- ../.env
|
||||
- .env # Локальный .env имеет приоритет
|
||||
|
||||
networks:
|
||||
play-life-network:
|
||||
driver: bridge
|
||||
|
||||
6
play-life-web/env.example
Normal file
6
play-life-web/env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# API URLs для PlayLifeWeb
|
||||
# Скопируйте этот файл в .env и укажите свои значения
|
||||
|
||||
# Play Life Web Port (по умолчанию: 3000)
|
||||
WEB_PORT=3000
|
||||
|
||||
14
play-life-web/index.html
Normal file
14
play-life-web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>PlayLife - Статистика</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
50
play-life-web/nginx.conf
Normal file
50
play-life-web/nginx.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy other API endpoints to backend
|
||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
2706
play-life-web/package-lock.json
generated
Normal file
2706
play-life-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
play-life-web/package.json
Normal file
28
play-life-web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
7
play-life-web/postcss.config.js
Normal file
7
play-life-web/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
531
play-life-web/src/App.jsx
Normal file
531
play-life-web/src/App.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import CurrentWeek from './components/CurrentWeek'
|
||||
import FullStatistics from './components/FullStatistics'
|
||||
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||
import WordList from './components/WordList'
|
||||
import AddWords from './components/AddWords'
|
||||
import TestConfigSelection from './components/TestConfigSelection'
|
||||
import AddConfig from './components/AddConfig'
|
||||
import TestWords from './components/TestWords'
|
||||
|
||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('current')
|
||||
const [selectedProject, setSelectedProject] = useState(null)
|
||||
const [loadedTabs, setLoadedTabs] = useState({
|
||||
current: false,
|
||||
priorities: false,
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
})
|
||||
|
||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||
const [tabsInitialized, setTabsInitialized] = useState({
|
||||
current: false,
|
||||
priorities: false,
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
})
|
||||
|
||||
// Параметры для навигации между вкладками
|
||||
const [tabParams, setTabParams] = useState({})
|
||||
|
||||
// Кеширование данных
|
||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||
|
||||
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
||||
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
||||
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
||||
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
||||
|
||||
// Состояния фоновой загрузки (не показываются визуально)
|
||||
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
||||
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
||||
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
||||
|
||||
// Ошибки
|
||||
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
||||
const [prioritiesError, setPrioritiesError] = useState(null)
|
||||
|
||||
// Состояние для кнопки Refresh (если она есть)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||
|
||||
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) return
|
||||
|
||||
try {
|
||||
const savedTab = window.localStorage?.getItem('activeTab')
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test']
|
||||
if (savedTab && validTabs.includes(savedTab)) {
|
||||
setActiveTab(savedTab)
|
||||
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||
setIsInitialized(true)
|
||||
} else {
|
||||
setIsInitialized(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Не удалось прочитать активный таб из localStorage', err)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized])
|
||||
|
||||
const markTabAsLoaded = useCallback((tab) => {
|
||||
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
|
||||
}, [])
|
||||
|
||||
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
|
||||
try {
|
||||
if (isBackground) {
|
||||
setCurrentWeekBackgroundLoading(true)
|
||||
} else {
|
||||
setCurrentWeekLoading(true)
|
||||
}
|
||||
setCurrentWeekError(null)
|
||||
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
|
||||
const response = await fetch(CURRENT_WEEK_API_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
}
|
||||
const jsonData = await response.json()
|
||||
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
|
||||
let projects = []
|
||||
let total = null
|
||||
|
||||
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
||||
// Если ответ - массив, проверяем первый элемент
|
||||
const firstItem = jsonData[0]
|
||||
if (firstItem && typeof firstItem === 'object') {
|
||||
// Если первый элемент - объект с полями total и projects
|
||||
if (firstItem.projects && Array.isArray(firstItem.projects)) {
|
||||
projects = firstItem.projects
|
||||
total = firstItem.total !== undefined ? firstItem.total : null
|
||||
} else {
|
||||
// Если это просто массив проектов
|
||||
projects = jsonData
|
||||
}
|
||||
} else {
|
||||
// Если это массив проектов напрямую
|
||||
projects = jsonData
|
||||
}
|
||||
} else if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) {
|
||||
// Если ответ - объект напрямую
|
||||
projects = jsonData.projects || jsonData.data || []
|
||||
total = jsonData.total !== undefined ? jsonData.total : null
|
||||
}
|
||||
|
||||
setCurrentWeekData({
|
||||
projects: Array.isArray(projects) ? projects : [],
|
||||
total: total
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentWeekError(err.message)
|
||||
console.error('Ошибка загрузки данных текущей недели:', err)
|
||||
} finally {
|
||||
if (isBackground) {
|
||||
setCurrentWeekBackgroundLoading(false)
|
||||
} else {
|
||||
setCurrentWeekLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
||||
try {
|
||||
if (isBackground) {
|
||||
setFullStatisticsBackgroundLoading(true)
|
||||
} else {
|
||||
setFullStatisticsLoading(true)
|
||||
}
|
||||
setFullStatisticsError(null)
|
||||
const response = await fetch(FULL_STATISTICS_API_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
}
|
||||
const jsonData = await response.json()
|
||||
setFullStatisticsData(jsonData)
|
||||
} catch (err) {
|
||||
setFullStatisticsError(err.message)
|
||||
console.error('Ошибка загрузки данных полной статистики:', err)
|
||||
} finally {
|
||||
if (isBackground) {
|
||||
setFullStatisticsBackgroundLoading(false)
|
||||
} else {
|
||||
setFullStatisticsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||
const tabsInitializedRef = useRef({
|
||||
current: false,
|
||||
priorities: false,
|
||||
full: false,
|
||||
words: false,
|
||||
'add-words': false,
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
})
|
||||
|
||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||
const cacheRef = useRef({
|
||||
current: null,
|
||||
full: null,
|
||||
})
|
||||
|
||||
// Обновляем ref при изменении данных
|
||||
useEffect(() => {
|
||||
cacheRef.current.current = currentWeekData
|
||||
}, [currentWeekData])
|
||||
|
||||
useEffect(() => {
|
||||
cacheRef.current.full = fullStatisticsData
|
||||
}, [fullStatisticsData])
|
||||
|
||||
// Функция для загрузки данных таба
|
||||
const loadTabData = useCallback((tab, isBackground = false) => {
|
||||
if (tab === 'current') {
|
||||
const hasCache = cacheRef.current.current !== null
|
||||
const isInitialized = tabsInitializedRef.current.current
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба - загружаем с индикатором
|
||||
fetchCurrentWeekData(false)
|
||||
tabsInitializedRef.current.current = true
|
||||
setTabsInitialized(prev => ({ ...prev, current: true }))
|
||||
} else if (hasCache && isBackground) {
|
||||
// Возврат на таб с кешем - фоновая загрузка
|
||||
fetchCurrentWeekData(true)
|
||||
}
|
||||
// Если нет кеша и это не первая загрузка - ничего не делаем (данные уже загружаются)
|
||||
} else if (tab === 'full') {
|
||||
const hasCache = cacheRef.current.full !== null
|
||||
const isInitialized = tabsInitializedRef.current.full
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба - загружаем с индикатором
|
||||
fetchFullStatisticsData(false)
|
||||
tabsInitializedRef.current.full = true
|
||||
setTabsInitialized(prev => ({ ...prev, full: true }))
|
||||
} else if (hasCache && isBackground) {
|
||||
// Возврат на таб с кешем - фоновая загрузка
|
||||
fetchFullStatisticsData(true)
|
||||
}
|
||||
} else if (tab === 'priorities') {
|
||||
const isInitialized = tabsInitializedRef.current.priorities
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба
|
||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current.priorities = true
|
||||
setTabsInitialized(prev => ({ ...prev, priorities: true }))
|
||||
} else if (isBackground) {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'test-config') {
|
||||
const isInitialized = tabsInitializedRef.current['test-config']
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
tabsInitializedRef.current['test-config'] = true
|
||||
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
||||
} else if (isBackground) {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||
|
||||
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
||||
const refreshAllData = useCallback(async () => {
|
||||
setIsRefreshing(true)
|
||||
setPrioritiesError(null)
|
||||
setCurrentWeekError(null)
|
||||
setFullStatisticsError(null)
|
||||
|
||||
// Триггерим обновление приоритетов
|
||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||
|
||||
// Загружаем все данные параллельно (не фоново)
|
||||
await Promise.all([
|
||||
fetchCurrentWeekData(false),
|
||||
fetchFullStatisticsData(false),
|
||||
])
|
||||
|
||||
setIsRefreshing(false)
|
||||
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||
|
||||
// Обновляем данные при возвращении экрана в фокус (фоново)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Загружаем данные активного таба фоново
|
||||
loadTabData(activeTab, true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleFocus)
|
||||
document.addEventListener('visibilitychange', handleFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus)
|
||||
document.removeEventListener('visibilitychange', handleFocus)
|
||||
}
|
||||
}, [activeTab, loadTabData])
|
||||
|
||||
const handleProjectClick = (projectName) => {
|
||||
setSelectedProject(projectName)
|
||||
markTabAsLoaded('full')
|
||||
setActiveTab('full')
|
||||
}
|
||||
|
||||
const handleTabChange = (tab, params = {}) => {
|
||||
if (tab === 'full' && activeTab === 'full') {
|
||||
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
||||
setSelectedProject(null)
|
||||
} else if (tab !== activeTab) {
|
||||
markTabAsLoaded(tab)
|
||||
// Сбрасываем tabParams при переходе с add-config на другой таб
|
||||
if (activeTab === 'add-config' && tab !== 'add-config') {
|
||||
setTabParams({})
|
||||
} else {
|
||||
setTabParams(params)
|
||||
}
|
||||
setActiveTab(tab)
|
||||
if (tab === 'current') {
|
||||
setSelectedProject(null)
|
||||
}
|
||||
// Обновляем список слов при возврате из экрана добавления слов
|
||||
if (activeTab === 'add-words' && tab === 'words') {
|
||||
setWordsRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик навигации для компонентов
|
||||
const handleNavigate = (tab, params = {}) => {
|
||||
handleTabChange(tab, params)
|
||||
}
|
||||
|
||||
// Загружаем данные при открытии таба (когда таб становится активным)
|
||||
const prevActiveTabRef = useRef(null)
|
||||
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTab || !loadedTabs[activeTab]) return
|
||||
|
||||
const isFirstLoad = !tabsInitializedRef.current[activeTab]
|
||||
const isReturningToTab = prevActiveTabRef.current !== null && prevActiveTabRef.current !== activeTab
|
||||
|
||||
// Проверяем, не загружали ли мы уже этот таб в этом рендере
|
||||
const tabKey = `${activeTab}-${isFirstLoad ? 'first' : 'return'}`
|
||||
if (lastLoadedTabRef.current === tabKey) {
|
||||
return // Уже загружали
|
||||
}
|
||||
|
||||
if (isFirstLoad) {
|
||||
// Первая загрузка таба
|
||||
lastLoadedTabRef.current = tabKey
|
||||
loadTabData(activeTab, false)
|
||||
} else if (isReturningToTab) {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
lastLoadedTabRef.current = tabKey
|
||||
loadTabData(activeTab, true)
|
||||
}
|
||||
|
||||
prevActiveTabRef.current = activeTab
|
||||
}, [activeTab, loadedTabs, loadTabData])
|
||||
|
||||
// Определяем общее состояние загрузки и ошибок для кнопки Refresh
|
||||
const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing
|
||||
const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError
|
||||
|
||||
// Сохраняем выбранный таб, чтобы восстановить его после перезагрузки
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage?.setItem('activeTab', activeTab)
|
||||
} catch (err) {
|
||||
console.warn('Не удалось сохранить активный таб в localStorage', err)
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen min-h-dvh">
|
||||
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
|
||||
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
|
||||
{loadedTabs.current && (
|
||||
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
|
||||
<CurrentWeek
|
||||
onProjectClick={handleProjectClick}
|
||||
data={currentWeekData}
|
||||
loading={currentWeekLoading}
|
||||
error={currentWeekError}
|
||||
onRetry={fetchCurrentWeekData}
|
||||
allProjectsData={fullStatisticsData}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.priorities && (
|
||||
<div className={activeTab === 'priorities' ? 'block' : 'hidden'}>
|
||||
<ProjectPriorityManager
|
||||
allProjectsData={fullStatisticsData}
|
||||
currentWeekData={currentWeekData}
|
||||
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
|
||||
onLoadingChange={setPrioritiesLoading}
|
||||
onErrorChange={setPrioritiesError}
|
||||
refreshTrigger={prioritiesRefreshTrigger}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.full && (
|
||||
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
|
||||
<FullStatistics
|
||||
selectedProject={selectedProject}
|
||||
onClearSelection={() => setSelectedProject(null)}
|
||||
data={fullStatisticsData}
|
||||
loading={fullStatisticsLoading}
|
||||
error={fullStatisticsError}
|
||||
onRetry={fetchFullStatisticsData}
|
||||
currentWeekData={currentWeekData}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.words && (
|
||||
<div className={activeTab === 'words' ? 'block' : 'hidden'}>
|
||||
<WordList
|
||||
onNavigate={handleNavigate}
|
||||
dictionaryId={tabParams.dictionaryId}
|
||||
isNewDictionary={tabParams.isNewDictionary}
|
||||
refreshTrigger={wordsRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['add-words'] && (
|
||||
<div className={activeTab === 'add-words' ? 'block' : 'hidden'}>
|
||||
<AddWords
|
||||
onNavigate={handleNavigate}
|
||||
dictionaryId={tabParams.dictionaryId}
|
||||
dictionaryName={tabParams.dictionaryName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['test-config'] && (
|
||||
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
||||
<TestConfigSelection
|
||||
onNavigate={handleNavigate}
|
||||
refreshTrigger={testConfigRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['add-config'] && (
|
||||
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
||||
<AddConfig
|
||||
key={tabParams.config?.id || 'new'}
|
||||
onNavigate={handleNavigate}
|
||||
editingConfig={tabParams.config}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.test && (
|
||||
<div className={activeTab === 'test' ? 'block' : 'hidden'}>
|
||||
<TestWords
|
||||
onNavigate={handleNavigate}
|
||||
wordCount={tabParams.wordCount}
|
||||
configId={tabParams.configId}
|
||||
maxCards={tabParams.maxCards}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFullscreenTab && (
|
||||
<div className="sticky bottom-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center relative w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => handleTabChange('current')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
activeTab === 'current'
|
||||
? 'text-indigo-700 bg-white/50'
|
||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Неделя"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
</span>
|
||||
{activeTab === 'current' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('test-config')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
activeTab === 'test-config' || activeTab === 'test'
|
||||
? 'text-indigo-700 bg-white/50'
|
||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Тест"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
<path d="M8 7h6"></path>
|
||||
<path d="M8 11h4"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{(activeTab === 'test-config' || activeTab === 'test') && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
222
play-life-web/src/components/AddConfig.css
Normal file
222
play-life-web/src/components/AddConfig.css
Normal file
@@ -0,0 +1,222 @@
|
||||
.add-config {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.add-config {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-config h2 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #2c3e50;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.stepper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepper-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepper-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.stepper-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stepper-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stepper-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dictionaries-hint {
|
||||
font-size: 0.875rem;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dictionaries-checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label:hover {
|
||||
background-color: #e8f4f8;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
margin: 0;
|
||||
margin-right: 0.75rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #3498db;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dictionary-checkbox-label span {
|
||||
color: #2c3e50;
|
||||
font-size: 0.95rem;
|
||||
line-height: 18px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
344
play-life-web/src/components/AddConfig.jsx
Normal file
344
play-life-web/src/components/AddConfig.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import './AddConfig.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
const [name, setName] = useState('')
|
||||
const [tryMessage, setTryMessage] = useState('')
|
||||
const [wordsCount, setWordsCount] = useState('10')
|
||||
const [maxCards, setMaxCards] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
|
||||
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
|
||||
|
||||
// Load dictionaries
|
||||
useEffect(() => {
|
||||
const loadDictionaries = async () => {
|
||||
setLoadingDictionaries(true)
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
const data = await response.json()
|
||||
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load dictionaries:', err)
|
||||
} finally {
|
||||
setLoadingDictionaries(false)
|
||||
}
|
||||
}
|
||||
loadDictionaries()
|
||||
}, [])
|
||||
|
||||
// Load selected dictionaries when editing
|
||||
useEffect(() => {
|
||||
const loadSelectedDictionaries = async () => {
|
||||
if (initialEditingConfig?.id) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load selected dictionaries:', err)
|
||||
}
|
||||
} else {
|
||||
setSelectedDictionaryIds([])
|
||||
}
|
||||
}
|
||||
loadSelectedDictionaries()
|
||||
}, [initialEditingConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEditingConfig) {
|
||||
setName(initialEditingConfig.name)
|
||||
setTryMessage(initialEditingConfig.try_message)
|
||||
setWordsCount(String(initialEditingConfig.words_count))
|
||||
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
|
||||
} else {
|
||||
// Сбрасываем состояние при открытии в режиме добавления
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
setSelectedDictionaryIds([])
|
||||
}
|
||||
}, [initialEditingConfig])
|
||||
|
||||
// Сбрасываем состояние при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage('')
|
||||
setLoading(true)
|
||||
|
||||
if (!name.trim()) {
|
||||
setMessage('Имя обязательно для заполнения.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = initialEditingConfig
|
||||
? `${API_URL}/configs/${initialEditingConfig.id}`
|
||||
: `${API_URL}/configs`
|
||||
const method = initialEditingConfig ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
try_message: tryMessage.trim() || '',
|
||||
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
|
||||
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
|
||||
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (!initialEditingConfig) {
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
}
|
||||
|
||||
// Navigate back immediately
|
||||
onNavigate?.('test-config')
|
||||
} catch (error) {
|
||||
setMessage(`Ошибка: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getNumericValue = () => {
|
||||
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
|
||||
}
|
||||
|
||||
const getMaxCardsNumericValue = () => {
|
||||
return maxCards === '' ? 0 : parseInt(maxCards) || 0
|
||||
}
|
||||
|
||||
const handleDecrease = () => {
|
||||
const numValue = getNumericValue()
|
||||
if (numValue > 0) {
|
||||
setWordsCount(String(numValue - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIncrease = () => {
|
||||
const numValue = getNumericValue()
|
||||
setWordsCount(String(numValue + 1))
|
||||
}
|
||||
|
||||
const handleMaxCardsDecrease = () => {
|
||||
const numValue = getMaxCardsNumericValue()
|
||||
if (numValue > 0) {
|
||||
setMaxCards(String(numValue - 1))
|
||||
} else {
|
||||
setMaxCards('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaxCardsIncrease = () => {
|
||||
const numValue = getMaxCardsNumericValue()
|
||||
const newValue = numValue + 1
|
||||
setMaxCards(String(newValue))
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// Сбрасываем состояние при закрытии
|
||||
setName('')
|
||||
setTryMessage('')
|
||||
setWordsCount('10')
|
||||
setMaxCards('')
|
||||
setMessage('')
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-config">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
<h2>Конфигурация теста</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Имя</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Название конфига"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
|
||||
<textarea
|
||||
id="tryMessage"
|
||||
className="form-textarea"
|
||||
value={tryMessage}
|
||||
onChange={(e) => setTryMessage(e.target.value)}
|
||||
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="wordsCount">Кол-во слов</label>
|
||||
<div className="stepper-container">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleDecrease}
|
||||
disabled={getNumericValue() <= 0}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
id="wordsCount"
|
||||
type="number"
|
||||
className="stepper-input"
|
||||
value={wordsCount}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value
|
||||
if (inputValue === '') {
|
||||
setWordsCount('')
|
||||
} else {
|
||||
const numValue = parseInt(inputValue)
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setWordsCount(inputValue)
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleIncrease}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
|
||||
<div className="stepper-container">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleMaxCardsDecrease}
|
||||
disabled={getMaxCardsNumericValue() <= 0}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
id="maxCards"
|
||||
type="number"
|
||||
className="stepper-input"
|
||||
value={maxCards}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value
|
||||
if (inputValue === '') {
|
||||
setMaxCards('')
|
||||
} else {
|
||||
const numValue = parseInt(inputValue)
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setMaxCards(inputValue)
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
onClick={handleMaxCardsIncrease}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="dictionaries">Словари (необязательно)</label>
|
||||
<div className="dictionaries-hint">
|
||||
Если не выбрано ни одного словаря, будут использоваться все словари
|
||||
</div>
|
||||
{loadingDictionaries ? (
|
||||
<div>Загрузка словарей...</div>
|
||||
) : (
|
||||
<div className="dictionaries-checkbox-list">
|
||||
{dictionaries.map((dict) => (
|
||||
<label key={dict.id} className="dictionary-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDictionaryIds.includes(dict.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
|
||||
} else {
|
||||
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{dict.name} ({dict.wordsCount})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={loading || !name.trim() || getNumericValue() === 0}
|
||||
>
|
||||
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddConfig
|
||||
|
||||
106
play-life-web/src/components/AddWords.css
Normal file
106
play-life-web/src/components/AddWords.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.add-words {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.add-words {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-words h2 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #2c3e50;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.markdown-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
163
play-life-web/src/components/AddWords.jsx
Normal file
163
play-life-web/src/components/AddWords.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react'
|
||||
import './AddWords.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||
const [markdownText, setMarkdownText] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Hide add button if dictionary name is not set
|
||||
const canAddWords = dictionaryName && dictionaryName.trim() !== ''
|
||||
|
||||
const parseMarkdownTable = (text) => {
|
||||
const lines = text.split('\n')
|
||||
const words = []
|
||||
let foundTable = false
|
||||
let headerFound = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
|
||||
// Skip empty lines
|
||||
if (!line) continue
|
||||
|
||||
// Look for table start (markdown table with |)
|
||||
if (line.includes('|') && line.includes('Слово')) {
|
||||
foundTable = true
|
||||
headerFound = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip separator line (|---|---|)
|
||||
if (foundTable && line.match(/^\|[\s\-|:]+\|$/)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse table rows
|
||||
if (foundTable && headerFound && line.includes('|')) {
|
||||
const cells = line
|
||||
.split('|')
|
||||
.map(cell => (cell || '').trim())
|
||||
.filter(cell => cell && cell.length > 0)
|
||||
|
||||
if (cells.length >= 2) {
|
||||
// Remove markdown formatting (**bold**, etc.)
|
||||
const word = cells[0].replace(/\*\*/g, '').trim()
|
||||
const translation = cells[1].replace(/\*\*/g, '').trim()
|
||||
|
||||
if (word && translation) {
|
||||
words.push({
|
||||
name: word,
|
||||
translation: translation,
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const words = parseMarkdownTable(markdownText)
|
||||
|
||||
if (words.length === 0) {
|
||||
setMessage('Не удалось найти слова в таблице. Убедитесь, что таблица содержит колонки "Слово" и "Перевод".')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Add dictionary_id to each word if dictionaryId is provided
|
||||
const wordsWithDictionary = words.map(word => ({
|
||||
...word,
|
||||
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
||||
}))
|
||||
|
||||
const response = await fetch(`${API_URL}/words`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ words: wordsWithDictionary }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при добавлении слов')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const addedCount = data?.added || 0
|
||||
setMessage(`Успешно добавлено ${addedCount} слов(а)!`)
|
||||
setMarkdownText('')
|
||||
} catch (error) {
|
||||
setMessage(`Ошибка: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate?.('words', dictionaryId !== undefined && dictionaryId !== null ? { dictionaryId } : {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-words">
|
||||
<button className="close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
<h2>Добавить слова</h2>
|
||||
<p className="description">
|
||||
Вставьте текст в формате Markdown с таблицей, содержащей колонки "Слово" и "Перевод"
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
className="markdown-input"
|
||||
value={markdownText}
|
||||
onChange={(e) => setMarkdownText(e.target.value)}
|
||||
placeholder={`Вот таблица, содержащая только слова и их перевод:
|
||||
|
||||
| Слово (Word) | Перевод |
|
||||
| --- | --- |
|
||||
| **Adventure** | Приключение |
|
||||
| **Challenge** | Вызов, сложная задача |
|
||||
| **Decision** | Решение |`}
|
||||
rows={15}
|
||||
/>
|
||||
|
||||
{canAddWords && (
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={loading || !markdownText.trim()}
|
||||
>
|
||||
{loading ? 'Добавление...' : 'Добавить слова'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!canAddWords && (
|
||||
<div className="message error">
|
||||
Сначала установите название словаря на экране списка слов
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddWords
|
||||
|
||||
200
play-life-web/src/components/CurrentWeek.jsx
Normal file
200
play-life-web/src/components/CurrentWeek.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import ProjectProgressBar from './ProjectProgressBar'
|
||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
|
||||
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
||||
// Обрабатываем данные: может быть объект с projects и total, или просто массив
|
||||
const projectsData = data?.projects || (Array.isArray(data) ? data : [])
|
||||
|
||||
// Показываем loading только если данных нет и идет загрузка
|
||||
if (loading && (!data || projectsData.length === 0)) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && (!data || projectsData.length === 0)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Процент выполнения берем только из данных API
|
||||
const overallProgress = (() => {
|
||||
// Проверяем различные возможные названия поля
|
||||
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
|
||||
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
|
||||
|
||||
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
|
||||
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
|
||||
}
|
||||
|
||||
return null // null означает, что данные не пришли
|
||||
})()
|
||||
|
||||
const hasProgressData = overallProgress !== null
|
||||
|
||||
// Логирование для отладки
|
||||
console.log('CurrentWeek data:', {
|
||||
data,
|
||||
dataTotal: data?.total,
|
||||
dataProgress: data?.progress,
|
||||
dataPercentage: data?.percentage,
|
||||
overallProgress,
|
||||
hasProgressData
|
||||
})
|
||||
|
||||
if (!projectsData || projectsData.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Получаем отсортированный список всех проектов для синхронизации цветов
|
||||
const allProjects = getAllProjectsSorted(allProjectsData, projectsData)
|
||||
|
||||
const normalizePriority = (value) => {
|
||||
if (value === null || value === undefined) return Infinity
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : Infinity
|
||||
}
|
||||
|
||||
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
||||
const sortedData = [...projectsData].sort((a, b) => {
|
||||
const priorityA = normalizePriority(a.priority)
|
||||
const priorityB = normalizePriority(b.priority)
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const minGoalA = parseFloat(a.min_goal_score) || 0
|
||||
const minGoalB = parseFloat(b.min_goal_score) || 0
|
||||
return minGoalB - minGoalA
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Информация об общем проценте выполнения целей */}
|
||||
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200">
|
||||
<div className="flex items-stretch justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
|
||||
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{hasProgressData ? `${overallProgress.toFixed(1)}%` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
{hasProgressData && (
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0">
|
||||
<svg className="transform -rotate-90" viewBox="0 0 64 64">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="url(#gradient)"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
strokeDasharray={`${Math.min(overallProgress / 100, 1) * 175.93} 175.93`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{overallProgress >= 100 && (
|
||||
<g className="transform rotate-90" style={{ transformOrigin: '32px 32px' }}>
|
||||
<path
|
||||
d="M 20 32 L 28 40 L 44 24"
|
||||
stroke="url(#gradient)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#4f46e5" />
|
||||
<stop offset="100%" stopColor="#9333ea" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<div className="flex flex-col flex-shrink-0 gap-1" style={{ minHeight: '64px', height: '100%' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('full')}
|
||||
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Статистика"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="4"></line>
|
||||
<line x1="6" y1="20" x2="6" y2="14"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('priorities')}
|
||||
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Приоритеты"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{sortedData.map((project, index) => {
|
||||
const projectColor = getProjectColor(project.project_name, allProjects)
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<ProjectProgressBar
|
||||
projectName={project.project_name}
|
||||
totalScore={parseFloat(project.total_score)}
|
||||
minGoalScore={parseFloat(project.min_goal_score)}
|
||||
maxGoalScore={parseFloat(project.max_goal_score)}
|
||||
onProjectClick={onProjectClick}
|
||||
projectColor={projectColor}
|
||||
priority={project.priority}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CurrentWeek
|
||||
|
||||
289
play-life-web/src/components/FullStatistics.jsx
Normal file
289
play-life-web/src/components/FullStatistics.jsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import WeekProgressChart from './WeekProgressChart'
|
||||
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||
|
||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||
|
||||
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
|
||||
|
||||
const parseWeekKey = (weekKey) => {
|
||||
const [yearStr, weekStr] = weekKey.split('-W')
|
||||
return { year: Number(yearStr), week: Number(weekStr) }
|
||||
}
|
||||
|
||||
const compareWeekKeys = (a, b) => {
|
||||
const [yearA, weekA] = a.split('-W').map(Number)
|
||||
const [yearB, weekB] = b.split('-W').map(Number)
|
||||
|
||||
if (yearA !== yearB) {
|
||||
return yearA - yearB
|
||||
}
|
||||
return weekA - weekB
|
||||
}
|
||||
|
||||
// Возвращает понедельник ISO-недели
|
||||
const getDateOfISOWeek = (week, year) => {
|
||||
const simple = new Date(year, 0, 1 + (week - 1) * 7)
|
||||
const dayOfWeek = simple.getDay() || 7 // Sunday -> 7
|
||||
if (dayOfWeek !== 1) {
|
||||
simple.setDate(simple.getDate() + (1 - dayOfWeek))
|
||||
}
|
||||
simple.setHours(0, 0, 0, 0)
|
||||
return simple
|
||||
}
|
||||
|
||||
const getISOWeekInfo = (date) => {
|
||||
const target = new Date(date.getTime())
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + 4 - (target.getDay() || 7))
|
||||
const year = target.getFullYear()
|
||||
const yearStart = new Date(year, 0, 1)
|
||||
const week = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
||||
return { year, week }
|
||||
}
|
||||
|
||||
const getPrevWeekKey = (weekKey) => {
|
||||
const { year, week } = parseWeekKey(weekKey)
|
||||
const currentWeekDate = getDateOfISOWeek(week, year)
|
||||
const prevWeekDate = new Date(currentWeekDate.getTime())
|
||||
prevWeekDate.setDate(prevWeekDate.getDate() - 7)
|
||||
const prevWeekInfo = getISOWeekInfo(prevWeekDate)
|
||||
return formatWeekKey(prevWeekInfo)
|
||||
}
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
|
||||
|
||||
const processData = () => {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
// Группируем данные по проектам
|
||||
const projectsMap = {}
|
||||
|
||||
data.forEach(item => {
|
||||
const projectName = item.project_name
|
||||
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
|
||||
|
||||
if (!projectsMap[projectName]) {
|
||||
projectsMap[projectName] = {}
|
||||
}
|
||||
|
||||
projectsMap[projectName][weekKey] = parseFloat(item.total_score)
|
||||
})
|
||||
|
||||
// Добавляем дополнительную неделю со значением 0,
|
||||
// если первая неделя проекта имеет ненулевое значение
|
||||
Object.values(projectsMap).forEach((weeks) => {
|
||||
const projectWeeks = Object.keys(weeks)
|
||||
if (!projectWeeks.length) return
|
||||
|
||||
const sortedProjectWeeks = projectWeeks.sort(compareWeekKeys)
|
||||
const firstWeekKey = sortedProjectWeeks[0]
|
||||
const firstScore = weeks[firstWeekKey]
|
||||
|
||||
if (firstScore !== 0) {
|
||||
const zeroWeekKey = getPrevWeekKey(firstWeekKey)
|
||||
weeks[zeroWeekKey] = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Собираем все уникальные недели и сортируем их по году и неделе
|
||||
const allWeeks = new Set()
|
||||
Object.values(projectsMap).forEach(weeks => {
|
||||
Object.keys(weeks).forEach(week => allWeeks.add(week))
|
||||
})
|
||||
const sortedWeeks = Array.from(allWeeks).sort(compareWeekKeys)
|
||||
|
||||
// Получаем отсортированный список всех проектов для синхронизации цветов
|
||||
const allProjectNames = getAllProjectsSorted(data)
|
||||
|
||||
// Фильтруем по выбранному проекту, если он указан
|
||||
let projectNames = allProjectNames.filter(name => projectsMap[name])
|
||||
|
||||
// Сортируем проекты так же, как на экране списка проектов (по priority и min_goal_score)
|
||||
if (currentWeekData) {
|
||||
projectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
|
||||
}
|
||||
|
||||
if (selectedProject) {
|
||||
projectNames = projectNames.filter(name => name === selectedProject)
|
||||
}
|
||||
|
||||
const datasets = projectNames.map((projectName) => {
|
||||
let cumulativeSum = 0
|
||||
const values = sortedWeeks.map(week => {
|
||||
const score = projectsMap[projectName]?.[week] || 0
|
||||
cumulativeSum += score
|
||||
return cumulativeSum
|
||||
})
|
||||
|
||||
// Генерируем цвет для линии на основе индекса в полном списке проектов
|
||||
const color = getProjectColor(projectName, allProjectNames)
|
||||
|
||||
return {
|
||||
label: projectName,
|
||||
data: values,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('50%)', '20%)'),
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: sortedWeeks,
|
||||
datasets: datasets,
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = processData()
|
||||
|
||||
// Показываем loading только если данных нет и идет загрузка
|
||||
if (loading && !chartData) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !chartData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
top: 20,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false,
|
||||
},
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onNavigate && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() => onNavigate('current')}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Закрыть"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: '550px' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullStatistics
|
||||
|
||||
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
@@ -0,0 +1,724 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
|
||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
||||
|
||||
// Компонент для сортируемого элемента проекта
|
||||
function SortableProjectItem({ project, index, allProjects, onRemove }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: project.name })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const projectColor = getProjectColor(project.name, allProjects)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-id={project.name}
|
||||
style={{ ...style, touchAction: 'none' }}
|
||||
className={`bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 ${
|
||||
isDragging ? 'border-indigo-400' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<circle cx="7" cy="7" r="1.5" />
|
||||
<circle cx="13" cy="7" r="1.5" />
|
||||
<circle cx="7" cy="13" r="1.5" />
|
||||
<circle cx="13" cy="13" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: projectColor }}
|
||||
></div>
|
||||
<span className="font-semibold text-gray-800">{project.name}</span>
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={() => onRemove(project.name)}
|
||||
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Убрать из этого слота"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для пустого слота (droppable)
|
||||
function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: containerId,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`bg-gray-50 border-2 border-dashed rounded-lg p-4 text-center text-sm transition-colors ${
|
||||
isOver
|
||||
? 'border-indigo-400 bg-indigo-50 text-indigo-600'
|
||||
: 'border-gray-300 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isEmpty
|
||||
? 'Перетащите проект сюда'
|
||||
: `Можно добавить еще ${maxItems - currentCount} проект${maxItems - currentCount > 1 ? 'а' : ''}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для слота приоритета
|
||||
function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null, containerId }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
||||
<div className="space-y-2 min-h-[60px]">
|
||||
{projects.length === 0 && (
|
||||
<DroppableSlot containerId={containerId} isEmpty={true} maxItems={maxItems} currentCount={0} />
|
||||
)}
|
||||
{projects.map((project, index) => (
|
||||
<SortableProjectItem
|
||||
key={project.name}
|
||||
project={project}
|
||||
index={index}
|
||||
allProjects={allProjects}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||
const [projectsError, setProjectsError] = useState(null)
|
||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||
|
||||
// Уведомляем родительский компонент об изменении состояния загрузки
|
||||
useEffect(() => {
|
||||
if (onLoadingChange) {
|
||||
onLoadingChange(projectsLoading)
|
||||
}
|
||||
}, [projectsLoading, onLoadingChange])
|
||||
|
||||
// Уведомляем родительский компонент об изменении ошибок
|
||||
useEffect(() => {
|
||||
if (onErrorChange) {
|
||||
onErrorChange(projectsError)
|
||||
}
|
||||
}, [projectsError, onErrorChange])
|
||||
const [allProjects, setAllProjects] = useState([])
|
||||
const [maxPriority, setMaxPriority] = useState([])
|
||||
const [mediumPriority, setMediumPriority] = useState([])
|
||||
const [lowPriority, setLowPriority] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
|
||||
|
||||
const scrollContainerRef = useRef(null)
|
||||
const hasFetchedRef = useRef(false)
|
||||
const skipNextEffectRef = useRef(false)
|
||||
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger
|
||||
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10, // Активация только после перемещения на 10px
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
// Получаем резервный список проектов из уже загруженных данных,
|
||||
// если API для приоритетов недоступен.
|
||||
const getFallbackProjects = useCallback(() => {
|
||||
return getAllProjectsSorted(allProjectsData, currentWeekData)
|
||||
}, [allProjectsData, currentWeekData])
|
||||
|
||||
const normalizeProjects = useCallback((projectsArray) => {
|
||||
const normalizedProjects = projectsArray
|
||||
.map(item => {
|
||||
const name = item?.name || item?.project || item?.project_name || item?.title
|
||||
if (!name) return null
|
||||
|
||||
const id = item?.project_id ?? item?.id ?? item?.projectId ?? null
|
||||
const priorityValue = item?.priority ?? item?.priority_value ?? item?.priority_level ?? null
|
||||
return { id, name, priority: priorityValue }
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const uniqueProjects = []
|
||||
const seenNames = new Set()
|
||||
|
||||
normalizedProjects.forEach(item => {
|
||||
const key = item.id ?? item.name
|
||||
if (!seenNames.has(key)) {
|
||||
seenNames.add(key)
|
||||
uniqueProjects.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
const max = []
|
||||
const medium = []
|
||||
const low = []
|
||||
|
||||
uniqueProjects.forEach(item => {
|
||||
const projectEntry = { name: item.name, id: item.id }
|
||||
if (item.priority === 1) {
|
||||
max.push(projectEntry)
|
||||
} else if (item.priority === 2) {
|
||||
medium.push(projectEntry)
|
||||
} else {
|
||||
low.push(projectEntry)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
uniqueProjects,
|
||||
max,
|
||||
medium,
|
||||
low,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyProjects = useCallback((projectsArray) => {
|
||||
const { uniqueProjects, max, medium, low } = normalizeProjects(projectsArray)
|
||||
setAllProjects(uniqueProjects.map(item => item.name))
|
||||
setMaxPriority(max)
|
||||
setMediumPriority(medium)
|
||||
setLowPriority(low)
|
||||
}, [normalizeProjects])
|
||||
|
||||
const fetchProjects = useCallback(async (isBackground = false) => {
|
||||
// Предотвращаем параллельные загрузки
|
||||
if (isLoadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingRef.current = true
|
||||
|
||||
// Показываем загрузку только если это не фоновая загрузка
|
||||
if (!isBackground) {
|
||||
setProjectsLoading(true)
|
||||
}
|
||||
setProjectsError(null)
|
||||
|
||||
const response = await fetch(PROJECTS_API_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить проекты')
|
||||
}
|
||||
|
||||
const jsonData = await response.json()
|
||||
const projectsArray = Array.isArray(jsonData)
|
||||
? jsonData
|
||||
: Array.isArray(jsonData?.projects)
|
||||
? jsonData.projects
|
||||
: Array.isArray(jsonData?.data)
|
||||
? jsonData.data
|
||||
: []
|
||||
|
||||
applyProjects(projectsArray)
|
||||
setHasDataCache(true) // Отмечаем, что данные загружены
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки проектов:', error)
|
||||
setProjectsError(error.message || 'Ошибка загрузки проектов')
|
||||
const fallbackProjects = getFallbackProjects()
|
||||
if (fallbackProjects.length > 0) {
|
||||
setAllProjects(fallbackProjects)
|
||||
setMaxPriority([])
|
||||
setMediumPriority([])
|
||||
setLowPriority(fallbackProjects.map(name => ({ name })))
|
||||
setHasDataCache(true) // Отмечаем, что есть fallback данные
|
||||
}
|
||||
} finally {
|
||||
isLoadingRef.current = false
|
||||
setProjectsLoading(false)
|
||||
}
|
||||
}, [applyProjects, getFallbackProjects])
|
||||
|
||||
useEffect(() => {
|
||||
// Если таб не должен загружаться, не делаем ничего
|
||||
if (!shouldLoad) {
|
||||
// Сбрасываем флаг загрузки, если таб стал неактивным
|
||||
if (hasFetchedRef.current) {
|
||||
hasFetchedRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Если загрузка уже идет, не запускаем еще одну
|
||||
if (isLoadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Если refreshTrigger равен 0 и мы еще не загружали - ждем, пока триггер не будет установлен
|
||||
// Это предотвращает загрузку при первом монтировании, когда shouldLoad становится true,
|
||||
// но refreshTrigger еще не установлен
|
||||
if (refreshTrigger === 0 && !hasFetchedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем, был ли этот refreshTrigger уже обработан
|
||||
if (refreshTrigger === lastRefreshTriggerRef.current && hasFetchedRef.current) {
|
||||
return // Уже обработали этот триггер
|
||||
}
|
||||
|
||||
// Если уже загружали и нет нового триггера обновления - не загружаем снова
|
||||
if (hasFetchedRef.current && refreshTrigger === lastRefreshTriggerRef.current) return
|
||||
|
||||
// Определяем, есть ли кеш (данные уже загружены)
|
||||
const hasCache = hasDataCache && (maxPriority.length > 0 || mediumPriority.length > 0 || lowPriority.length > 0)
|
||||
|
||||
// Отмечаем, что обрабатываем этот триггер ПЕРЕД загрузкой
|
||||
lastRefreshTriggerRef.current = refreshTrigger
|
||||
|
||||
if (refreshTrigger > 0) {
|
||||
// Если есть триггер обновления, сбрасываем флаг загрузки
|
||||
hasFetchedRef.current = false
|
||||
}
|
||||
|
||||
// Устанавливаем флаг загрузки перед вызовом
|
||||
hasFetchedRef.current = true
|
||||
|
||||
// Загружаем: если есть кеш - фоново, если нет - с индикатором
|
||||
fetchProjects(hasCache)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchProjects, shouldLoad, refreshTrigger]) // hasDataCache и длины массивов проверяются внутри эффекта, не добавляем в зависимости
|
||||
|
||||
const buildAssignments = useCallback(() => {
|
||||
const map = new Map()
|
||||
maxPriority.forEach(p => {
|
||||
map.set(p.id ?? p.name, { id: p.id, priority: 1 })
|
||||
})
|
||||
mediumPriority.forEach(p => {
|
||||
map.set(p.id ?? p.name, { id: p.id, priority: 2 })
|
||||
})
|
||||
lowPriority.forEach(p => {
|
||||
map.set(p.id ?? p.name, { id: p.id, priority: null })
|
||||
})
|
||||
return map
|
||||
}, [lowPriority, maxPriority, mediumPriority])
|
||||
|
||||
const prevAssignmentsRef = useRef(new Map())
|
||||
const initializedAssignmentsRef = useRef(false)
|
||||
|
||||
const sendPriorityChanges = useCallback(async (changes) => {
|
||||
if (!changes.length) return
|
||||
try {
|
||||
await fetch(PRIORITY_UPDATE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Ошибка отправки изменений приоритета', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const current = buildAssignments()
|
||||
|
||||
if (!initializedAssignmentsRef.current) {
|
||||
prevAssignmentsRef.current = current
|
||||
initializedAssignmentsRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (skipNextEffectRef.current) {
|
||||
skipNextEffectRef.current = false
|
||||
prevAssignmentsRef.current = current
|
||||
return
|
||||
}
|
||||
|
||||
const prev = prevAssignmentsRef.current
|
||||
const allKeys = new Set([...prev.keys(), ...current.keys()])
|
||||
const changes = []
|
||||
|
||||
allKeys.forEach(key => {
|
||||
const prevItem = prev.get(key)
|
||||
const currItem = current.get(key)
|
||||
const prevPriority = prevItem?.priority ?? null
|
||||
const currPriority = currItem?.priority ?? null
|
||||
const id = currItem?.id ?? prevItem?.id
|
||||
|
||||
if (!id) return
|
||||
if (prevPriority !== currPriority) {
|
||||
changes.push({ id, priority: currPriority })
|
||||
}
|
||||
})
|
||||
|
||||
if (changes.length) {
|
||||
sendPriorityChanges(changes)
|
||||
}
|
||||
|
||||
prevAssignmentsRef.current = current
|
||||
}, [buildAssignments, sendPriorityChanges])
|
||||
|
||||
const findProjectContainer = (projectName) => {
|
||||
if (maxPriority.find(p => p.name === projectName)) return 'max'
|
||||
if (mediumPriority.find(p => p.name === projectName)) return 'medium'
|
||||
if (lowPriority.find(p => p.name === projectName)) return 'low'
|
||||
return null
|
||||
}
|
||||
|
||||
const isValidContainer = (containerId) => {
|
||||
return containerId === 'max' || containerId === 'medium' || containerId === 'low'
|
||||
}
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
setActiveId(event.active.id)
|
||||
// Находим скроллируемый контейнер и отключаем его скролл
|
||||
if (!scrollContainerRef.current) {
|
||||
// Ищем родительский скроллируемый контейнер через DOM
|
||||
const activeElement = document.querySelector(`[data-id="${event.active.id}"]`)
|
||||
if (activeElement) {
|
||||
const container = activeElement.closest('.overflow-y-auto')
|
||||
if (container) {
|
||||
scrollContainerRef.current = container
|
||||
container.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollContainerRef.current.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
// Включаем скролл обратно
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.style.overflow = 'auto'
|
||||
scrollContainerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
// Включаем скролл обратно
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.style.overflow = 'auto'
|
||||
scrollContainerRef.current = null
|
||||
}
|
||||
|
||||
if (!over) return
|
||||
|
||||
const activeId = active.id
|
||||
const overId = over.id
|
||||
|
||||
const activeContainer = findProjectContainer(activeId)
|
||||
// Проверяем, является ли overId контейнером или проектом
|
||||
let overContainer = findProjectContainer(overId)
|
||||
if (!overContainer && isValidContainer(overId)) {
|
||||
overContainer = overId
|
||||
}
|
||||
|
||||
if (!activeContainer) return
|
||||
if (!overContainer) return
|
||||
|
||||
// Если перетаскиваем в тот же контейнер
|
||||
if (activeContainer === overContainer) {
|
||||
let items
|
||||
let setItems
|
||||
|
||||
if (activeContainer === 'max') {
|
||||
items = maxPriority
|
||||
setItems = setMaxPriority
|
||||
} else if (activeContainer === 'medium') {
|
||||
items = mediumPriority
|
||||
setItems = setMediumPriority
|
||||
} else {
|
||||
items = lowPriority
|
||||
setItems = setLowPriority
|
||||
}
|
||||
|
||||
const oldIndex = items.findIndex(p => p.name === activeId)
|
||||
const newIndex = items.findIndex(p => p.name === overId)
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
setItems(arrayMove(items, oldIndex, newIndex))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Перемещаем между контейнерами
|
||||
const activeProject = [
|
||||
...maxPriority,
|
||||
...mediumPriority,
|
||||
...lowPriority,
|
||||
].find(p => p.name === activeId)
|
||||
|
||||
if (!activeProject) return
|
||||
|
||||
// Если контейнеры одинаковые, ничего не делаем (уже обработано выше)
|
||||
if (activeContainer === overContainer) return
|
||||
|
||||
// Удаляем из старого контейнера
|
||||
if (activeContainer === 'max') {
|
||||
setMaxPriority(prev => prev.filter(p => p.name !== activeId))
|
||||
} else if (activeContainer === 'medium') {
|
||||
setMediumPriority(prev => prev.filter(p => p.name !== activeId))
|
||||
} else {
|
||||
setLowPriority(prev => prev.filter(p => p.name !== activeId))
|
||||
}
|
||||
|
||||
// Добавляем в новый контейнер
|
||||
if (overContainer === 'max') {
|
||||
// Если контейнер max уже заполнен, заменяем первый элемент
|
||||
if (maxPriority.length >= 1) {
|
||||
const oldProject = maxPriority[0]
|
||||
setMaxPriority([activeProject])
|
||||
// Старый проект перемещаем в low
|
||||
setLowPriority(prev => [...prev, oldProject])
|
||||
} else {
|
||||
// Контейнер пустой, просто добавляем
|
||||
setMaxPriority([activeProject])
|
||||
}
|
||||
} else if (overContainer === 'medium') {
|
||||
// Если контейнер medium уже заполнен (2 элемента)
|
||||
if (mediumPriority.length >= 2) {
|
||||
// Если перетаскиваем на конкретный проект, заменяем его
|
||||
const overIndex = mediumPriority.findIndex(p => p.name === overId)
|
||||
if (overIndex !== -1) {
|
||||
const oldProject = mediumPriority[overIndex]
|
||||
const newItems = [...mediumPriority]
|
||||
newItems[overIndex] = activeProject
|
||||
setMediumPriority(newItems)
|
||||
setLowPriority(prev => [...prev, oldProject])
|
||||
} else {
|
||||
// Перетаскиваем на пустой слот, заменяем последний
|
||||
const oldProject = mediumPriority[mediumPriority.length - 1]
|
||||
setMediumPriority([mediumPriority[0], activeProject])
|
||||
setLowPriority(prev => [...prev, oldProject])
|
||||
}
|
||||
} else {
|
||||
// Есть место, добавляем в нужную позицию или в конец
|
||||
const overIndex = mediumPriority.findIndex(p => p.name === overId)
|
||||
if (overIndex !== -1) {
|
||||
const newItems = [...mediumPriority]
|
||||
newItems.splice(overIndex, 0, activeProject)
|
||||
setMediumPriority(newItems)
|
||||
} else {
|
||||
// Перетаскиваем на пустой слот, добавляем в конец
|
||||
setMediumPriority([...mediumPriority, activeProject])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для low priority просто добавляем
|
||||
const overIndex = lowPriority.findIndex(p => p.name === overId)
|
||||
if (overIndex !== -1) {
|
||||
// Перетаскиваем на конкретный проект
|
||||
const newItems = [...lowPriority]
|
||||
newItems.splice(overIndex, 0, activeProject)
|
||||
setLowPriority(newItems)
|
||||
} else {
|
||||
// Перетаскиваем на пустой слот, добавляем в конец
|
||||
setLowPriority([...lowPriority, activeProject])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getProjectKey = (project) => project?.id ?? project?.name
|
||||
|
||||
const moveProjectToLow = (project) => {
|
||||
const projectKey = getProjectKey(project)
|
||||
if (!projectKey) return
|
||||
|
||||
setLowPriority(prev => {
|
||||
const filtered = prev.filter(p => getProjectKey(p) !== projectKey)
|
||||
return [...filtered, project]
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemove = (projectName, container) => {
|
||||
const sourceList =
|
||||
container === 'max'
|
||||
? maxPriority
|
||||
: container === 'medium'
|
||||
? mediumPriority
|
||||
: lowPriority
|
||||
|
||||
const project = sourceList.find(p => p.name === projectName)
|
||||
if (!project) return
|
||||
|
||||
const projectId = project.id ?? project.name
|
||||
if (projectId) {
|
||||
skipNextEffectRef.current = true
|
||||
sendPriorityChanges([{ id: projectId, priority: null }])
|
||||
}
|
||||
|
||||
if (container === 'max') {
|
||||
setMaxPriority(prev => prev.filter(p => p.name !== projectName))
|
||||
} else if (container === 'medium') {
|
||||
setMediumPriority(prev => prev.filter(p => p.name !== projectName))
|
||||
}
|
||||
|
||||
moveProjectToLow(project)
|
||||
}
|
||||
|
||||
const allItems = [
|
||||
...maxPriority.map(p => ({ ...p, container: 'max' })),
|
||||
...mediumPriority.map(p => ({ ...p, container: 'medium' })),
|
||||
...lowPriority.map(p => ({ ...p, container: 'low' })),
|
||||
]
|
||||
|
||||
const activeProject = allItems.find(item => item.name === activeId)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{onNavigate && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() => onNavigate('current')}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Закрыть"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm">
|
||||
<div className="font-semibold">Не удалось загрузить проекты</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-red-600">{projectsError}</span>
|
||||
<button
|
||||
onClick={() => fetchProjects()}
|
||||
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-600 shadow-sm">
|
||||
Загружаем проекты...
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||
<PrioritySlot
|
||||
title="Максимальный приоритет (1 проект)"
|
||||
projects={maxPriority}
|
||||
allProjects={allProjects}
|
||||
onRemove={(name) => handleRemove(name, 'max')}
|
||||
maxItems={1}
|
||||
containerId="max"
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||
<PrioritySlot
|
||||
title="Средний приоритет (2 проекта)"
|
||||
projects={mediumPriority}
|
||||
allProjects={allProjects}
|
||||
onRemove={(name) => handleRemove(name, 'medium')}
|
||||
maxItems={2}
|
||||
containerId="medium"
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||
<PrioritySlot
|
||||
title="Остальные проекты"
|
||||
projects={lowPriority}
|
||||
allProjects={allProjects}
|
||||
containerId="low"
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
{!maxPriority.length && !mediumPriority.length && !lowPriority.length && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-gray-600">
|
||||
Проекты не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<DragOverlay>
|
||||
{activeProject ? (
|
||||
<div className="bg-white rounded-lg p-3 border-2 border-indigo-400 shadow-lg opacity-90">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getProjectColor(activeProject.name, allProjects) }}
|
||||
></div>
|
||||
<span className="font-semibold text-gray-800">{activeProject.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPriorityManager
|
||||
|
||||
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
function ProjectProgressBar({ projectName, totalScore, minGoalScore, maxGoalScore, onProjectClick, projectColor, priority }) {
|
||||
// Вычисляем максимальное значение для шкалы (берем максимум из maxGoalScore и totalScore + 20%)
|
||||
const maxScale = Math.max(maxGoalScore, totalScore * 1.2, 1)
|
||||
|
||||
// Процентные значения
|
||||
const totalScorePercent = (totalScore / maxScale) * 100
|
||||
const minGoalPercent = (minGoalScore / maxScale) * 100
|
||||
const maxGoalPercent = (maxGoalScore / maxScale) * 100
|
||||
const goalRangePercent = maxGoalPercent - minGoalPercent
|
||||
|
||||
const normalizedPriority = (() => {
|
||||
if (priority === null || priority === undefined) return null
|
||||
const numeric = Number(priority)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
})()
|
||||
|
||||
const priorityBonus = (() => {
|
||||
if (normalizedPriority === 1) return 50
|
||||
if (normalizedPriority === 2) return 35
|
||||
return 20
|
||||
})()
|
||||
|
||||
const goalProgress = (() => {
|
||||
const safeTotal = Number.isFinite(totalScore) ? totalScore : 0
|
||||
const safeMinGoal = Number.isFinite(minGoalScore) ? minGoalScore : 0
|
||||
const safeMaxGoal = Number.isFinite(maxGoalScore) ? maxGoalScore : 0
|
||||
|
||||
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
|
||||
if (safeMinGoal <= 0) {
|
||||
if (safeMaxGoal > 0) {
|
||||
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// До достижения minGoal растем линейно от 0 до 100%
|
||||
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
|
||||
|
||||
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
|
||||
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
|
||||
return baseProgress
|
||||
}
|
||||
|
||||
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
|
||||
const extraRange = safeMaxGoal - safeMinGoal
|
||||
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
|
||||
const extraProgress = extraRatio * priorityBonus
|
||||
|
||||
// Выше maxGoal прогресс не растет
|
||||
return Math.min(100 + priorityBonus, 100 + extraProgress)
|
||||
})()
|
||||
|
||||
const isGoalReached = totalScore >= minGoalScore
|
||||
const isGoalExceeded = totalScore >= maxGoalScore
|
||||
|
||||
const priorityBorderStyle =
|
||||
normalizedPriority === 1
|
||||
? { borderColor: '#d4af37' }
|
||||
: normalizedPriority === 2
|
||||
? { borderColor: '#c0c0c0' }
|
||||
: {}
|
||||
|
||||
const cardBorderClasses =
|
||||
normalizedPriority === 1 || normalizedPriority === 2
|
||||
? 'border-2'
|
||||
: 'border border-gray-200/50 hover:border-indigo-300'
|
||||
|
||||
const cardBaseClasses =
|
||||
'bg-gradient-to-br from-white to-gray-50 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer'
|
||||
|
||||
const handleClick = () => {
|
||||
if (onProjectClick) {
|
||||
onProjectClick(projectName)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`${cardBaseClasses} ${cardBorderClasses}`}
|
||||
style={priorityBorderStyle}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: projectColor }}
|
||||
></div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{projectName}</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{totalScore.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
из {minGoalScore.toFixed(1)} ({goalProgress.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
|
||||
{/* Диапазон цели (min_goal_score до max_goal_score) */}
|
||||
{minGoalScore > 0 && maxGoalScore > 0 && (
|
||||
<div
|
||||
className="absolute h-full bg-gradient-to-r from-amber-200 via-yellow-300 to-amber-200 opacity-70 border-l border-r border-amber-400"
|
||||
style={{
|
||||
left: `${minGoalPercent}%`,
|
||||
width: `${goalRangePercent}%`,
|
||||
}}
|
||||
title={`Цель: ${minGoalScore.toFixed(2)} - ${maxGoalScore.toFixed(2)}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Текущее значение (total_score) */}
|
||||
<div
|
||||
className={`absolute h-full transition-all duration-700 ease-out ${
|
||||
isGoalExceeded
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-500'
|
||||
: isGoalReached
|
||||
? 'bg-gradient-to-r from-yellow-500 to-amber-500'
|
||||
: 'bg-gradient-to-r from-indigo-500 to-purple-500'
|
||||
} shadow-sm`}
|
||||
style={{
|
||||
width: `${totalScorePercent}%`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор текущего значения */}
|
||||
{totalScorePercent > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-white shadow-md"
|
||||
style={{
|
||||
left: `${totalScorePercent}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-xs mt-1.5">
|
||||
<span className="text-gray-400">0</span>
|
||||
{minGoalScore > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400"></div>
|
||||
<span className="text-amber-700 font-medium text-xs">
|
||||
Цель: {minGoalScore.toFixed(1)} - {maxGoalScore.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-gray-400">{maxScale.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectProgressBar
|
||||
|
||||
347
play-life-web/src/components/TestConfigSelection.css
Normal file
347
play-life-web/src/components/TestConfigSelection.css
Normal file
@@ -0,0 +1,347 @@
|
||||
.config-selection {
|
||||
padding-top: 0;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.config-selection {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-config-button {
|
||||
background: transparent;
|
||||
border: 2px dashed #3498db;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-config-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
border-color: #2980b9;
|
||||
}
|
||||
|
||||
.add-config-icon {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-config-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #3498db;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.loading, .error-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.configs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
background: #3498db;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.75rem;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-menu-button:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.config-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
|
||||
.config-words-count {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-max-cards {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -1rem;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.config-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.config-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.config-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.config-modal-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.config-modal-close:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.config-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.config-modal-edit,
|
||||
.config-modal-delete {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.config-modal-edit {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-modal-edit:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-modal-delete {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.config-modal-delete:hover {
|
||||
background-color: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dictionaries-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.dictionary-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dictionary-card .card-menu-button {
|
||||
background: transparent;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dictionary-card .card-menu-button:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dictionary-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dictionary-words-count {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dictionary-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.add-dictionary-button {
|
||||
background: transparent;
|
||||
border: 2px dashed #2c3e50;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-dictionary-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.2);
|
||||
background-color: rgba(44, 62, 80, 0.05);
|
||||
border-color: #1a252f;
|
||||
}
|
||||
|
||||
.add-dictionary-button .add-config-icon {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.add-dictionary-button .add-config-text {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
278
play-life-web/src/components/TestConfigSelection.jsx
Normal file
278
play-life-web/src/components/TestConfigSelection.jsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import './TestConfigSelection.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
const [configs, setConfigs] = useState([])
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedConfig, setSelectedConfig] = useState(null)
|
||||
const [selectedDictionary, setSelectedDictionary] = useState(null)
|
||||
const [longPressTimer, setLongPressTimer] = useState(null)
|
||||
const isInitializedRef = useRef(false)
|
||||
const configsRef = useRef([])
|
||||
const dictionariesRef = useRef([])
|
||||
|
||||
// Обновляем ref при изменении состояния
|
||||
useEffect(() => {
|
||||
configsRef.current = configs
|
||||
}, [configs])
|
||||
|
||||
useEffect(() => {
|
||||
dictionariesRef.current = dictionaries
|
||||
}, [dictionaries])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTestConfigsAndDictionaries()
|
||||
}, [refreshTrigger])
|
||||
|
||||
|
||||
const fetchTestConfigsAndDictionaries = async () => {
|
||||
try {
|
||||
// Показываем загрузку только при первой инициализации или если нет данных для отображения
|
||||
const isFirstLoad = !isInitializedRef.current
|
||||
const hasData = !isFirstLoad && (configsRef.current.length > 0 || dictionariesRef.current.length > 0)
|
||||
if (!hasData) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
||||
}
|
||||
const data = await response.json()
|
||||
setConfigs(Array.isArray(data.configs) ? data.configs : [])
|
||||
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||
setError('')
|
||||
isInitializedRef.current = true
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setConfigs([])
|
||||
setDictionaries([])
|
||||
isInitializedRef.current = true
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfigSelect = (config) => {
|
||||
onNavigate?.('test', {
|
||||
wordCount: config.words_count,
|
||||
configId: config.id,
|
||||
maxCards: config.max_cards || null
|
||||
})
|
||||
}
|
||||
|
||||
const handleDictionarySelect = (dict) => {
|
||||
// For now, navigate to words list
|
||||
// In the future, we might want to filter by dictionary_id
|
||||
onNavigate?.('words', { dictionaryId: dict.id })
|
||||
}
|
||||
|
||||
const handleConfigMenuClick = (config, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedConfig(config)
|
||||
}
|
||||
|
||||
const handleDictionaryMenuClick = (dict, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedDictionary(dict)
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (selectedConfig) {
|
||||
onNavigate?.('add-config', { config: selectedConfig })
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDictionaryDelete = async () => {
|
||||
if (!selectedDictionary) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Delete error:', response.status, errorText)
|
||||
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
|
||||
}
|
||||
|
||||
setSelectedDictionary(null)
|
||||
// Refresh dictionaries list
|
||||
await fetchTestConfigsAndDictionaries()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message)
|
||||
setSelectedDictionary(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedConfig) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Delete error:', response.status, errorText)
|
||||
throw new Error(`Ошибка при удалении конфигурации: ${response.status}`)
|
||||
}
|
||||
|
||||
setSelectedConfig(null)
|
||||
// Refresh configs and dictionaries list
|
||||
await fetchTestConfigsAndDictionaries()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
setError(err.message)
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setSelectedConfig(null)
|
||||
}
|
||||
|
||||
// Показываем загрузку только при первой инициализации и если нет данных для отображения
|
||||
const shouldShowLoading = loading && !isInitializedRef.current && configs.length === 0 && dictionaries.length === 0
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return (
|
||||
<div className="config-selection">
|
||||
<div className="loading">Загрузка...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="config-selection">
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-selection">
|
||||
{/* Секция тестов */}
|
||||
<div className="section-divider">
|
||||
<h2 className="section-title">Тесты</h2>
|
||||
</div>
|
||||
<div className="configs-grid">
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="config-card"
|
||||
onClick={() => handleConfigSelect(config)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleConfigMenuClick(config, e)}
|
||||
className="card-menu-button"
|
||||
title="Меню"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className="config-words-count">
|
||||
{config.words_count}
|
||||
</div>
|
||||
{config.max_cards && (
|
||||
<div className="config-max-cards">
|
||||
{config.max_cards}
|
||||
</div>
|
||||
)}
|
||||
<div className="config-name">{config.name}</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => onNavigate?.('add-config')} className="add-config-button">
|
||||
<div className="add-config-icon">+</div>
|
||||
<div className="add-config-text">Добавить</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Секция словарей */}
|
||||
<div className="dictionaries-section">
|
||||
<div className="section-divider">
|
||||
<h2 className="section-title">Словари</h2>
|
||||
</div>
|
||||
<div className="configs-grid">
|
||||
{dictionaries.map((dict) => (
|
||||
<div
|
||||
key={dict.id}
|
||||
className="dictionary-card"
|
||||
onClick={() => handleDictionarySelect(dict)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => handleDictionaryMenuClick(dict, e)}
|
||||
className="card-menu-button"
|
||||
title="Меню"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className="dictionary-words-count">
|
||||
{dict.wordsCount}
|
||||
</div>
|
||||
<div className="dictionary-name">{dict.name}</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onNavigate?.('words', { isNewDictionary: true })}
|
||||
className="add-dictionary-button"
|
||||
>
|
||||
<div className="add-config-icon">+</div>
|
||||
<div className="add-config-text">Добавить</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{selectedConfig && (
|
||||
<div className="config-modal-overlay" onClick={closeModal}>
|
||||
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="config-modal-header">
|
||||
<h3>{selectedConfig.name}</h3>
|
||||
</div>
|
||||
<div className="config-modal-actions">
|
||||
<button className="config-modal-edit" onClick={handleEdit}>
|
||||
Редактировать
|
||||
</button>
|
||||
<button className="config-modal-delete" onClick={handleDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDictionary && (
|
||||
<div className="config-modal-overlay" onClick={() => setSelectedDictionary(null)}>
|
||||
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="config-modal-header">
|
||||
<h3>{selectedDictionary.name}</h3>
|
||||
</div>
|
||||
<div className="config-modal-actions">
|
||||
<button className="config-modal-delete" onClick={handleDictionaryDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestConfigSelection
|
||||
|
||||
485
play-life-web/src/components/TestWords.css
Normal file
485
play-life-web/src/components/TestWords.css
Normal file
@@ -0,0 +1,485 @@
|
||||
.test-container {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.test-container-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #f5f5f5;
|
||||
z-index: 1500;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.test-close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.test-close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.test-duration-selection {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.test-duration-selection h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.test-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.duration-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.duration-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-button:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.duration-button:disabled {
|
||||
background-color: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.duration-count {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.test-loading {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.test-screen {
|
||||
min-height: 100vh;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.test-progress {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-card-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.test-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 500px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.6s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.test-card.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.test-card-front,
|
||||
.test-card-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.test-card-front {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-card-back {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.test-card-content {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.test-word {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.test-translation {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.test-card-hint {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.test-card-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
padding-top: 2rem;
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.test-action-button {
|
||||
padding: 1.25rem 2.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.test-action-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.test-action-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.success-button {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.success-button:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
|
||||
.failure-button {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.failure-button:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.results-stats {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4rem 1rem 1rem 1rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: #fafafa;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.result-word-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-word-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.result-word {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-translation {
|
||||
font-size: 1rem;
|
||||
color: #34495e;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-stat-success {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.result-stat-separator {
|
||||
color: #7f8c8d;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.result-stat-failure {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4rem 1rem 1rem 1rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: #fafafa;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.preview-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-word-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview-word-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-word {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-translation {
|
||||
font-size: 1rem;
|
||||
color: #34495e;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-stats-numbers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-stat-success {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.preview-stat-separator {
|
||||
color: #7f8c8d;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.preview-stat-failure {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
background-color: #f5f5f5;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.test-start-button {
|
||||
padding: 1.25rem 3rem;
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background-color 0.2s;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.test-start-button:hover {
|
||||
background-color: #229954;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.test-start-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
background-color: #f5f5f5;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.test-finish-button {
|
||||
padding: 1.25rem 3rem;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background-color 0.2s;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.test-finish-button:hover {
|
||||
background-color: #2980b9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.test-finish-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
490
play-life-web/src/components/TestWords.jsx
Normal file
490
play-life-web/src/components/TestWords.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import './TestWords.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
const DEFAULT_TEST_WORD_COUNT = 10
|
||||
|
||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||
const configId = initialConfigId || null
|
||||
const maxCards = initialMaxCards || null
|
||||
|
||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [flippedCards, setFlippedCards] = useState(new Set())
|
||||
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
||||
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
||||
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
||||
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
||||
|
||||
const isFinishingRef = useRef(false)
|
||||
const wordStatsRef = useRef({})
|
||||
const processingRef = useRef(false)
|
||||
|
||||
// Загрузка слов при монтировании
|
||||
useEffect(() => {
|
||||
setWords([])
|
||||
setTestWords([])
|
||||
setCurrentIndex(0)
|
||||
setFlippedCards(new Set())
|
||||
setWordStats({})
|
||||
wordStatsRef.current = {}
|
||||
setCardsShown(0)
|
||||
setTotalAnswers(0)
|
||||
setError('')
|
||||
setShowPreview(false) // Сбрасываем экран предпросмотра
|
||||
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
||||
isFinishingRef.current = false
|
||||
processingRef.current = false
|
||||
setLoading(true)
|
||||
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
if (configId === null) {
|
||||
throw new Error('config_id обязателен для запуска теста')
|
||||
}
|
||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('Недостаточно слов для теста')
|
||||
}
|
||||
|
||||
// Инициализируем статистику из данных бэкенда
|
||||
const stats = {}
|
||||
data.forEach(word => {
|
||||
stats[word.id] = {
|
||||
success: word.success || 0,
|
||||
failure: word.failure || 0,
|
||||
lastSuccessAt: word.last_success_at || null,
|
||||
lastFailureAt: word.last_failure_at || null
|
||||
}
|
||||
})
|
||||
|
||||
setWords(data)
|
||||
|
||||
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
|
||||
// n = max(1, floor(0.7 * maxCards / количество_слов))
|
||||
const wordsCount = data.length
|
||||
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
||||
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||
|
||||
// Создаем пул, где каждое слово повторяется n раз
|
||||
const wordPool = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
wordPool.push(...data)
|
||||
}
|
||||
|
||||
// Перемешиваем пул случайным образом
|
||||
for (let i = wordPool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
|
||||
}
|
||||
|
||||
setTestWords(wordPool)
|
||||
setWordStats(stats)
|
||||
wordStatsRef.current = stats
|
||||
|
||||
// Показываем экран предпросмотра
|
||||
setShowPreview(true)
|
||||
|
||||
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
|
||||
setCardsShown(0)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadWords()
|
||||
}, [wordCount, configId])
|
||||
|
||||
const getCurrentWord = () => {
|
||||
if (currentIndex < testWords.length && testWords.length > 0) {
|
||||
return testWords[currentIndex]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
|
||||
const getRightCounter = () => {
|
||||
const total = totalAnswers + testWords.length
|
||||
if (maxCards !== null && maxCards > 0) {
|
||||
return Math.min(total, maxCards)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const handleCardFlip = (wordId) => {
|
||||
setFlippedCards(prev => new Set(prev).add(wordId))
|
||||
}
|
||||
|
||||
// Завершение теста
|
||||
const finishTest = async () => {
|
||||
if (isFinishingRef.current) return
|
||||
isFinishingRef.current = true
|
||||
|
||||
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
|
||||
setShowResults(true)
|
||||
|
||||
// Отправляем статистику на бэкенд
|
||||
try {
|
||||
// Получаем актуальные данные из состояния
|
||||
const currentStats = wordStatsRef.current
|
||||
|
||||
// Отправляем все слова, которые были в тесте, с их текущими значениями
|
||||
// Бэкенд сам обновит только измененные поля
|
||||
const updates = words.map(word => {
|
||||
const stats = currentStats[word.id] || {
|
||||
success: word.success || 0,
|
||||
failure: word.failure || 0,
|
||||
lastSuccessAt: word.last_success_at || null,
|
||||
lastFailureAt: word.last_failure_at || null
|
||||
}
|
||||
return {
|
||||
id: word.id,
|
||||
success: stats.success || 0,
|
||||
failure: stats.failure || 0,
|
||||
last_success_at: stats.lastSuccessAt || null,
|
||||
last_failure_at: stats.lastFailureAt || null
|
||||
}
|
||||
})
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No words to send - empty test')
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = { words: updates }
|
||||
if (configId !== null) {
|
||||
requestBody.config_id = configId
|
||||
}
|
||||
|
||||
console.log('Sending test progress to backend:', {
|
||||
wordsCount: updates.length,
|
||||
configId: configId,
|
||||
requestBody
|
||||
})
|
||||
|
||||
const response = await fetch(`${API_URL}/test/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
console.log('Test progress saved successfully:', responseData)
|
||||
} catch (err) {
|
||||
console.error('Failed to save progress:', err)
|
||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка условий завершения и показ следующей карточки
|
||||
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
|
||||
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
|
||||
if (isFinishingRef.current || showResults) {
|
||||
return
|
||||
}
|
||||
|
||||
// Условие 1: Достигли максимума карточек
|
||||
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
// Условие 2: Пул слов пуст
|
||||
if (newTestWords.length === 0) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
// Показываем следующую карточку и увеличиваем левый счётчик
|
||||
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
|
||||
const nextCardsShown = currentCardsShown + 1
|
||||
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
setCardsShown(nextCardsShown)
|
||||
}
|
||||
|
||||
const handleSuccess = (wordId) => {
|
||||
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||
processingRef.current = true
|
||||
|
||||
const word = words.find(w => w.id === wordId)
|
||||
if (!word) {
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Обновляем статистику: success + 1, lastSuccessAt = now
|
||||
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||
const updatedStats = {
|
||||
...wordStatsRef.current,
|
||||
[wordId]: {
|
||||
success: (currentWordStats.success || 0) + 1,
|
||||
failure: currentWordStats.failure || 0,
|
||||
lastSuccessAt: now,
|
||||
lastFailureAt: currentWordStats.lastFailureAt || null
|
||||
}
|
||||
}
|
||||
wordStatsRef.current = updatedStats
|
||||
setWordStats(updatedStats)
|
||||
|
||||
// Увеличиваем счётчик ответов
|
||||
const newTotalAnswers = totalAnswers + 1
|
||||
setTotalAnswers(newTotalAnswers)
|
||||
|
||||
// Убираем только один экземпляр слова из пула (по текущему индексу)
|
||||
const newTestWords = [...testWords]
|
||||
newTestWords.splice(currentIndex, 1)
|
||||
|
||||
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
|
||||
let newIndex = currentIndex
|
||||
if (newTestWords.length === 0) {
|
||||
newIndex = 0
|
||||
} else if (currentIndex >= newTestWords.length) {
|
||||
newIndex = newTestWords.length - 1
|
||||
}
|
||||
|
||||
// Обновляем состояние
|
||||
setTestWords(newTestWords)
|
||||
setCurrentIndex(newIndex)
|
||||
setFlippedCards(new Set())
|
||||
|
||||
// Проверяем условия завершения или показываем следующую карточку
|
||||
showNextCardOrFinish(newTestWords, cardsShown)
|
||||
|
||||
// Если тест завершился, не сбрасываем processingRef
|
||||
if (isFinishingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const handleFailure = (wordId) => {
|
||||
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||
processingRef.current = true
|
||||
|
||||
const word = words.find(w => w.id === wordId)
|
||||
if (!word) {
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Обновляем статистику: failure + 1, lastFailureAt = now
|
||||
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||
const updatedStats = {
|
||||
...wordStatsRef.current,
|
||||
[wordId]: {
|
||||
success: currentWordStats.success || 0,
|
||||
failure: (currentWordStats.failure || 0) + 1,
|
||||
lastSuccessAt: currentWordStats.lastSuccessAt || null,
|
||||
lastFailureAt: now
|
||||
}
|
||||
}
|
||||
wordStatsRef.current = updatedStats
|
||||
setWordStats(updatedStats)
|
||||
|
||||
// Увеличиваем счётчик ответов
|
||||
const newTotalAnswers = totalAnswers + 1
|
||||
setTotalAnswers(newTotalAnswers)
|
||||
|
||||
// Слово остаётся в пуле, переходим к следующему
|
||||
let newIndex = currentIndex + 1
|
||||
if (newIndex >= testWords.length) {
|
||||
newIndex = 0
|
||||
}
|
||||
|
||||
setCurrentIndex(newIndex)
|
||||
setFlippedCards(new Set())
|
||||
|
||||
// Проверяем условия завершения или показываем следующую карточку
|
||||
// При failure пул не изменяется
|
||||
showNextCardOrFinish(testWords, cardsShown)
|
||||
|
||||
// Если тест завершился, не сбрасываем processingRef
|
||||
if (isFinishingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
const handleStartTest = () => {
|
||||
setShowPreview(false)
|
||||
// Показываем первую карточку и увеличиваем левый счётчик
|
||||
setCardsShown(1)
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
const getRandomSide = (word) => {
|
||||
return word.id % 2 === 0 ? 'word' : 'translation'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-container test-container-fullscreen">
|
||||
<button className="test-close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
{showPreview ? (
|
||||
<div className="test-preview">
|
||||
<div className="preview-stats">
|
||||
{words.map((word) => {
|
||||
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||
return (
|
||||
<div key={word.id} className="preview-item">
|
||||
<div className="preview-word-content">
|
||||
<div className="preview-word-header">
|
||||
<h3 className="preview-word">{word.name}</h3>
|
||||
</div>
|
||||
<div className="preview-translation">{word.translation}</div>
|
||||
</div>
|
||||
<div className="preview-stats-numbers">
|
||||
<span className="preview-stat-success">{stats.success}</span>
|
||||
<span className="preview-stat-separator"> | </span>
|
||||
<span className="preview-stat-failure">{stats.failure}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="preview-actions">
|
||||
<button className="test-start-button" onClick={handleStartTest}>
|
||||
Начать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
<div className="test-results">
|
||||
<div className="results-stats">
|
||||
{words.map((word) => {
|
||||
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||
return (
|
||||
<div key={word.id} className="result-item">
|
||||
<div className="result-word-content">
|
||||
<div className="result-word-header">
|
||||
<h3 className="result-word">{word.name}</h3>
|
||||
</div>
|
||||
<div className="result-translation">{word.translation}</div>
|
||||
</div>
|
||||
<div className="result-stats">
|
||||
<span className="result-stat-success">{stats.success}</span>
|
||||
<span className="result-stat-separator"> | </span>
|
||||
<span className="result-stat-failure">{stats.failure}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="results-actions">
|
||||
<button className="test-finish-button" onClick={handleFinish}>
|
||||
Закончить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="test-screen">
|
||||
{loading && (
|
||||
<div className="test-loading">Загрузка слов...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="test-error">{error}</div>
|
||||
)}
|
||||
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
|
||||
const word = getCurrentWord()
|
||||
const isFlipped = flippedCards.has(word.id)
|
||||
const showSide = getRandomSide(word)
|
||||
|
||||
return (
|
||||
<div className="test-card-container" key={word.id}>
|
||||
<div
|
||||
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
||||
onClick={() => !isFlipped && handleCardFlip(word.id)}
|
||||
>
|
||||
<div className="test-card-front">
|
||||
<div className="test-card-content">
|
||||
{showSide === 'word' ? (
|
||||
<div className="test-word">{word.name}</div>
|
||||
) : (
|
||||
<div className="test-translation">{word.translation}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="test-card-back">
|
||||
<div className="test-card-content">
|
||||
{showSide === 'word' ? (
|
||||
<div className="test-translation">{word.translation}</div>
|
||||
) : (
|
||||
<div className="test-word">{word.name}</div>
|
||||
)}
|
||||
<div className="test-card-actions">
|
||||
<button
|
||||
className="test-action-button success-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSuccess(word.id)
|
||||
}}
|
||||
>
|
||||
✓ Знаю
|
||||
</button>
|
||||
<button
|
||||
className="test-action-button failure-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleFailure(word.id)
|
||||
}}
|
||||
>
|
||||
✗ Не знаю
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading && !error && (
|
||||
<div className="test-progress">
|
||||
<div className="progress-text">
|
||||
{cardsShown} / {getRightCounter()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestWords
|
||||
160
play-life-web/src/components/WeekProgressChart.jsx
Normal file
160
play-life-web/src/components/WeekProgressChart.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react'
|
||||
import { getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||
|
||||
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
|
||||
|
||||
const parseWeekKey = (weekKey) => {
|
||||
const [yearStr, weekStr] = weekKey.split('-W')
|
||||
return { year: Number(yearStr), week: Number(weekStr) }
|
||||
}
|
||||
|
||||
const compareWeekKeys = (a, b) => {
|
||||
const [yearA, weekA] = a.split('-W').map(Number)
|
||||
const [yearB, weekB] = b.split('-W').map(Number)
|
||||
|
||||
if (yearA !== yearB) {
|
||||
return yearA - yearB
|
||||
}
|
||||
return weekA - weekB
|
||||
}
|
||||
|
||||
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Группируем данные по неделям
|
||||
const weeksMap = {}
|
||||
|
||||
data.forEach(item => {
|
||||
// Фильтруем по выбранному проекту, если он указан
|
||||
if (selectedProject && item.project_name !== selectedProject) {
|
||||
return
|
||||
}
|
||||
|
||||
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
|
||||
|
||||
if (!weeksMap[weekKey]) {
|
||||
weeksMap[weekKey] = []
|
||||
}
|
||||
|
||||
weeksMap[weekKey].push({
|
||||
projectName: item.project_name,
|
||||
score: parseFloat(item.total_score) || 0
|
||||
})
|
||||
})
|
||||
|
||||
// Получаем все уникальные недели и сортируем их (новые сверху)
|
||||
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
|
||||
|
||||
// Берем первые 4 недели (самые актуальные)
|
||||
const last4Weeks = allWeeks.slice(0, 4)
|
||||
|
||||
// Используем переданный отсортированный список проектов или получаем из данных
|
||||
const allProjects = allProjectsSorted || (() => {
|
||||
const allProjectsSet = new Set()
|
||||
data.forEach(item => {
|
||||
allProjectsSet.add(item.project_name)
|
||||
})
|
||||
return Array.from(allProjectsSet).sort()
|
||||
})()
|
||||
|
||||
// Обрабатываем данные для каждой недели
|
||||
const weeksData = last4Weeks.map(weekKey => {
|
||||
const weekProjects = weeksMap[weekKey]
|
||||
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
|
||||
|
||||
// Используем абсолютные значения (баллы)
|
||||
// Сортируем проекты так же, как в полной статистике (по priority и min_goal_score)
|
||||
const projectsWithData = weekProjects.map(project => {
|
||||
const color = getProjectColor(project.projectName, allProjects)
|
||||
|
||||
return {
|
||||
...project,
|
||||
color
|
||||
}
|
||||
})
|
||||
|
||||
// Применяем ту же сортировку, что и в FullStatistics
|
||||
const projectNames = projectsWithData.map(p => p.projectName)
|
||||
const sortedProjectNames = currentWeekData
|
||||
? sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
|
||||
: projectNames
|
||||
|
||||
// Пересобираем projectsWithData в правильном порядке
|
||||
const sortedProjectsWithData = sortedProjectNames.map(name => {
|
||||
return projectsWithData.find(p => p.projectName === name)
|
||||
}).filter(Boolean)
|
||||
|
||||
const { year, week } = parseWeekKey(weekKey)
|
||||
|
||||
return {
|
||||
weekKey,
|
||||
year,
|
||||
week,
|
||||
projects: sortedProjectsWithData,
|
||||
totalScore
|
||||
}
|
||||
})
|
||||
|
||||
// Находим максимальное значение среди всех недель для единой шкалы сравнения
|
||||
const maxTotalScore = Math.max(...weeksData.map(w => w.totalScore), 1)
|
||||
|
||||
if (weeksData.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">Последние 4 недели</h2>
|
||||
<div className="space-y-3">
|
||||
{weeksData.map((weekData) => (
|
||||
<div key={weekData.weekKey} className="flex items-center gap-3">
|
||||
<div className="min-w-[100px] text-sm font-medium text-gray-700">
|
||||
Неделя {weekData.week}
|
||||
</div>
|
||||
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
|
||||
{weekData.totalScore === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
|
||||
Нет данных
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{weekData.projects.map((project, index) => {
|
||||
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
|
||||
let left = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
left += weekData.projects[i].score
|
||||
}
|
||||
|
||||
const widthPercent = (project.score / maxTotalScore) * 100
|
||||
const leftPercent = (left / maxTotalScore) * 100
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectName}
|
||||
className="absolute h-full transition-all duration-300 hover:opacity-90"
|
||||
style={{
|
||||
left: `${leftPercent}%`,
|
||||
width: `${widthPercent}%`,
|
||||
backgroundColor: project.color,
|
||||
}}
|
||||
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-[60px] text-right text-sm text-gray-600 font-medium">
|
||||
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeekProgressChart
|
||||
|
||||
248
play-life-web/src/components/WordList.css
Normal file
248
play-life-web/src/components/WordList.css
Normal file
@@ -0,0 +1,248 @@
|
||||
.word-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.word-list h2 {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background-color: transparent;
|
||||
color: #3498db;
|
||||
border: 2px solid #3498db;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.loading, .error-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.words-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.word-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: #fafafa;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.word-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.word-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.word-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.word-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.word-translation {
|
||||
font-size: 1rem;
|
||||
color: #34495e;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.word-description {
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.word-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.stat-separator {
|
||||
color: #7f8c8d;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.stat-failure {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.dictionary-name-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-right: 60px; /* Space for close button */
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dictionary-name-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
border-radius: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.dictionary-name-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: #3498db;
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
|
||||
.dictionary-name-input::placeholder {
|
||||
color: #95a5a6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dictionary-name-save-button {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(39, 174, 96, 0.25);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dictionary-name-save-button:hover:not(:disabled) {
|
||||
background-color: #229954;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||
}
|
||||
|
||||
.dictionary-name-save-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dictionary-name-save-button:disabled {
|
||||
background-color: #95a5a6;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 4rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
z-index: 1500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #3498db;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
246
play-life-web/src/components/WordList.jsx
Normal file
246
play-life-web/src/components/WordList.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import './WordList.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
||||
const [words, setWords] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [dictionary, setDictionary] = useState(null)
|
||||
const [dictionaryName, setDictionaryName] = useState('')
|
||||
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
|
||||
const [isSavingName, setIsSavingName] = useState(false)
|
||||
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId)
|
||||
const [isNewDict, setIsNewDict] = useState(isNewDictionary)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDictionaryId(dictionaryId)
|
||||
setIsNewDict(isNewDictionary)
|
||||
|
||||
if (isNewDictionary) {
|
||||
setLoading(false)
|
||||
setDictionary(null)
|
||||
setDictionaryName('')
|
||||
setOriginalDictionaryName('')
|
||||
setWords([])
|
||||
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
||||
fetchDictionary()
|
||||
fetchWords()
|
||||
} else {
|
||||
setLoading(false)
|
||||
setWords([])
|
||||
}
|
||||
}, [dictionaryId, isNewDictionary, refreshTrigger])
|
||||
|
||||
const fetchDictionary = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
const dictionaries = await response.json()
|
||||
const dict = dictionaries.find(d => d.id === dictionaryId)
|
||||
if (dict) {
|
||||
setDictionary(dict)
|
||||
setDictionaryName(dict.name)
|
||||
setOriginalDictionaryName(dict.name)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching dictionary:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWords = async () => {
|
||||
if (isNewDictionary || dictionaryId === undefined || dictionaryId === null) {
|
||||
setWords([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await fetchWordsForDictionary(dictionaryId)
|
||||
}
|
||||
|
||||
const fetchWordsForDictionary = async (dictId) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
const data = await response.json()
|
||||
setWords(Array.isArray(data) ? data : [])
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setWords([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setDictionaryName(e.target.value)
|
||||
}
|
||||
|
||||
const handleNameSave = async () => {
|
||||
if (!dictionaryName.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSavingName(true)
|
||||
try {
|
||||
if (isNewDictionary) {
|
||||
// Create new dictionary
|
||||
const response = await fetch(`${API_URL}/dictionaries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: dictionaryName.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при создании словаря')
|
||||
}
|
||||
|
||||
const newDict = await response.json()
|
||||
const newDictionaryId = newDict.id
|
||||
|
||||
// Update local state immediately
|
||||
setOriginalDictionaryName(newDict.name)
|
||||
setDictionaryName(newDict.name)
|
||||
setDictionary(newDict)
|
||||
setCurrentDictionaryId(newDictionaryId)
|
||||
setIsNewDict(false)
|
||||
|
||||
// Fetch words for the new dictionary
|
||||
await fetchWordsForDictionary(newDictionaryId)
|
||||
|
||||
// Update navigation to use the new dictionary ID and remove isNewDictionary flag
|
||||
onNavigate?.('words', { dictionaryId: newDictionaryId, isNewDictionary: false })
|
||||
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
||||
// Update existing dictionary
|
||||
const response = await fetch(`${API_URL}/dictionaries/${dictionaryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: dictionaryName.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при обновлении словаря')
|
||||
}
|
||||
|
||||
setOriginalDictionaryName(dictionaryName.trim())
|
||||
if (dictionary) {
|
||||
setDictionary({ ...dictionary, name: dictionaryName.trim() })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsSavingName(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="word-list">
|
||||
<div className="loading">Загрузка...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="word-list">
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="word-list">
|
||||
<button
|
||||
onClick={() => onNavigate?.('test-config')}
|
||||
className="close-x-button"
|
||||
title="Закрыть"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Dictionary name input */}
|
||||
<div className="dictionary-name-input-container">
|
||||
<input
|
||||
type="text"
|
||||
className="dictionary-name-input"
|
||||
value={dictionaryName}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Введите название словаря"
|
||||
/>
|
||||
{showSaveButton && (
|
||||
<button
|
||||
className="dictionary-name-save-button"
|
||||
onClick={handleNameSave}
|
||||
disabled={isSavingName}
|
||||
title="Сохранить название"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show add button and words list:
|
||||
- If dictionary exists (has dictionaryId), show regardless of name
|
||||
- If new dictionary (no dictionaryId), show only if name is set */}
|
||||
{((currentDictionaryId !== undefined && currentDictionaryId !== null && !isNewDict) || (isNewDict && dictionaryName.trim())) && (
|
||||
<>
|
||||
{(!words || words.length === 0) ? (
|
||||
<>
|
||||
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
|
||||
Добавить
|
||||
</button>
|
||||
<div className="empty-state">
|
||||
<p>Слов пока нет. Добавьте слова через экран "Добавить слова".</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
|
||||
Добавить
|
||||
</button>
|
||||
<div className="words-grid">
|
||||
{words.map((word) => (
|
||||
<div key={word.id} className="word-card">
|
||||
<div className="word-content">
|
||||
<div className="word-header">
|
||||
<h3 className="word-name">{word.name}</h3>
|
||||
</div>
|
||||
<div className="word-translation">{word.translation}</div>
|
||||
{word.description && (
|
||||
<div className="word-description">{word.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="word-stats">
|
||||
<span className="stat-success">{word.success || 0}</span>
|
||||
<span className="stat-separator"> | </span>
|
||||
<span className="stat-failure">{word.failure || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WordList
|
||||
|
||||
48
play-life-web/src/index.css
Normal file
48
play-life-web/src/index.css
Normal file
@@ -0,0 +1,48 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
min-height: 100dvh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh; /* Dynamic viewport height для мобильных устройств */
|
||||
background: #f3f4f6;
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
|
||||
11
play-life-web/src/main.jsx
Normal file
11
play-life-web/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
104
play-life-web/src/utils/projectUtils.js
Normal file
104
play-life-web/src/utils/projectUtils.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Утилиты для работы с проектами - обеспечивают единую сортировку и цвета
|
||||
|
||||
// Функция для генерации цвета проекта на основе его индекса в отсортированном списке
|
||||
export function getProjectColorByIndex(index) {
|
||||
const hue = (index * 137.508) % 360 // Золотой угол для равномерного распределения цветов
|
||||
return `hsl(${hue}, 70%, 50%)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает отсортированный список всех проектов для определения цветов
|
||||
* Сортировка: по алфавиту (стабильный порядок для цветов)
|
||||
*
|
||||
* @param {Array} allProjectsData - данные полной статистики (массив объектов с project_name)
|
||||
* @param {Array} currentWeekData - данные текущей недели (массив объектов с project_name)
|
||||
* @returns {Array} отсортированный массив названий проектов
|
||||
*/
|
||||
export function getAllProjectsSorted(allProjectsData, currentWeekData = null) {
|
||||
const projectsSet = new Set()
|
||||
|
||||
// Собираем проекты из полной статистики (приоритетный источник)
|
||||
if (allProjectsData && allProjectsData.length > 0) {
|
||||
allProjectsData.forEach(item => {
|
||||
projectsSet.add(item.project_name)
|
||||
})
|
||||
}
|
||||
|
||||
// Если данных полной статистики нет, используем проекты из текущей недели
|
||||
if (projectsSet.size === 0 && currentWeekData) {
|
||||
const projects = Array.isArray(currentWeekData)
|
||||
? currentWeekData
|
||||
: (currentWeekData?.projects || [])
|
||||
|
||||
projects.forEach(item => {
|
||||
projectsSet.add(item.project_name)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(projectsSet).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает цвет проекта на основе его названия
|
||||
*
|
||||
* @param {string} projectName - название проекта
|
||||
* @param {Array} allProjectsSorted - отсортированный список всех проектов
|
||||
* @returns {string} цвет в формате HSL
|
||||
*/
|
||||
export function getProjectColor(projectName, allProjectsSorted) {
|
||||
const projectIndex = allProjectsSorted.indexOf(projectName)
|
||||
return projectIndex >= 0 ? getProjectColorByIndex(projectIndex) : '#9CA3AF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует значение priority для сортировки
|
||||
*/
|
||||
function normalizePriority(value) {
|
||||
if (value === null || value === undefined) return Infinity
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : Infinity
|
||||
}
|
||||
|
||||
/**
|
||||
* Сортирует проекты так же, как на экране списка проектов:
|
||||
* сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
||||
*
|
||||
* @param {Array} projectNames - массив названий проектов для сортировки
|
||||
* @param {Array} currentWeekData - данные текущей недели с информацией о priority и min_goal_score
|
||||
* @returns {Array} отсортированный массив названий проектов
|
||||
*/
|
||||
export function sortProjectsLikeCurrentWeek(projectNames, currentWeekData) {
|
||||
if (!currentWeekData || projectNames.length === 0) {
|
||||
return projectNames
|
||||
}
|
||||
|
||||
// Получаем данные проектов из currentWeekData
|
||||
const projectsData = currentWeekData?.projects || (Array.isArray(currentWeekData) ? currentWeekData : [])
|
||||
|
||||
// Создаем Map для быстрого доступа к данным проекта
|
||||
const projectDataMap = new Map()
|
||||
projectsData.forEach(project => {
|
||||
if (project.project_name) {
|
||||
projectDataMap.set(project.project_name, {
|
||||
priority: project.priority,
|
||||
min_goal_score: parseFloat(project.min_goal_score) || 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Сортируем проекты
|
||||
return [...projectNames].sort((a, b) => {
|
||||
const dataA = projectDataMap.get(a) || { priority: null, min_goal_score: 0 }
|
||||
const dataB = projectDataMap.get(b) || { priority: null, min_goal_score: 0 }
|
||||
|
||||
const priorityA = normalizePriority(dataA.priority)
|
||||
const priorityB = normalizePriority(dataB.priority)
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
return dataB.min_goal_score - dataA.min_goal_score
|
||||
})
|
||||
}
|
||||
|
||||
12
play-life-web/tailwind.config.js
Normal file
12
play-life-web/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
51
play-life-web/vite.config.js
Normal file
51
play-life-web/vite.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Загружаем переменные окружения из корня проекта
|
||||
// Сначала пробуем корневой .env, затем локальный
|
||||
const rootEnv = loadEnv(mode, resolve(process.cwd(), '..'), '')
|
||||
const localEnv = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// Объединяем переменные (локальные имеют приоритет)
|
||||
const env = { ...rootEnv, ...localEnv, ...process.env }
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: parseInt(env.VITE_PORT || '3000', 10),
|
||||
proxy: {
|
||||
// Proxy API requests to backend
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Proxy other API endpoints
|
||||
'/playlife-feed': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/projects': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/project': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
25
supervisord.conf
Normal file
25
supervisord.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
user=root
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/var/log/supervisor/nginx.err.log
|
||||
stdout_logfile=/var/log/supervisor/nginx.out.log
|
||||
priority=10
|
||||
|
||||
[program:backend]
|
||||
command=/app/backend/main
|
||||
directory=/app/backend
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/var/log/supervisor/backend.err.log
|
||||
stdout_logfile=/var/log/supervisor/backend.out.log
|
||||
priority=20
|
||||
# Переменные окружения будут переданы из docker run --env-file
|
||||
# PORT по умолчанию 8080 внутри контейнера
|
||||
|
||||
Reference in New Issue
Block a user