Migrate easysite from api_es to api_yal
- Remove api_es service, Dockerfile, all Go source files - Remove api_es from docker-compose.yml, nginx-ssl.conf, .env, Makefile - Replace nginx /api/ proxy with /api/v1/ → api_yal:8787 - Add amenity/upload domains, AuthResponse, GET /auth/me, GET /objects/my to api_yal - Rewrite easysite frontend: types, composables, and all 5 pages to use api_yal DTOs - Wire nuxt.config public.apiBase, add useObjects CRUD composable - Update docs references from api_es to api_yal
This commit is contained in:
@@ -12,5 +12,3 @@ ALL_DOMAINS=yalarba.ru,www.yalarba.ru,valitovgaziz.ru,www.valitovgaziz.ru,easysi
|
|||||||
KEYCLOAK_ADMIN_PASSWORD=your_secure_password
|
KEYCLOAK_ADMIN_PASSWORD=your_secure_password
|
||||||
KEYCLOAK_DB_PASSWORD=your_secure_db_password
|
KEYCLOAK_DB_PASSWORD=your_secure_db_password
|
||||||
|
|
||||||
# API_ES port
|
|
||||||
API_ES_APP_PORT=8088
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
│ Docker Compose Cluster │
|
│ Docker Compose Cluster │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_ES │ │
|
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_YAL │ │
|
||||||
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
||||||
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
|
|||||||
@@ -165,21 +165,6 @@ restart_analytics:
|
|||||||
# Полный цикл обновления analytics
|
# Полный цикл обновления analytics
|
||||||
analytics: stop_analitics git build_analititcs start_analytics wn
|
analytics: stop_analitics git build_analititcs start_analytics wn
|
||||||
|
|
||||||
# Остановка api_es
|
|
||||||
stop_api_es:
|
|
||||||
docker compose down api_es
|
|
||||||
|
|
||||||
# Пересборка api_es
|
|
||||||
build_api_es:
|
|
||||||
docker compose build api_es --no-cache
|
|
||||||
|
|
||||||
# Запуск api_es
|
|
||||||
start_api_es:
|
|
||||||
docker compose up api_es -d
|
|
||||||
|
|
||||||
# Полный цикл обновления api_es
|
|
||||||
api_es: stop_api_es git build_api_es start_api_es wn
|
|
||||||
|
|
||||||
# Остановка certbot
|
# Остановка certbot
|
||||||
stop_cerbot:
|
stop_cerbot:
|
||||||
docker compose down certbot
|
docker compose down certbot
|
||||||
|
|||||||
+2
-2
@@ -68,7 +68,7 @@ sites:
|
|||||||
aliases: [www.easysite102.ru]
|
aliases: [www.easysite102.ru]
|
||||||
type: container
|
type: container
|
||||||
upstream: http://easysite:3000
|
upstream: http://easysite:3000
|
||||||
api: http://api_es:8088/
|
api: http://api_yal:8787/
|
||||||
|
|
||||||
begushiybashkir:
|
begushiybashkir:
|
||||||
domain: begushiybashkir.ru
|
domain: begushiybashkir.ru
|
||||||
@@ -165,7 +165,7 @@ sites:
|
|||||||
aliases: [www.easysite102.ru]
|
aliases: [www.easysite102.ru]
|
||||||
type: container
|
type: container
|
||||||
upstream: http://easysite:3000
|
upstream: http://easysite:3000
|
||||||
api: http://api_es:8088/
|
api: http://api_yal:8787/
|
||||||
|
|
||||||
begushiybashkir:
|
begushiybashkir:
|
||||||
domain: begushiybashkir.ru
|
domain: begushiybashkir.ru
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
easysite:
|
easysite:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api_es:
|
|
||||||
condition: service_healthy
|
|
||||||
certbot:
|
certbot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api_tp:
|
api_tp:
|
||||||
@@ -241,7 +239,7 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_es REST API app
|
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_yal REST API app
|
||||||
easysite:
|
easysite:
|
||||||
build:
|
build:
|
||||||
context: ./yalarba/easySite/easySite
|
context: ./yalarba/easySite/easySite
|
||||||
@@ -254,6 +252,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
NUXT_PUBLIC_API_BASE: /api/v1
|
||||||
networks:
|
networks:
|
||||||
- web-network
|
- web-network
|
||||||
- app-network
|
- app-network
|
||||||
@@ -263,34 +262,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# REST API приложение для easysite102.ru тут бизнес логика и система для обращения к PostgresQL БД (тоже сервис db:db_tp)
|
|
||||||
api_es:
|
|
||||||
build:
|
|
||||||
context: ./yalarba/api_es
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: api_es
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ./yalarba/api_es/.env
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DB_HOST: db
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: postgres
|
|
||||||
DB_PASSWORD: postgres
|
|
||||||
DB_NAME: mydb
|
|
||||||
APP_PORT: ${API_ES_APP_PORT}
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
- web-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--spider", "http://localhost:8088/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# REST API app on Golang для api_yal сервиса
|
# REST API app on Golang для api_yal сервиса
|
||||||
api_yal:
|
api_yal:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
│ • certbot - SSL сертификаты │
|
│ • certbot - SSL сертификаты │
|
||||||
│ • analytics - Статистика (Node.js) │
|
│ • analytics - Статистика (Node.js) │
|
||||||
│ • api_tp - API yalarba.ru (Go) │
|
│ • api_tp - API yalarba.ru (Go) │
|
||||||
│ • api_es - API easysite102.ru (Go) │
|
│ • api_yal - API easysite102.ru (Go) │
|
||||||
│ • api_bb - API Бегущий Башкир (Go) │
|
│ • api_bb - API Бегущий Башкир (Go) │
|
||||||
│ • easysite - SPA (Nuxt.js) │
|
│ • easysite - SPA (Nuxt.js) │
|
||||||
│ • db - PostgreSQL (yalarba/easy) │
|
│ • db - PostgreSQL (yalarba/easy) │
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|-------|-----|----------------|---------------|
|
|-------|-----|----------------|---------------|
|
||||||
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
|
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
|
||||||
| `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/html` |
|
| `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/html` |
|
||||||
| `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_es:8088` | Прокси |
|
| `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_yal:8787` | Прокси |
|
||||||
| `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
| `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||||
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
```
|
```
|
||||||
EMAIL=admin@example.com # Для Let's Encrypt
|
EMAIL=admin@example.com # Для Let's Encrypt
|
||||||
ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL
|
ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL
|
||||||
API_ES_APP_PORT=8088 # Порт API easysite
|
# API_ES убран, используется api_yal:8787
|
||||||
```
|
```
|
||||||
|
|
||||||
### Сервисные
|
### Сервисные
|
||||||
@@ -141,14 +141,14 @@ STAGING=0 # 1 для тестового режима Let's Encrypt
|
|||||||
| certbot | Проверка файла сертификата | - | 30s |
|
| certbot | Проверка файла сертификата | - | 30s |
|
||||||
| analytics | `http://localhost:3000/health` | 3000 | 30s |
|
| analytics | `http://localhost:3000/health` | 3000 | 30s |
|
||||||
| api_tp | `http://localhost:8080/health` | 8080 | 30s |
|
| api_tp | `http://localhost:8080/health` | 8080 | 30s |
|
||||||
| api_es | `http://localhost:8088/health` | 8088 | 30s |
|
| api_yal | `http://localhost:8787/health` | 8787 | 30s |
|
||||||
| api_bb | `http://localhost:8080/api/health` | 8080 | 30s |
|
| api_bb | `http://localhost:8080/api/health` | 8080 | 30s |
|
||||||
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
||||||
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
||||||
|
|
||||||
### Зависимости запуска
|
### Зависимости запуска
|
||||||
Nginx запускается только после подтверждения здоровья:
|
Nginx запускается только после подтверждения здоровья:
|
||||||
- `easysite`, `api_es`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
- `easysite`, `api_yal`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
||||||
|
|
||||||
## Волумы
|
## Волумы
|
||||||
|
|
||||||
|
|||||||
@@ -231,82 +231,28 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЛОКАЦИЯ: API Backend для Easysite
|
# ЛОКАЦИЯ: API Backend для Easysite (api_yal)
|
||||||
# ============================================
|
# ============================================
|
||||||
location /api/ {
|
location /api/v1/ {
|
||||||
# Отдельный API endpoint для backend
|
proxy_pass http://api_yal:8787;
|
||||||
proxy_pass http://api_es:8088/;
|
|
||||||
|
|
||||||
# Заголовки прокси
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
# Таймауты как у основного приложения
|
|
||||||
proxy_connect_timeout 600;
|
proxy_connect_timeout 600;
|
||||||
proxy_send_timeout 600;
|
proxy_send_timeout 600;
|
||||||
proxy_read_timeout 600;
|
proxy_read_timeout 600;
|
||||||
|
|
||||||
# ========================================
|
if ($request_method = OPTIONS) {
|
||||||
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
|
|
||||||
# ========================================
|
|
||||||
if ($request_method = OPTIONS ) {
|
|
||||||
# Динамический заголовок Origin из запроса
|
|
||||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
|
||||||
|
|
||||||
# Подробный список разрешенных заголовков
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
|
||||||
# Время кэширования preflight ответа (20 дней)
|
|
||||||
add_header 'Access-Control-Max-Age' 1728000;
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
|
||||||
# Пустой ответ для OPTIONS
|
|
||||||
add_header 'Content-Length' 0;
|
add_header 'Content-Length' 0;
|
||||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
|
|
||||||
# Возвращаем 204 без тела ответа
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api_yal/ {
|
|
||||||
# Отдельный API endpoint для backend
|
|
||||||
proxy_pass http://api_yal:8787/;
|
|
||||||
|
|
||||||
# Заголовки прокси
|
|
||||||
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_set_header X-Forwarded-Port $server_port;
|
|
||||||
|
|
||||||
# Таймауты как у основного приложения
|
|
||||||
proxy_connect_timeout 600;
|
|
||||||
proxy_send_timeout 600;
|
|
||||||
proxy_read_timeout 600;
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
|
|
||||||
# ========================================
|
|
||||||
if ($request_method = OPTIONS ) {
|
|
||||||
# Динамический заголовок Origin из запроса
|
|
||||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
|
|
||||||
|
|
||||||
# Подробный список разрешенных заголовков
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
|
||||||
|
|
||||||
# Время кэширования preflight ответа (20 дней)
|
|
||||||
add_header 'Access-Control-Max-Age' 1728000;
|
|
||||||
|
|
||||||
# Пустой ответ для OPTIONS
|
|
||||||
add_header 'Content-Length' 0;
|
|
||||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
|
||||||
|
|
||||||
# Возвращаем 204 без тела ответа
|
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# DB environment variabels
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=postgres
|
|
||||||
DB_NAME=mydb
|
|
||||||
APP_PORT=8080
|
|
||||||
JWT_SECRET=secret
|
|
||||||
UPLOAD_PATH=./storage/uploads
|
|
||||||
ENVIRONMENT=development
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
API_ES_APP_PORT=8088
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM golang:1.25.1-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Устанавливаем зависимости для компиляции
|
|
||||||
RUN apk add --no-cache gcc musl-dev
|
|
||||||
|
|
||||||
# Копируем go.mod и go.sum
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Копируем исходный код
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Компилируем БЕЗ CGO (указываем путь к main.go)
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
|
|
||||||
|
|
||||||
EXPOSE 8081
|
|
||||||
|
|
||||||
CMD ["./bin/main"]
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"api_es/internal/config"
|
|
||||||
"api_es/internal/database"
|
|
||||||
"api_es/internal/router"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Загрузка конфигурации приложения из файлов окружения или конфигурационных файлов
|
|
||||||
// Конфигурация включает параметры БД, уровень логирования, порт приложения и т.д.
|
|
||||||
cfg := config.Load()
|
|
||||||
|
|
||||||
// Инициализация логгера с указанным уровнем логирования и окружением (dev/prod)
|
|
||||||
// Логгер будет настроен соответствующим образом для заданного окружения
|
|
||||||
logger.Init(cfg.LogLevel, cfg.Environment)
|
|
||||||
|
|
||||||
// Получение инстанса логгера для использования во всем приложении
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
|
|
||||||
// Логирование старта приложения с указанием используемого стека технологий
|
|
||||||
zapLogger.Info("Start api_es REST API on stack Golang (gorm, chi) and PostgresDB connect")
|
|
||||||
|
|
||||||
// Инициализация подключения к базе данных PostgreSQL с использованием параметров из конфигурации
|
|
||||||
// Возвращается объект gorm.DB для работы с ORM
|
|
||||||
db, err := database.NewPostgresConnection(cfg)
|
|
||||||
if err != nil {
|
|
||||||
// Критическая ошибка подключения к БД - приложение не может работать без БД
|
|
||||||
zapLogger.Panic("Failed to connect to database:", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получение низкоуровневого объекта *sql.DB из gorm.DB для выполнения операций,
|
|
||||||
// не поддерживаемых напрямую gorm (например, Ping)
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
// Ошибка получения инстанса БД, но приложение может продолжить работу
|
|
||||||
zapLogger.Error("failed to get database instance", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка доступности базы данных через ping-запрос
|
|
||||||
// Убеждаемся, что соединение активно и БД отвечает
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
zapLogger.Error("database ping failed", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Успешная проверка соединения с БД
|
|
||||||
zapLogger.Info("database ping successful")
|
|
||||||
|
|
||||||
// Настройка маршрутизатора (роутера) для обработки HTTP-запросов
|
|
||||||
// Передаем подключение к БД и конфигурацию для инициализации обработчиков
|
|
||||||
zapLogger.Info("setup router")
|
|
||||||
r := router.SetupRouter(db, cfg)
|
|
||||||
|
|
||||||
// Запуск HTTP-сервера на порту, указанном в конфигурации
|
|
||||||
// Сервер начинает прослушивать входящие соединения
|
|
||||||
zapLogger.Info("Server starting on port %s", zap.String("AppPort", cfg.AppPort))
|
|
||||||
log.Printf("Server starting on port %s", cfg.AppPort)
|
|
||||||
|
|
||||||
// Запуск HTTP-сервера с указанным роутером
|
|
||||||
// ListenAndServe блокирует выполнение и обрабатывает входящие запросы
|
|
||||||
// В случае ошибки запуска сервера, логируем ошибку и завершаем приложение
|
|
||||||
if err := http.ListenAndServe(":"+cfg.AppPort, r); err != nil {
|
|
||||||
log.Fatal("Failed to start server:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
# Документация REST API сервиса "Travel Platform"
|
|
||||||
|
|
||||||
## Общая информация
|
|
||||||
API сервиса для управления туристическими объектами (отели, санатории, достопримечательности и др.) с системой аутентификации пользователей, отзывами и фильтрацией.
|
|
||||||
|
|
||||||
## Базовый URL
|
|
||||||
`http://localhost:8080` (или другой хост/порт в зависимости от конфигурации)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Модели данных
|
|
||||||
|
|
||||||
### Пользователь (User)
|
|
||||||
**Поля:**
|
|
||||||
- `id` - уникальный идентификатор
|
|
||||||
- `email` - электронная почта (уникальный)
|
|
||||||
- `password_hash` - хеш пароля
|
|
||||||
- `full_name`, `first_name`, `last_name` - имя пользователя
|
|
||||||
- `phone`, `city` - контактная информация
|
|
||||||
- `organization_*` - бизнес-данные для владельцев
|
|
||||||
- `is_active`, `is_verified`, `role` - статус и права доступа
|
|
||||||
|
|
||||||
### Объект (Object)
|
|
||||||
**Типы объектов:**
|
|
||||||
- `hotel` - отель
|
|
||||||
- `sanatorium` - санаторий
|
|
||||||
- `guest_house` - гостевой дом
|
|
||||||
- `tour` - тур
|
|
||||||
- `restaurant` - ресторан
|
|
||||||
- `museum` - музей
|
|
||||||
- `landmark` - достопримечательность
|
|
||||||
- `event` - мероприятие
|
|
||||||
- `route` - маршрут
|
|
||||||
|
|
||||||
**Статусы объектов:**
|
|
||||||
- `draft` - черновик
|
|
||||||
- `moderation` - на модерации
|
|
||||||
- `active` - активен
|
|
||||||
- `inactive` - неактивен
|
|
||||||
- `rejected` - отклонен
|
|
||||||
|
|
||||||
### Отзыв (Review)
|
|
||||||
- Оценка от 1 до 5 звезд
|
|
||||||
- Текстовый отзыв
|
|
||||||
- Связь с объектом и автором
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Аутентификация и авторизация
|
|
||||||
|
|
||||||
### Система токенов:
|
|
||||||
- **Access Token** - для доступа к защищенным ресурсам
|
|
||||||
- **Refresh Token** - для обновления access token
|
|
||||||
- **Token Type**: Bearer
|
|
||||||
- Токены передаются в заголовке `Authorization: Bearer <token>`
|
|
||||||
|
|
||||||
### Роли пользователей:
|
|
||||||
1. **user** - обычный пользователь
|
|
||||||
2. **moderator** - модератор
|
|
||||||
3. **admin** - администратор
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Эндпоинты API
|
|
||||||
|
|
||||||
### 1. Проверка работоспособности
|
|
||||||
**GET /health**
|
|
||||||
**GET /check**
|
|
||||||
*Проверка доступности сервиса*
|
|
||||||
|
|
||||||
### 2. Аутентификация
|
|
||||||
|
|
||||||
#### Регистрация пользователя
|
|
||||||
**POST /auth/register**
|
|
||||||
*Создание нового аккаунта*
|
|
||||||
|
|
||||||
**Тело запроса (UserRegisterRequest):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"password": "password123",
|
|
||||||
"full_name": "Иван Иванов",
|
|
||||||
"phone": "+79991234567",
|
|
||||||
"city": "Москва"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Вход в систему
|
|
||||||
**POST /auth/login**
|
|
||||||
*Получение токенов доступа*
|
|
||||||
|
|
||||||
**Тело запроса (AuthRequest):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ответ (AuthResponse):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": {
|
|
||||||
"id": 1,
|
|
||||||
"email": "user@example.com",
|
|
||||||
"full_name": "Иван Иванов"
|
|
||||||
// ... остальные поля UserResponse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Обновление токена
|
|
||||||
**POST /auth/refresh**
|
|
||||||
*Получение нового access token по refresh token*
|
|
||||||
|
|
||||||
**Тело запроса (RefreshTokenRequest):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Выход из системы
|
|
||||||
**POST /auth/logout**
|
|
||||||
*Инвалидация токенов*
|
|
||||||
|
|
||||||
### 3. Профиль пользователя
|
|
||||||
|
|
||||||
#### Получение профиля
|
|
||||||
**GET /users/profile**
|
|
||||||
*Требуется аутентификация*
|
|
||||||
*Получение данных текущего пользователя*
|
|
||||||
|
|
||||||
#### Обновление профиля
|
|
||||||
**PUT /users/profile**
|
|
||||||
*Требуется аутентификация*
|
|
||||||
*Обновление данных пользователя*
|
|
||||||
|
|
||||||
### 4. Управление пользователями (Admin)
|
|
||||||
|
|
||||||
#### Список пользователей
|
|
||||||
**GET /users**
|
|
||||||
*Требуется роль admin*
|
|
||||||
*Получение списка всех пользователей*
|
|
||||||
|
|
||||||
#### Получение пользователя по ID
|
|
||||||
**GET /users/{id}**
|
|
||||||
*Требуется роль admin*
|
|
||||||
*Получение данных конкретного пользователя*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фильтрация и пагинация
|
|
||||||
|
|
||||||
Для эндпоинтов списков объектов поддерживается фильтрация через `ObjectFilter`:
|
|
||||||
|
|
||||||
**Параметры запроса:**
|
|
||||||
- `search` - текстовый поиск
|
|
||||||
- `type` - тип объекта (hotel, sanatorium и т.д.)
|
|
||||||
- `city` - город
|
|
||||||
- `min_price`, `max_price` - диапазон цен
|
|
||||||
- `min_rating` - минимальный рейтинг
|
|
||||||
- `status` - статус объекта
|
|
||||||
- `owner_id` - ID владельца
|
|
||||||
- `page` - номер страницы (начинается с 1)
|
|
||||||
- `page_size` - количество элементов на странице (1-100)
|
|
||||||
- `sort_by` - поле сортировки (title, price, rating, city, created_at)
|
|
||||||
- `sort_order` - порядок сортировки (asc, desc)
|
|
||||||
|
|
||||||
**Пример запроса:**
|
|
||||||
```
|
|
||||||
GET /objects?city=Москва&min_price=1000&max_price=5000&page=1&page_size=20&sort_by=price&sort_order=asc
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Формат ответа с пагинацией
|
|
||||||
|
|
||||||
Для списков возвращается `PaginatedResponse`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [...], // массив объектов
|
|
||||||
"total": 150, // общее количество
|
|
||||||
"page": 1, // текущая страница
|
|
||||||
"page_size": 20, // элементов на странице
|
|
||||||
"total_pages": 8 // всего страниц
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обработка ошибок
|
|
||||||
|
|
||||||
Сервис использует стандартные HTTP статусы:
|
|
||||||
- `200` - успешный запрос
|
|
||||||
- `201` - создан новый ресурс
|
|
||||||
- `400` - ошибка валидации
|
|
||||||
- `401` - неавторизован
|
|
||||||
- `403` - доступ запрещен
|
|
||||||
- `404` - ресурс не найден
|
|
||||||
- `500` - внутренняя ошибка сервера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Следующие шаги (планируемые эндпоинты)
|
|
||||||
|
|
||||||
На основе моделей данных ожидаются следующие API:
|
|
||||||
|
|
||||||
### Управление объектами:
|
|
||||||
- `GET /objects` - список объектов с фильтрацией
|
|
||||||
- `GET /objects/{id}` - получение объекта
|
|
||||||
- `POST /objects` - создание объекта (требуется аутентификация)
|
|
||||||
- `PUT /objects/{id}` - обновление объекта
|
|
||||||
- `DELETE /objects/{id}` - удаление объекта
|
|
||||||
|
|
||||||
### Управление отзывами:
|
|
||||||
- `GET /objects/{id}/reviews` - отзывы объекта
|
|
||||||
- `POST /reviews` - создание отзыва
|
|
||||||
- `PUT /reviews/{id}` - обновление отзыва
|
|
||||||
- `DELETE /reviews/{id}` - удаление отзыва
|
|
||||||
|
|
||||||
### Модерация:
|
|
||||||
- `GET /moderation/objects` - объекты на модерации
|
|
||||||
- `POST /moderation/objects/{id}/approve` - утвердить объект
|
|
||||||
- `POST /moderation/objects/{id}/reject` - отклонить объект
|
|
||||||
|
|
||||||
### Отчеты и аналитика:
|
|
||||||
- `GET /reports/popular-objects` - популярные объекты
|
|
||||||
- `GET /reports/user-activity` - активность пользователей
|
|
||||||
- `GET /reviews/revenue` - аналитика доходов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технические детали
|
|
||||||
|
|
||||||
### База данных:
|
|
||||||
- Используется GORM (Go ORM)
|
|
||||||
- Поддерживаются миграции
|
|
||||||
- Soft delete для основных сущностей
|
|
||||||
|
|
||||||
### Логирование:
|
|
||||||
- Структурированное логирование через Zap
|
|
||||||
- Логирование маршрутов при запуске
|
|
||||||
- Middleware для логирования запросов
|
|
||||||
|
|
||||||
### Конфигурация:
|
|
||||||
- Централизованная конфигурация через `config.Config`
|
|
||||||
- Поддержка разных окружений
|
|
||||||
|
|
||||||
### Middleware:
|
|
||||||
- Аутентификация (`AuthMiddleware`)
|
|
||||||
- Авторизация по ролям (`AdminMiddleware`)
|
|
||||||
- Логирование
|
|
||||||
- Recovery от паник
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
module api_es
|
|
||||||
|
|
||||||
go 1.25.1
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
|
||||||
gorm.io/driver/postgres v1.6.0
|
|
||||||
gorm.io/gorm v1.25.10
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/go-chi/cors v1.2.2
|
|
||||||
github.com/go-playground/validator/v10 v10.28.0
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
go.uber.org/zap v1.27.0
|
|
||||||
golang.org/x/crypto v0.42.0
|
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
|
||||||
golang.org/x/text v0.29.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
|
||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
|
||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
|
||||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
|
||||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
DBHost string
|
|
||||||
DBPort string
|
|
||||||
DBUser string
|
|
||||||
DBPassword string
|
|
||||||
DBName string
|
|
||||||
JWTSecret string
|
|
||||||
ServerPort string
|
|
||||||
UploadPath string
|
|
||||||
LogLevel string
|
|
||||||
Environment string
|
|
||||||
AppPort string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() *Config {
|
|
||||||
return &Config{
|
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
|
||||||
DBPort: getEnv("DB_PORT", "5432"),
|
|
||||||
DBUser: getEnv("DB_USER", "postgres"),
|
|
||||||
DBPassword: getEnv("DB_PASSWORD", "postgres"),
|
|
||||||
DBName: getEnv("DB_NAME", "mydb"),
|
|
||||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
|
||||||
UploadPath: getEnv("UPLOAD_PATH", "./storage/uploads"),
|
|
||||||
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
|
||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
|
||||||
AppPort: getEnv("APP_PORT", "8088"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/models"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SeedInitialData(db *gorm.DB) error {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("start fill init data")
|
|
||||||
// Создание базовых удобств
|
|
||||||
amenities := []models.Amenity{
|
|
||||||
{Name: "Wi-Fi", Category: "basic", Icon: "wifi"},
|
|
||||||
{Name: "Парковка", Category: "basic", Icon: "parking"},
|
|
||||||
{Name: "Бассейн", Category: "comfort", Icon: "pool"},
|
|
||||||
// ... другие удобства
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, amenity := range amenities {
|
|
||||||
if err := db.FirstOrCreate(&amenity, models.Amenity{Name: amenity.Name}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
zapLogger.Debug("end fill init data")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/config"
|
|
||||||
"api_es/internal/models"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewPostgresConnection(cfg *config.Config) (*gorm.DB, error) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Info("Start connect to Postgres DB")
|
|
||||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
|
|
||||||
cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort)
|
|
||||||
zapLogger.Info("dsn = %s", zap.String("dsn", dsn))
|
|
||||||
|
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zapLogger.Info("AutoMigrate models")
|
|
||||||
// Автомиграция
|
|
||||||
if err := autoMigrate(db); err != nil {
|
|
||||||
zapLogger.Error("can't migrate models, error = %s", zap.Error(err))
|
|
||||||
return nil, fmt.Errorf("can't migrate models, error = %s", err)
|
|
||||||
}
|
|
||||||
zapLogger.Info("Migrate complite successfully")
|
|
||||||
|
|
||||||
zapLogger.Info("Fill init data")
|
|
||||||
SeedInitialData(db)
|
|
||||||
|
|
||||||
zapLogger.Info("Successfully connected to database")
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func autoMigrate(db *gorm.DB) error {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start migration")
|
|
||||||
models := []interface{}{
|
|
||||||
&models.User{},
|
|
||||||
&models.Object{},
|
|
||||||
&models.ObjectImage{},
|
|
||||||
&models.Amenity{},
|
|
||||||
&models.ObjectAmenity{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range models {
|
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
|
||||||
return fmt.Errorf("failed to migrate %T: %w", model, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
zapLogger.Debug("End migration seccessfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/models"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterRequest - запрос на регистрацию
|
|
||||||
type RegisterRequest struct {
|
|
||||||
Email string `json:"email" validate:"required,email"`
|
|
||||||
Password string `json:"password" validate:"required,min=6"`
|
|
||||||
FullName string `json:"full_name" validate:"required"`
|
|
||||||
FirstName string `json:"first_name" validate:"required"`
|
|
||||||
LastName string `json:"last_name" validate:"required"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRequest - запрос на вход
|
|
||||||
type LoginRequest struct {
|
|
||||||
Email string `json:"email" validate:"required,email"`
|
|
||||||
Password string `json:"password" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserRequest - запрос на обновление пользователя
|
|
||||||
type UpdateUserRequest struct {
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
FirstName string `json:"first_name"`
|
|
||||||
LastName string `json:"last_name"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
OrganizationForm string `json:"organization_form"`
|
|
||||||
OrganizationName string `json:"organization_name"`
|
|
||||||
OrganizationShort string `json:"organization_short"`
|
|
||||||
INN string `json:"inn"`
|
|
||||||
PersonalINN string `json:"personal_inn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserResponse - ответ с данными пользователя
|
|
||||||
type UserResponse struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
FirstName string `json:"first_name"`
|
|
||||||
LastName string `json:"last_name"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
OrganizationForm string `json:"organization_form"`
|
|
||||||
OrganizationName string `json:"organization_name"`
|
|
||||||
OrganizationShort string `json:"organization_short"`
|
|
||||||
INN string `json:"inn"`
|
|
||||||
PersonalINN string `json:"personal_inn"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
IsVerified bool `json:"is_verified"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthResponse - ответ с токеном
|
|
||||||
type AuthResponse struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
User UserResponse `json:"user"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToUserResponse преобразует модель в DTO
|
|
||||||
func ToUserResponse(user *models.User) UserResponse {
|
|
||||||
return UserResponse{
|
|
||||||
ID: user.ID,
|
|
||||||
Email: user.Email,
|
|
||||||
FullName: user.FullName,
|
|
||||||
FirstName: user.FirstName,
|
|
||||||
LastName: user.LastName,
|
|
||||||
Phone: user.Phone,
|
|
||||||
City: user.City,
|
|
||||||
OrganizationForm: user.OrganizationForm,
|
|
||||||
OrganizationName: user.OrganizationName,
|
|
||||||
OrganizationShort: user.OrganizationShort,
|
|
||||||
INN: user.INN,
|
|
||||||
PersonalINN: user.PersonalINN,
|
|
||||||
IsActive: user.IsActive,
|
|
||||||
IsVerified: user.IsVerified,
|
|
||||||
Role: user.Role,
|
|
||||||
CreatedAt: user.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dto/auth.go (добавляем если нужно)
|
|
||||||
type RefreshTokenRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/config"
|
|
||||||
"api_es/internal/repository"
|
|
||||||
"api_es/internal/service"
|
|
||||||
"api_es/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AllHandler struct {
|
|
||||||
userHandler *UserHandler
|
|
||||||
healthHandler *HealthHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAllHandler(db *gorm.DB, cfg *config.Config) *AllHandler {
|
|
||||||
|
|
||||||
userRepo := repository.NewUserRepository(db)
|
|
||||||
|
|
||||||
userService := service.NewUserService(userRepo, utils.NewJWTUtil(cfg.JWTSecret))
|
|
||||||
|
|
||||||
userHandler := NewUserHandler(userService)
|
|
||||||
healthHandler := NewHealthHandler()
|
|
||||||
|
|
||||||
return &AllHandler{
|
|
||||||
userHandler: userHandler,
|
|
||||||
healthHandler: healthHandler,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AllHandler) UserHandler() *UserHandler {
|
|
||||||
return h.userHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AllHandler) HealthHandler() *HealthHandler {
|
|
||||||
return h.healthHandler
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"api_es/internal/utils"
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
type HealthHandler struct{}
|
|
||||||
|
|
||||||
func NewHealthHandler() *HealthHandler {
|
|
||||||
return &HealthHandler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|
||||||
response := map[string]string{
|
|
||||||
"status": "ok",
|
|
||||||
"message": "Service is healthy",
|
|
||||||
}
|
|
||||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
|
|
||||||
response := map[string]string{
|
|
||||||
"status": "ok",
|
|
||||||
"message": "API is working",
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/dto"
|
|
||||||
appMiddleware "api_es/internal/middleware"
|
|
||||||
"api_es/internal/service"
|
|
||||||
"api_es/internal/utils"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-playground/validator/v10"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserHandler struct {
|
|
||||||
userService service.UserService
|
|
||||||
validator *validator.Validate
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserHandler(userService service.UserService) *UserHandler {
|
|
||||||
return &UserHandler{
|
|
||||||
userService: userService,
|
|
||||||
validator: validator.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register godoc
|
|
||||||
// @Summary Register new user
|
|
||||||
// @Description Create a new user account
|
|
||||||
// @Tags auth
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.RegisterRequest true "Register request"
|
|
||||||
// @Success 201 {object} dto.AuthResponse
|
|
||||||
// @Failure 400 {object} map[string]string
|
|
||||||
// @Failure 500 {object} map[string]string
|
|
||||||
// @Router /auth/register [post]
|
|
||||||
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start register")
|
|
||||||
var req dto.RegisterRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.validator.Struct(req); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := h.userService.Register(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case service.ErrUserAlreadyExists:
|
|
||||||
http.Error(w, "User already exists", http.StatusConflict)
|
|
||||||
default:
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем куку с токеном
|
|
||||||
appMiddleware.SetAuthCookie(w, response.Token)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
zapLogger.Debug("End register")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login godoc
|
|
||||||
// @Summary Login user
|
|
||||||
// @Description Authenticate user and get token
|
|
||||||
// @Tags auth
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.LoginRequest true "Login request"
|
|
||||||
// @Success 200 {object} dto.AuthResponse
|
|
||||||
// @Failure 400 {object} map[string]string
|
|
||||||
// @Failure 401 {object} map[string]string
|
|
||||||
// @Router /auth/login [post]
|
|
||||||
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start login")
|
|
||||||
var req dto.LoginRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.validator.Struct(req); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := h.userService.Login(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
switch err {
|
|
||||||
case service.ErrInvalidCredentials:
|
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
||||||
default:
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем куку с токеном
|
|
||||||
appMiddleware.SetAuthCookie(w, response.Token)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
zapLogger.Debug("End login")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем новый метод для logout
|
|
||||||
// Logout godoc
|
|
||||||
// @Summary Logout user
|
|
||||||
// @Description Clear authentication cookies and tokens
|
|
||||||
// @Tags auth
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Success 200 {object} map[string]string
|
|
||||||
// @Router /auth/logout [post]
|
|
||||||
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Очищаем auth cookie
|
|
||||||
appMiddleware.ClearAuthCookie(w)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"message": "Successfully logged out",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем метод для обновления токена
|
|
||||||
// RefreshToken godoc
|
|
||||||
// @Summary Refresh authentication token
|
|
||||||
// @Description Refresh JWT token using refresh token or existing auth
|
|
||||||
// @Tags auth
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Success 200 {object} dto.AuthResponse
|
|
||||||
// @Failure 401 {object} map[string]string
|
|
||||||
// @Router /auth/refresh [post]
|
|
||||||
func (h *UserHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.GetUserProfile(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "User not found", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем новый токен
|
|
||||||
// В реальном приложении здесь должна быть логика с refresh token
|
|
||||||
jwtUtil := utils.NewJWTUtil("secret")
|
|
||||||
newToken, err := jwtUtil.GenerateToken(userID, user.Email, user.Role)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем куку
|
|
||||||
appMiddleware.SetAuthCookie(w, newToken)
|
|
||||||
|
|
||||||
response := &dto.AuthResponse{
|
|
||||||
Token: newToken,
|
|
||||||
User: *user,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProfile godoc
|
|
||||||
// @Summary Get user profile
|
|
||||||
// @Description Get current user profile
|
|
||||||
// @Tags users
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Success 200 {object} dto.UserResponse
|
|
||||||
// @Failure 404 {object} map[string]string
|
|
||||||
// @Router /users/profile [get]
|
|
||||||
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("GetProfile start debug level")
|
|
||||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.GetUserProfile(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "User not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
zapLogger.Debug("GetProfile end debug level")
|
|
||||||
json.NewEncoder(w).Encode(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile godoc
|
|
||||||
// @Summary Update user profile
|
|
||||||
// @Description Update current user profile
|
|
||||||
// @Tags users
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Param request body dto.UpdateUserRequest true "Update request"
|
|
||||||
// @Success 200 {object} dto.UserResponse
|
|
||||||
// @Failure 400 {object} map[string]string
|
|
||||||
// @Router /users/profile [put]
|
|
||||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req dto.UpdateUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.UpdateUser(r.Context(), userID, req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser godoc
|
|
||||||
// @Summary Get user by ID
|
|
||||||
// @Description Get user details by ID (admin only)
|
|
||||||
// @Tags users
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Param id path int true "User ID"
|
|
||||||
// @Success 200 {object} dto.UserResponse
|
|
||||||
// @Failure 404 {object} map[string]string
|
|
||||||
// @Router /users/{id} [get]
|
|
||||||
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := chi.URLParam(r, "id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.GetUser(r.Context(), uint(id))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "User not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUsers godoc
|
|
||||||
// @Summary List users
|
|
||||||
// @Description Get paginated list of users (admin only)
|
|
||||||
// @Tags users
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Param limit query int false "Limit" default(10)
|
|
||||||
// @Param offset query int false "Offset" default(0)
|
|
||||||
// @Success 200 {array} dto.UserResponse
|
|
||||||
// @Router /users [get]
|
|
||||||
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Debug start handler listUsers")
|
|
||||||
limitStr := r.URL.Query().Get("limit")
|
|
||||||
offsetStr := r.URL.Query().Get("offset")
|
|
||||||
|
|
||||||
limit := 10
|
|
||||||
offset := 0
|
|
||||||
|
|
||||||
if limitStr != "" {
|
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
|
||||||
limit = l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if offsetStr != "" {
|
|
||||||
if o, err := strconv.Atoi(offsetStr); err == nil {
|
|
||||||
offset = o
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
users, err := h.userService.ListUsers(r.Context(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
zapLogger.Debug("Debug end handler listUsers")
|
|
||||||
json.NewEncoder(w).Encode(users)
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
// auth.go
|
|
||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/utils"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
const (
|
|
||||||
UserIDKey contextKey = "userID"
|
|
||||||
UserEmailKey contextKey = "userEmail"
|
|
||||||
UserRoleKey contextKey = "userRole"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cookie конфигурация
|
|
||||||
const (
|
|
||||||
AuthCookieName = "auth_token"
|
|
||||||
CookieMaxAge = 24 * 60 * 60 // 24 часа
|
|
||||||
)
|
|
||||||
|
|
||||||
func AuthMiddleware(next http.Handler) http.Handler {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
zapLogger.Debug("Debug start AuthMiddleware")
|
|
||||||
|
|
||||||
var tokenString string
|
|
||||||
|
|
||||||
// Пробуем получить токен из заголовка Authorization
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader != "" {
|
|
||||||
tokenString = strings.Replace(authHeader, "Bearer ", "", 1)
|
|
||||||
zapLogger.Debug("Token from Authorization header", zap.String("token", tokenString))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если токена нет в заголовке, пробуем получить из куки
|
|
||||||
if tokenString == "" {
|
|
||||||
cookie, err := r.Cookie(AuthCookieName)
|
|
||||||
if err == nil && cookie.Value != "" {
|
|
||||||
tokenString = cookie.Value
|
|
||||||
zapLogger.Debug("Token from cookie", zap.String("token", tokenString))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenString == "" {
|
|
||||||
http.Error(w, "Authorization required", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Валидируем токен
|
|
||||||
jwtUtil := utils.NewJWTUtil("secret")
|
|
||||||
claims, err := jwtUtil.ValidateToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
// Если токен невалиден, удаляем куку
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: AuthCookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
|
||||||
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
|
|
||||||
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
|
|
||||||
|
|
||||||
zapLogger.Debug("Debug end AuthMiddleware")
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательная функция для установки auth cookie
|
|
||||||
func SetAuthCookie(w http.ResponseWriter, token string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: AuthCookieName,
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: CookieMaxAge,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true, // В production должно быть true
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательная функция для удаления auth cookie
|
|
||||||
func ClearAuthCookie(w http.ResponseWriter) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: AuthCookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func AdminMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
role, ok := r.Context().Value(UserRoleKey).(string)
|
|
||||||
if !ok || role != "admin" {
|
|
||||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthRequest - запрос на аутентификацию
|
|
||||||
type AuthRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthResponse - ответ с токенами
|
|
||||||
type AuthResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
TokenType string `json:"token_type"` // Bearer
|
|
||||||
ExpiresIn int64 `json:"expires_in"`
|
|
||||||
User UserResponse `json:"user"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshTokenRequest - запрос на обновление токена
|
|
||||||
type RefreshTokenRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRegisterRequest - запрос на регистрацию
|
|
||||||
type UserRegisterRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
FullName string `json:"full_name" binding:"required"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PasswordResetRequest - запрос на сброс пароля
|
|
||||||
type PasswordResetRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PasswordResetConfirmRequest - подтверждение сброса пароля
|
|
||||||
type PasswordResetConfirmRequest struct {
|
|
||||||
Token string `json:"token" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
type ObjectFilter struct {
|
|
||||||
Search string `form:"search" json:"search"`
|
|
||||||
Type ObjectType `form:"type" json:"type"`
|
|
||||||
City string `form:"city" json:"city"`
|
|
||||||
MinPrice float64 `form:"min_price" json:"min_price"`
|
|
||||||
MaxPrice float64 `form:"max_price" json:"max_price"`
|
|
||||||
MinRating float64 `form:"min_rating" json:"min_rating"`
|
|
||||||
Status ObjectStatus `form:"status" json:"status"`
|
|
||||||
OwnerID uint `form:"owner_id" json:"owner_id"`
|
|
||||||
|
|
||||||
// Пагинация
|
|
||||||
Page int `form:"page" json:"page" binding:"min=1"`
|
|
||||||
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
|
|
||||||
|
|
||||||
// Сортировка
|
|
||||||
SortBy string `form:"sort_by" json:"sort_by"` // title, price, rating, city, created_at
|
|
||||||
SortOrder string `form:"sort_order" json:"sort_order"` // asc, desc
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaginatedResponse - общий ответ с пагинацией
|
|
||||||
type PaginatedResponse struct {
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"page_size"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package models
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ObjectType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ObjectTypeHotel ObjectType = "hotel"
|
|
||||||
ObjectTypeSanatorium ObjectType = "sanatorium"
|
|
||||||
ObjectTypeGuestHouse ObjectType = "guest_house"
|
|
||||||
ObjectTypeTour ObjectType = "tour"
|
|
||||||
ObjectTypeRestaurant ObjectType = "restaurant"
|
|
||||||
ObjectTypeMuseum ObjectType = "museum"
|
|
||||||
ObjectTypeLandmark ObjectType = "landmark"
|
|
||||||
ObjectTypeEvent ObjectType = "event"
|
|
||||||
ObjectTypeRoute ObjectType = "route"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ObjectStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ObjectStatusDraft ObjectStatus = "draft"
|
|
||||||
ObjectStatusModeration ObjectStatus = "moderation"
|
|
||||||
ObjectStatusActive ObjectStatus = "active"
|
|
||||||
ObjectStatusInactive ObjectStatus = "inactive"
|
|
||||||
ObjectStatusRejected ObjectStatus = "rejected"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Object struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// Основная информация
|
|
||||||
Title string `gorm:"not null" json:"title"`
|
|
||||||
Type ObjectType `gorm:"not null" json:"type"`
|
|
||||||
Description string `gorm:"type:text" json:"description"`
|
|
||||||
|
|
||||||
// Локация
|
|
||||||
City string `gorm:"not null" json:"city"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
|
|
||||||
// Цена и условия
|
|
||||||
Price float64 `gorm:"default:0" json:"price"`
|
|
||||||
PricePeriod string `gorm:"default:'per_night'" json:"price_period"` // per_night, per_person, per_tour
|
|
||||||
|
|
||||||
// Статус и рейтинг
|
|
||||||
Status ObjectStatus `gorm:"default:draft" json:"status"`
|
|
||||||
Rating float64 `gorm:"default:0" json:"rating"`
|
|
||||||
ReviewCount int `gorm:"default:0" json:"review_count"`
|
|
||||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
|
||||||
|
|
||||||
// Владелец
|
|
||||||
OwnerID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner_id"`
|
|
||||||
Owner User `gorm:"foreignKey:OwnerID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner,omitempty"`
|
|
||||||
|
|
||||||
// Связи
|
|
||||||
Images []ObjectImage `gorm:"foreignKey:ObjectID" json:"images"`
|
|
||||||
Amenities []Amenity `gorm:"many2many:object_amenities;" json:"amenities"`
|
|
||||||
Reviews []Review `gorm:"foreignKey:ObjectID" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectImage представляет изображения объекта
|
|
||||||
type ObjectImage struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
ObjectID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"object_id"`
|
|
||||||
URL string `gorm:"not null" json:"url"`
|
|
||||||
IsPrimary bool `gorm:"default:false" json:"is_primary"`
|
|
||||||
Order int `gorm:"default:0" json:"order"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amenity представляет удобства объекта
|
|
||||||
type Amenity struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
|
||||||
Category string `json:"category"` // basic, comfort, safety, entertainment, etc.
|
|
||||||
Icon string `json:"icon"` // иконка для фронтенда
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectAmenity связь многие-ко-многим между Object и Amenity
|
|
||||||
type ObjectAmenity struct {
|
|
||||||
ObjectID uint `gorm:"primaryKey" json:"object_id"`
|
|
||||||
AmenityID uint `gorm:"primaryKey" json:"amenity_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectCreateRequest - запрос на создание объекта
|
|
||||||
type ObjectCreateRequest struct {
|
|
||||||
Title string `json:"title" binding:"required"`
|
|
||||||
Type ObjectType `json:"type" binding:"required"`
|
|
||||||
Description string `json:"description" binding:"required"`
|
|
||||||
City string `json:"city" binding:"required"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
PricePeriod string `json:"price_period"`
|
|
||||||
AmenityIDs []uint `json:"amenity_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObjectUpdateRequest - запрос на обновление объекта
|
|
||||||
type ObjectUpdateRequest struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Type ObjectType `json:"type"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
City string `json:"city"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
PricePeriod string `json:"price_period"`
|
|
||||||
Status ObjectStatus `json:"status"`
|
|
||||||
AmenityIDs []uint `json:"amenity_ids"`
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package models
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Review struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
|
|
||||||
// Связи
|
|
||||||
ObjectID uint `gorm:"not null" json:"object_id"`
|
|
||||||
Object Object `gorm:"foreignKey:ObjectID" json:"object,omitempty"`
|
|
||||||
AuthorID uint `gorm:"not null" json:"author_id"`
|
|
||||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
|
||||||
|
|
||||||
// Контент отзыва
|
|
||||||
Rating int `gorm:"not null;check:rating >= 1 AND rating <= 5" json:"rating"`
|
|
||||||
Text string `gorm:"type:text" json:"text"`
|
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReviewCreateRequest - запрос на создание отзыва
|
|
||||||
type ReviewCreateRequest struct {
|
|
||||||
ObjectID uint `json:"object_id" binding:"required"`
|
|
||||||
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
|
||||||
Text string `json:"text" binding:"required,min=10"`
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
// Основная информация
|
|
||||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
|
||||||
PasswordHash string `gorm:"not null" json:"-"`
|
|
||||||
FullName string `gorm:"not null;default:'Unknown'" json:"full_name"`
|
|
||||||
FirstName string `gorm:"not null;default:'FirstName'" json:"first_name"`
|
|
||||||
LastName string `gorm:"not null;default:'LastName'" json:"last_name"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
|
|
||||||
// Бизнес информация (для владельцев объектов)
|
|
||||||
OrganizationForm string `json:"organization_form"` // ИП, ООО и т.д.
|
|
||||||
OrganizationName string `json:"organization_name"`
|
|
||||||
OrganizationShort string `json:"organization_short"`
|
|
||||||
INN string `json:"inn"` // ИНН организации
|
|
||||||
PersonalINN string `json:"personal_inn"` // Личный ИНН
|
|
||||||
|
|
||||||
// Статус
|
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
|
||||||
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
|
||||||
Role string `gorm:"default:user" json:"role"` // user, admin, moderator
|
|
||||||
|
|
||||||
// Связи
|
|
||||||
Objects []Object `gorm:"foreignKey:OwnerID" json:"-"`
|
|
||||||
Reviews []Review `gorm:"foreignKey:AuthorID" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserStats представляет статистику пользователя
|
|
||||||
type UserStats struct {
|
|
||||||
UserID uint `gorm:"primaryKey" json:"user_id"`
|
|
||||||
TotalObjects int `gorm:"default:0" json:"total_objects"`
|
|
||||||
ActiveObjects int `gorm:"default:0" json:"active_objects"`
|
|
||||||
ModerationObjects int `gorm:"default:0" json:"moderation_objects"`
|
|
||||||
TotalReviews int `gorm:"default:0" json:"total_reviews"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserResponse - структура для ответа API (без чувствительных данных)
|
|
||||||
type UserResponse struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
FirstName string `json:"first_name"`
|
|
||||||
LastName string `json:"last_name"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
City string `json:"city"`
|
|
||||||
OrganizationForm string `json:"organization_form"`
|
|
||||||
OrganizationName string `json:"organization_name"`
|
|
||||||
OrganizationShort string `json:"organization_short"`
|
|
||||||
INN string `json:"inn"`
|
|
||||||
PersonalINN string `json:"personal_inn"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
IsVerified bool `json:"is_verified"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
Stats UserStats `json:"stats,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"api_es/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrObjectNotFound = errors.New("object not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
type ObjectRepository interface {
|
|
||||||
// Основные операции
|
|
||||||
Create(object *models.Object) error
|
|
||||||
GetByID(id uint) (*models.Object, error)
|
|
||||||
Update(id uint, updates *models.ObjectUpdateRequest) error
|
|
||||||
Delete(id uint) error
|
|
||||||
List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
|
|
||||||
|
|
||||||
// Специфичные операции
|
|
||||||
GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
|
|
||||||
UpdateStatus(id uint, status models.ObjectStatus) error
|
|
||||||
IncrementViewCount(id uint) error
|
|
||||||
UpdateRating(id uint, rating float64, reviewCount int) error
|
|
||||||
|
|
||||||
// Работа с изображениями
|
|
||||||
AddImage(objectID uint, image *models.ObjectImage) error
|
|
||||||
RemoveImage(objectID uint, imageID uint) error
|
|
||||||
SetPrimaryImage(objectID uint, imageID uint) error
|
|
||||||
GetImages(objectID uint) ([]models.ObjectImage, error)
|
|
||||||
|
|
||||||
// Работа с удобствами
|
|
||||||
AddAmenities(objectID uint, amenityIDs []uint) error
|
|
||||||
RemoveAmenities(objectID uint, amenityIDs []uint) error
|
|
||||||
GetAmenities(objectID uint) ([]models.Amenity, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ObjectFilter struct {
|
|
||||||
Type []models.ObjectType
|
|
||||||
City string
|
|
||||||
Status []models.ObjectStatus
|
|
||||||
OwnerID uint
|
|
||||||
MinPrice float64
|
|
||||||
MaxPrice float64
|
|
||||||
MinRating float64
|
|
||||||
AmenityIDs []uint
|
|
||||||
Search string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pagination struct {
|
|
||||||
Page int `form:"page" default:"1"`
|
|
||||||
PageSize int `form:"page_size" default:"20"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type objectRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewObjectRepository(db *gorm.DB) ObjectRepository {
|
|
||||||
return &objectRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create создает новый объект
|
|
||||||
func (r *objectRepository) Create(object *models.Object) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Создаем основной объект
|
|
||||||
if err := tx.Create(object).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем связи с удобствами, если они есть
|
|
||||||
if len(object.Amenities) > 0 {
|
|
||||||
if err := tx.Model(object).Association("Amenities").Append(object.Amenities); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID возвращает объект по ID с связанными данными
|
|
||||||
func (r *objectRepository) GetByID(id uint) (*models.Object, error) {
|
|
||||||
var object models.Object
|
|
||||||
err := r.db.
|
|
||||||
Preload("Owner", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, first_name, last_name, email, phone")
|
|
||||||
}).
|
|
||||||
Preload("Images", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Order("is_primary DESC, order ASC")
|
|
||||||
}).
|
|
||||||
Preload("Amenities").
|
|
||||||
First(&object, id).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, ErrObjectNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &object, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update обновляет объект
|
|
||||||
func (r *objectRepository) Update(id uint, updates *models.ObjectUpdateRequest) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Обновляем основные поля
|
|
||||||
updateData := map[string]interface{}{}
|
|
||||||
|
|
||||||
if updates.Title != "" {
|
|
||||||
updateData["title"] = updates.Title
|
|
||||||
}
|
|
||||||
if updates.Type != "" {
|
|
||||||
updateData["type"] = updates.Type
|
|
||||||
}
|
|
||||||
if updates.Description != "" {
|
|
||||||
updateData["description"] = updates.Description
|
|
||||||
}
|
|
||||||
if updates.City != "" {
|
|
||||||
updateData["city"] = updates.City
|
|
||||||
}
|
|
||||||
if updates.Address != "" {
|
|
||||||
updateData["address"] = updates.Address
|
|
||||||
}
|
|
||||||
if updates.Latitude != 0 {
|
|
||||||
updateData["latitude"] = updates.Latitude
|
|
||||||
}
|
|
||||||
if updates.Longitude != 0 {
|
|
||||||
updateData["longitude"] = updates.Longitude
|
|
||||||
}
|
|
||||||
if updates.Price != 0 {
|
|
||||||
updateData["price"] = updates.Price
|
|
||||||
}
|
|
||||||
if updates.PricePeriod != "" {
|
|
||||||
updateData["price_period"] = updates.PricePeriod
|
|
||||||
}
|
|
||||||
if updates.Status != "" {
|
|
||||||
updateData["status"] = updates.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updateData) > 0 {
|
|
||||||
if err := tx.Model(&models.Object{}).Where("id = ?", id).Updates(updateData).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем удобства, если переданы
|
|
||||||
if updates.AmenityIDs != nil {
|
|
||||||
var object models.Object
|
|
||||||
if err := tx.First(&object, id).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var amenities []models.Amenity
|
|
||||||
if err := tx.Where("id IN ?", updates.AmenityIDs).Find(&amenities).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Model(&object).Association("Amenities").Replace(amenities); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete удаляет объект (мягкое удаление)
|
|
||||||
func (r *objectRepository) Delete(id uint) error {
|
|
||||||
result := r.db.Delete(&models.Object{}, id)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrObjectNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List возвращает список объектов с фильтрацией и пагинацией
|
|
||||||
func (r *objectRepository) List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
|
|
||||||
var objects []models.Object
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.Object{})
|
|
||||||
|
|
||||||
// Применяем фильтры
|
|
||||||
if filter != nil {
|
|
||||||
query = r.applyFilters(query, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Считаем общее количество
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем пагинацию
|
|
||||||
if pagination != nil {
|
|
||||||
offset := (pagination.Page - 1) * pagination.PageSize
|
|
||||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем данные с прелоадами
|
|
||||||
err := query.
|
|
||||||
Preload("Images", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Where("is_primary = ?", true).Limit(1)
|
|
||||||
}).
|
|
||||||
Preload("Amenities").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&objects).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return objects, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByOwner возвращает объекты владельца
|
|
||||||
func (r *objectRepository) GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
|
|
||||||
if filter == nil {
|
|
||||||
filter = &ObjectFilter{}
|
|
||||||
}
|
|
||||||
filter.OwnerID = ownerID
|
|
||||||
return r.List(filter, pagination)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStatus обновляет статус объекта
|
|
||||||
func (r *objectRepository) UpdateStatus(id uint, status models.ObjectStatus) error {
|
|
||||||
result := r.db.Model(&models.Object{}).Where("id = ?", id).Update("status", status)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrObjectNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementViewCount увеличивает счетчик просмотров
|
|
||||||
func (r *objectRepository) IncrementViewCount(id uint) error {
|
|
||||||
return r.db.Model(&models.Object{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Update("view_count", gorm.Expr("view_count + ?", 1)).
|
|
||||||
Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRating обновляет рейтинг и количество отзывов
|
|
||||||
func (r *objectRepository) UpdateRating(id uint, rating float64, reviewCount int) error {
|
|
||||||
return r.db.Model(&models.Object{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"rating": rating,
|
|
||||||
"review_count": reviewCount,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddImage добавляет изображение к объекту
|
|
||||||
func (r *objectRepository) AddImage(objectID uint, image *models.ObjectImage) error {
|
|
||||||
image.ObjectID = objectID
|
|
||||||
return r.db.Create(image).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveImage удаляет изображение объекта
|
|
||||||
func (r *objectRepository) RemoveImage(objectID uint, imageID uint) error {
|
|
||||||
result := r.db.Where("object_id = ? AND id = ?", objectID, imageID).Delete(&models.ObjectImage{})
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrObjectNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPrimaryImage устанавливает основное изображение
|
|
||||||
func (r *objectRepository) SetPrimaryImage(objectID uint, imageID uint) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Сбрасываем все is_primary для объекта
|
|
||||||
if err := tx.Model(&models.ObjectImage{}).
|
|
||||||
Where("object_id = ?", objectID).
|
|
||||||
Update("is_primary", false).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем новое основное изображение
|
|
||||||
result := tx.Model(&models.ObjectImage{}).
|
|
||||||
Where("object_id = ? AND id = ?", objectID, imageID).
|
|
||||||
Update("is_primary", true)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrObjectNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetImages возвращает изображения объекта
|
|
||||||
func (r *objectRepository) GetImages(objectID uint) ([]models.ObjectImage, error) {
|
|
||||||
var images []models.ObjectImage
|
|
||||||
err := r.db.Where("object_id = ?", objectID).
|
|
||||||
Order("is_primary DESC, order ASC").
|
|
||||||
Find(&images).Error
|
|
||||||
return images, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAmenities добавляет удобства к объекту
|
|
||||||
func (r *objectRepository) AddAmenities(objectID uint, amenityIDs []uint) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var object models.Object
|
|
||||||
if err := tx.First(&object, objectID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var amenities []models.Amenity
|
|
||||||
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Model(&object).Association("Amenities").Append(amenities)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveAmenities удаляет удобства у объекта
|
|
||||||
func (r *objectRepository) RemoveAmenities(objectID uint, amenityIDs []uint) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var object models.Object
|
|
||||||
if err := tx.First(&object, objectID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var amenities []models.Amenity
|
|
||||||
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Model(&object).Association("Amenities").Delete(amenities)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAmenities возвращает удобства объекта
|
|
||||||
func (r *objectRepository) GetAmenities(objectID uint) ([]models.Amenity, error) {
|
|
||||||
var amenities []models.Amenity
|
|
||||||
err := r.db.Joins("JOIN object_amenities ON amenities.id = object_amenities.amenity_id").
|
|
||||||
Where("object_amenities.object_id = ?", objectID).
|
|
||||||
Find(&amenities).Error
|
|
||||||
return amenities, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyFilters применяет фильтры к запросу
|
|
||||||
func (r *objectRepository) applyFilters(query *gorm.DB, filter *ObjectFilter) *gorm.DB {
|
|
||||||
if len(filter.Type) > 0 {
|
|
||||||
query = query.Where("type IN ?", filter.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.City != "" {
|
|
||||||
query = query.Where("city = ?", filter.City)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filter.Status) > 0 {
|
|
||||||
query = query.Where("status IN ?", filter.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.OwnerID != 0 {
|
|
||||||
query = query.Where("owner_id = ?", filter.OwnerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.MinPrice > 0 {
|
|
||||||
query = query.Where("price >= ?", filter.MinPrice)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.MaxPrice > 0 {
|
|
||||||
query = query.Where("price <= ?", filter.MaxPrice)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.MinRating > 0 {
|
|
||||||
query = query.Where("rating >= ?", filter.MinRating)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.Search != "" {
|
|
||||||
search := "%" + filter.Search + "%"
|
|
||||||
query = query.Where("title ILIKE ? OR description ILIKE ?", search, search)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтр по удобствам
|
|
||||||
if len(filter.AmenityIDs) > 0 {
|
|
||||||
query = query.Joins("JOIN object_amenities ON objects.id = object_amenities.object_id").
|
|
||||||
Where("object_amenities.amenity_id IN ?", filter.AmenityIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"api_es/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrReviewNotFound = errors.New("review not found")
|
|
||||||
ErrDuplicateReview = errors.New("user already has review for this object")
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReviewRepository interface {
|
|
||||||
// Основные операции
|
|
||||||
Create(review *models.Review) error
|
|
||||||
GetByID(id uint) (*models.Review, error)
|
|
||||||
Update(id uint, updates map[string]interface{}) error
|
|
||||||
Delete(id uint) error
|
|
||||||
|
|
||||||
// Списки отзывов
|
|
||||||
GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error)
|
|
||||||
GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error)
|
|
||||||
GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error)
|
|
||||||
|
|
||||||
// Статистика
|
|
||||||
GetObjectRatingStats(objectID uint) (float64, int, error)
|
|
||||||
GetUserReviewStats(authorID uint) (int, float64, error)
|
|
||||||
|
|
||||||
// Административные методы
|
|
||||||
SetActive(id uint, isActive bool) error
|
|
||||||
GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReviewFilter struct {
|
|
||||||
ObjectID uint
|
|
||||||
AuthorID uint
|
|
||||||
Rating int
|
|
||||||
IsActive *bool
|
|
||||||
MinRating int
|
|
||||||
MaxRating int
|
|
||||||
}
|
|
||||||
|
|
||||||
type reviewRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
|
||||||
return &reviewRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create создает новый отзыв
|
|
||||||
func (r *reviewRepository) Create(review *models.Review) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Проверяем, не оставлял ли пользователь уже отзыв на этот объект
|
|
||||||
var existingReview models.Review
|
|
||||||
err := tx.Where("object_id = ? AND author_id = ?", review.ObjectID, review.AuthorID).
|
|
||||||
First(&existingReview).Error
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
return ErrDuplicateReview
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем отзыв
|
|
||||||
if err := tx.Create(review).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем рейтинг объекта
|
|
||||||
return r.updateObjectRating(tx, review.ObjectID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID возвращает отзыв по ID
|
|
||||||
func (r *reviewRepository) GetByID(id uint) (*models.Review, error) {
|
|
||||||
var review models.Review
|
|
||||||
err := r.db.
|
|
||||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, first_name, last_name, avatar")
|
|
||||||
}).
|
|
||||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, title, type")
|
|
||||||
}).
|
|
||||||
First(&review, id).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, ErrReviewNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &review, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update обновляет отзыв
|
|
||||||
func (r *reviewRepository) Update(id uint, updates map[string]interface{}) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Получаем отзыв для получения object_id
|
|
||||||
var review models.Review
|
|
||||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем отзыв
|
|
||||||
result := tx.Model(&models.Review{}).Where("id = ?", id).Updates(updates)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем рейтинг объекта, если изменился рейтинг
|
|
||||||
if _, hasRating := updates["rating"]; hasRating {
|
|
||||||
return r.updateObjectRating(tx, review.ObjectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete удаляет отзыв
|
|
||||||
func (r *reviewRepository) Delete(id uint) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Получаем отзыв для получения object_id
|
|
||||||
var review models.Review
|
|
||||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем отзыв
|
|
||||||
result := tx.Delete(&models.Review{}, id)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем рейтинг объекта
|
|
||||||
return r.updateObjectRating(tx, review.ObjectID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByObject возвращает отзывы для объекта
|
|
||||||
func (r *reviewRepository) GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error) {
|
|
||||||
var reviews []models.Review
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.Review{}).Where("object_id = ? AND is_active = ?", objectID, true)
|
|
||||||
|
|
||||||
// Считаем общее количество
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем пагинацию
|
|
||||||
if pagination != nil {
|
|
||||||
offset := (pagination.Page - 1) * pagination.PageSize
|
|
||||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем данные
|
|
||||||
err := query.
|
|
||||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, first_name, last_name, avatar")
|
|
||||||
}).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&reviews).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reviews, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByAuthor возвращает отзывы пользователя
|
|
||||||
func (r *reviewRepository) GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error) {
|
|
||||||
var reviews []models.Review
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.Review{}).Where("author_id = ?", authorID)
|
|
||||||
|
|
||||||
// Считаем общее количество
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем пагинацию
|
|
||||||
if pagination != nil {
|
|
||||||
offset := (pagination.Page - 1) * pagination.PageSize
|
|
||||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем данные
|
|
||||||
err := query.
|
|
||||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, title, type, city")
|
|
||||||
}).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&reviews).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reviews, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByObjectAndAuthor возвращает отзыв конкретного пользователя для объекта
|
|
||||||
func (r *reviewRepository) GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error) {
|
|
||||||
var review models.Review
|
|
||||||
err := r.db.
|
|
||||||
Where("object_id = ? AND author_id = ?", objectID, authorID).
|
|
||||||
First(&review).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, ErrReviewNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &review, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetObjectRatingStats возвращает статистику рейтинга для объекта
|
|
||||||
func (r *reviewRepository) GetObjectRatingStats(objectID uint) (float64, int, error) {
|
|
||||||
var stats struct {
|
|
||||||
AverageRating float64
|
|
||||||
ReviewCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.db.Model(&models.Review{}).
|
|
||||||
Select("AVG(rating) as average_rating, COUNT(*) as review_count").
|
|
||||||
Where("object_id = ? AND is_active = ?", objectID, true).
|
|
||||||
Scan(&stats).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats.AverageRating, stats.ReviewCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserReviewStats возвращает статистику отзывов пользователя
|
|
||||||
func (r *reviewRepository) GetUserReviewStats(authorID uint) (int, float64, error) {
|
|
||||||
var stats struct {
|
|
||||||
ReviewCount int
|
|
||||||
AverageRating float64
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.db.Model(&models.Review{}).
|
|
||||||
Select("COUNT(*) as review_count, AVG(rating) as average_rating").
|
|
||||||
Where("author_id = ? AND is_active = ?", authorID, true).
|
|
||||||
Scan(&stats).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats.ReviewCount, stats.AverageRating, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetActive активирует/деактивирует отзыв
|
|
||||||
func (r *reviewRepository) SetActive(id uint, isActive bool) error {
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Получаем отзыв для получения object_id
|
|
||||||
var review models.Review
|
|
||||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем статус
|
|
||||||
result := tx.Model(&models.Review{}).Where("id = ?", id).Update("is_active", isActive)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return ErrReviewNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем рейтинг объекта
|
|
||||||
return r.updateObjectRating(tx, review.ObjectID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAll возвращает все отзывы с фильтрацией (для админки)
|
|
||||||
func (r *reviewRepository) GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error) {
|
|
||||||
var reviews []models.Review
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.Review{})
|
|
||||||
|
|
||||||
// Применяем фильтры
|
|
||||||
if filters != nil {
|
|
||||||
query = r.applyFilters(query, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Считаем общее количество
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем пагинацию
|
|
||||||
if pagination != nil {
|
|
||||||
offset := (pagination.Page - 1) * pagination.PageSize
|
|
||||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем данные
|
|
||||||
err := query.
|
|
||||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, first_name, last_name, email")
|
|
||||||
}).
|
|
||||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
|
||||||
return db.Select("id, title, type")
|
|
||||||
}).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&reviews).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reviews, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateObjectRating обновляет рейтинг объекта
|
|
||||||
func (r *reviewRepository) updateObjectRating(tx *gorm.DB, objectID uint) error {
|
|
||||||
stats, _, err := r.GetObjectRatingStats(objectID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
count_ := int64(0)
|
|
||||||
|
|
||||||
// Обновляем рейтинг объекта
|
|
||||||
return tx.Model(&models.Object{}).
|
|
||||||
Where("id = ?", objectID).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"rating": stats,
|
|
||||||
"review_count": tx.Model(&models.Review{}).
|
|
||||||
Where("object_id = ? AND is_active = ?", objectID, true).
|
|
||||||
Count(&count_),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyFilters применяет фильтры к запросу
|
|
||||||
func (r *reviewRepository) applyFilters(query *gorm.DB, filters *ReviewFilter) *gorm.DB {
|
|
||||||
if filters.ObjectID != 0 {
|
|
||||||
query = query.Where("object_id = ?", filters.ObjectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filters.AuthorID != 0 {
|
|
||||||
query = query.Where("author_id = ?", filters.AuthorID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filters.Rating != 0 {
|
|
||||||
query = query.Where("rating = ?", filters.Rating)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filters.IsActive != nil {
|
|
||||||
query = query.Where("is_active = ?", *filters.IsActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filters.MinRating > 0 {
|
|
||||||
query = query.Where("rating >= ?", filters.MinRating)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filters.MaxRating > 0 {
|
|
||||||
query = query.Where("rating <= ?", filters.MaxRating)
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/models"
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserRepository interface {
|
|
||||||
Create(ctx context.Context, user *models.User) error
|
|
||||||
GetByID(ctx context.Context, id uint) (*models.User, error)
|
|
||||||
GetByEmail(ctx context.Context, email string) (*models.User, error)
|
|
||||||
Update(ctx context.Context, user *models.User) error
|
|
||||||
Delete(ctx context.Context, id uint) error
|
|
||||||
List(ctx context.Context, limit, offset int) ([]*models.User, error)
|
|
||||||
GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type userRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
|
||||||
return &userRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) Create(ctx context.Context, user *models.User) error {
|
|
||||||
return r.db.WithContext(ctx).Create(user).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) GetByID(ctx context.Context, id uint) (*models.User, error) {
|
|
||||||
var user models.User
|
|
||||||
err := r.db.WithContext(ctx).First(&user, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
|
||||||
var user models.User
|
|
||||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) Update(ctx context.Context, user *models.User) error {
|
|
||||||
return r.db.WithContext(ctx).Save(user).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&models.User{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) List(ctx context.Context, limit, offset int) ([]*models.User, error) {
|
|
||||||
var users []*models.User
|
|
||||||
err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&users).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return users, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error) {
|
|
||||||
var stats models.UserStats
|
|
||||||
err := r.db.WithContext(ctx).First(&stats, userID).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &stats, nil
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"api_es/internal/config"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"api_es/internal/handler"
|
|
||||||
appMiddleware "api_es/internal/middleware"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start setup rounting")
|
|
||||||
r := chi.NewRouter()
|
|
||||||
|
|
||||||
// Initialize logger
|
|
||||||
baseLogger := logger.NewWrapper(logger.Get())
|
|
||||||
|
|
||||||
setupMiddlewares(r)
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
|
|
||||||
})
|
|
||||||
|
|
||||||
h := handler.NewAllHandler(db, config)
|
|
||||||
|
|
||||||
// Health routes
|
|
||||||
r.Route("/", func(r chi.Router) {
|
|
||||||
r.Get("/health", h.HealthHandler().HealthCheck)
|
|
||||||
r.Get("/check", h.HealthHandler().Check)
|
|
||||||
})
|
|
||||||
|
|
||||||
// router.go (обновляем секцию auth routes)
|
|
||||||
r.Route("/auth", func(r chi.Router) {
|
|
||||||
r.Post("/register", h.UserHandler().Register)
|
|
||||||
r.Post("/login", h.UserHandler().Login)
|
|
||||||
r.Post("/logout", h.UserHandler().Logout)
|
|
||||||
r.Post("/refresh", h.UserHandler().RefreshToken)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Route("/users", func(r chi.Router) {
|
|
||||||
r.Use(appMiddleware.AuthMiddleware)
|
|
||||||
|
|
||||||
r.Get("/profile", h.UserHandler().GetProfile)
|
|
||||||
r.Put("/profile", h.UserHandler().UpdateProfile)
|
|
||||||
|
|
||||||
// Admin routes
|
|
||||||
r.With(appMiddleware.AdminMiddleware).Get("/", h.UserHandler().ListUsers)
|
|
||||||
r.With(appMiddleware.AdminMiddleware).Get("/{id}", h.UserHandler().GetUser)
|
|
||||||
})
|
|
||||||
|
|
||||||
zapLogger.Debug("End setup routing")
|
|
||||||
|
|
||||||
// Логируем все зарегистрированные маршруты
|
|
||||||
routeLogger := logger.NewRouteLogger(baseLogger)
|
|
||||||
routeLogger.LogRoutes(r)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/go-chi/cors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupMiddlewares — устанавливает общие middleware для роутера.
|
|
||||||
func setupMiddlewares(r *chi.Mux) {
|
|
||||||
// Логирование всех запросов
|
|
||||||
r.Use(middleware.Logger)
|
|
||||||
|
|
||||||
// Восстановление после паник
|
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
|
|
||||||
// Удаление завершающих слешей
|
|
||||||
r.Use(middleware.StripSlashes)
|
|
||||||
|
|
||||||
// Установка реального IP из заголовков (X-Forwarded-For, X-Real-IP)
|
|
||||||
r.Use(middleware.RealIP)
|
|
||||||
|
|
||||||
// Таймаут обработки запроса
|
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
|
||||||
|
|
||||||
// Поддержка CORS
|
|
||||||
r.Use(cors.Handler(cors.Options{
|
|
||||||
AllowedOrigins: []string{"https://easysite102.ru", "http://localhost:3000"},
|
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
|
||||||
ExposedHeaders: []string{"Link"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 300, // 5 минут
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
// Можно добавить и другие кастомные middleware при необходимости
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"api_es/internal/dto"
|
|
||||||
"api_es/internal/models"
|
|
||||||
"api_es/internal/repository"
|
|
||||||
"api_es/internal/utils"
|
|
||||||
"api_es/pkg/logger"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUserNotFound = errors.New("user not found")
|
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
||||||
ErrUserAlreadyExists = errors.New("user already exists")
|
|
||||||
ErrInvalidPassword = errors.New("invalid password")
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserService interface {
|
|
||||||
Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error)
|
|
||||||
Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error)
|
|
||||||
GetUser(ctx context.Context, id uint) (*dto.UserResponse, error)
|
|
||||||
UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error)
|
|
||||||
DeleteUser(ctx context.Context, id uint) error
|
|
||||||
ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error)
|
|
||||||
GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type userService struct {
|
|
||||||
userRepo repository.UserRepository
|
|
||||||
jwtUtil *utils.JWTUtil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserService(userRepo repository.UserRepository, jwtUtil *utils.JWTUtil) UserService {
|
|
||||||
return &userService{
|
|
||||||
userRepo: userRepo,
|
|
||||||
jwtUtil: jwtUtil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start register")
|
|
||||||
// Проверяем существование пользователя
|
|
||||||
existingUser, _ := s.userRepo.GetByEmail(ctx, req.Email)
|
|
||||||
if existingUser != nil {
|
|
||||||
return nil, ErrUserAlreadyExists
|
|
||||||
}
|
|
||||||
|
|
||||||
// Хешируем пароль
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем пользователя
|
|
||||||
user := &models.User{
|
|
||||||
Email: req.Email,
|
|
||||||
PasswordHash: string(hashedPassword),
|
|
||||||
FullName: req.FullName,
|
|
||||||
FirstName: req.FirstName,
|
|
||||||
LastName: req.LastName,
|
|
||||||
Phone: req.Phone,
|
|
||||||
City: req.City,
|
|
||||||
IsActive: true,
|
|
||||||
IsVerified: false,
|
|
||||||
Role: "user",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем токен
|
|
||||||
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userResponse := dto.ToUserResponse(user)
|
|
||||||
zapLogger.Debug("End register")
|
|
||||||
return &dto.AuthResponse{
|
|
||||||
Token: token,
|
|
||||||
User: userResponse,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error) {
|
|
||||||
zapLogger := logger.Get()
|
|
||||||
zapLogger.Debug("Start login")
|
|
||||||
// Находим пользователя по email
|
|
||||||
user, err := s.userRepo.GetByEmail(ctx, req.Email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrInvalidCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем пароль
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
||||||
return nil, ErrInvalidCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем активность пользователя
|
|
||||||
if !user.IsActive {
|
|
||||||
return nil, errors.New("account is deactivated")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем токен
|
|
||||||
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userResponse := dto.ToUserResponse(user)
|
|
||||||
zapLogger.Debug("End login")
|
|
||||||
return &dto.AuthResponse{
|
|
||||||
Token: token,
|
|
||||||
User: userResponse,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) GetUser(ctx context.Context, id uint) (*dto.UserResponse, error) {
|
|
||||||
user, err := s.userRepo.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
response := dto.ToUserResponse(user)
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error) {
|
|
||||||
user, err := s.userRepo.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем поля
|
|
||||||
if req.FullName != "" {
|
|
||||||
user.FullName = req.FullName
|
|
||||||
}
|
|
||||||
if req.FirstName != "" {
|
|
||||||
user.FirstName = req.FirstName
|
|
||||||
}
|
|
||||||
if req.LastName != "" {
|
|
||||||
user.LastName = req.LastName
|
|
||||||
}
|
|
||||||
if req.Phone != "" {
|
|
||||||
user.Phone = req.Phone
|
|
||||||
}
|
|
||||||
if req.City != "" {
|
|
||||||
user.City = req.City
|
|
||||||
}
|
|
||||||
if req.OrganizationForm != "" {
|
|
||||||
user.OrganizationForm = req.OrganizationForm
|
|
||||||
}
|
|
||||||
if req.OrganizationName != "" {
|
|
||||||
user.OrganizationName = req.OrganizationName
|
|
||||||
}
|
|
||||||
if req.OrganizationShort != "" {
|
|
||||||
user.OrganizationShort = req.OrganizationShort
|
|
||||||
}
|
|
||||||
if req.INN != "" {
|
|
||||||
user.INN = req.INN
|
|
||||||
}
|
|
||||||
if req.PersonalINN != "" {
|
|
||||||
user.PersonalINN = req.PersonalINN
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
response := dto.ToUserResponse(user)
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) DeleteUser(ctx context.Context, id uint) error {
|
|
||||||
return s.userRepo.Delete(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error) {
|
|
||||||
users, err := s.userRepo.List(ctx, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
responses := make([]*dto.UserResponse, len(users))
|
|
||||||
for i, user := range users {
|
|
||||||
response := dto.ToUserResponse(user)
|
|
||||||
responses[i] = &response
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userService) GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error) {
|
|
||||||
return s.GetUser(ctx, id)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
// formatPace форматирует темп в строку "MM:SS"
|
|
||||||
func FormatPace(minutes, seconds int) string {
|
|
||||||
if seconds >= 60 {
|
|
||||||
minutes += seconds / 60
|
|
||||||
seconds = seconds % 60
|
|
||||||
}
|
|
||||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTwoDigits форматирует число в двузначную строку
|
|
||||||
func FormatTwoDigits(num int) string {
|
|
||||||
if num < 10 {
|
|
||||||
return "0" + string(rune(num+'0'))
|
|
||||||
}
|
|
||||||
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTime форматирует время в строку "MM:SS"
|
|
||||||
func FormatTime(minutes, seconds int) string {
|
|
||||||
if seconds >= 60 {
|
|
||||||
minutes += seconds / 60
|
|
||||||
seconds = seconds % 60
|
|
||||||
}
|
|
||||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type JWTUtil struct {
|
|
||||||
secretKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
UserID uint `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewJWTUtil(secretKey string) *JWTUtil {
|
|
||||||
return &JWTUtil{secretKey: secretKey}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWTUtil) GenerateToken(userID uint, email, role string) (string, error) {
|
|
||||||
claims := Claims{
|
|
||||||
UserID: userID,
|
|
||||||
Email: email,
|
|
||||||
Role: role,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString([]byte(j.secretKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWTUtil) ValidateToken(tokenString string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return []byte(j.secretKey), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, jwt.ErrInvalidKey
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// pkg/utils/response.go (дополнение)
|
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RespondWithValidationError отправляет ответ с ошибками валидации
|
|
||||||
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"error": "Validation failed",
|
|
||||||
"details": GetValidationErrors(validationError),
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(statusCode)
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
|
|
||||||
RespondWithJSON(w, statusCode, map[string]string{"error": message})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecodeJSONBody декодирует JSON тело запроса
|
|
||||||
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
return errors.New("Content-Type header is not application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
|
|
||||||
|
|
||||||
dec := json.NewDecoder(r.Body)
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
|
|
||||||
err := dec.Decode(dst)
|
|
||||||
if err != nil {
|
|
||||||
var syntaxError *json.SyntaxError
|
|
||||||
var unmarshalTypeError *json.UnmarshalTypeError
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.As(err, &syntaxError):
|
|
||||||
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
|
|
||||||
|
|
||||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
|
||||||
return errors.New("request body contains badly-formed JSON")
|
|
||||||
|
|
||||||
case errors.As(err, &unmarshalTypeError):
|
|
||||||
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
|
|
||||||
|
|
||||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
|
||||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
|
||||||
return fmt.Errorf("request body contains unknown field %s", fieldName)
|
|
||||||
|
|
||||||
case errors.Is(err, io.EOF):
|
|
||||||
return errors.New("request body must not be empty")
|
|
||||||
|
|
||||||
case err.Error() == "http: request body too large":
|
|
||||||
return errors.New("request body must not be larger than 1MB")
|
|
||||||
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = dec.Decode(&struct{}{})
|
|
||||||
if err != io.EOF {
|
|
||||||
return errors.New("request body must only contain a single JSON object")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserIDFromContext извлекает userID из контекста
|
|
||||||
func GetUserIDFromContext(r *http.Request) (uint, bool) {
|
|
||||||
userID, ok := r.Context().Value("userID").(uint)
|
|
||||||
return userID, ok
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
// pkg/utils/validation.go
|
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidationError представляет ошибку валидации
|
|
||||||
type ValidationError struct {
|
|
||||||
Field string `json:"field"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e ValidationError) Error() string {
|
|
||||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidationResult содержит результат валидации
|
|
||||||
type ValidationResult struct {
|
|
||||||
IsValid bool
|
|
||||||
Errors []ValidationError
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagOptions содержит опции из тега validate
|
|
||||||
type TagOptions struct {
|
|
||||||
Required bool
|
|
||||||
Min *float64
|
|
||||||
Max *float64
|
|
||||||
MinInt *int64
|
|
||||||
MaxInt *int64
|
|
||||||
OneOf []string
|
|
||||||
Email bool
|
|
||||||
MaxLength *int
|
|
||||||
MinLength *int
|
|
||||||
Custom string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateStruct валидирует структуру на основе тегов validate
|
|
||||||
func ValidateStruct(s interface{}) error {
|
|
||||||
val := reflect.ValueOf(s)
|
|
||||||
if val.Kind() == reflect.Ptr {
|
|
||||||
val = val.Elem()
|
|
||||||
}
|
|
||||||
|
|
||||||
if val.Kind() != reflect.Struct {
|
|
||||||
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errors []ValidationError
|
|
||||||
typ := val.Type()
|
|
||||||
|
|
||||||
for i := 0; i < val.NumField(); i++ {
|
|
||||||
field := val.Field(i)
|
|
||||||
fieldType := typ.Field(i)
|
|
||||||
|
|
||||||
// Пропускаем неэкспортируемые поля
|
|
||||||
if !field.CanInterface() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tag := fieldType.Tag.Get("validate")
|
|
||||||
if tag == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
options := parseTagOptions(tag)
|
|
||||||
fieldName := getFieldName(fieldType)
|
|
||||||
|
|
||||||
// Валидация поля
|
|
||||||
if err := validateField(field, fieldName, options); err != nil {
|
|
||||||
errors = append(errors, err...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errors) > 0 {
|
|
||||||
return &ValidationResult{
|
|
||||||
IsValid: false,
|
|
||||||
Errors: errors,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTagOptions парсит тег validate и возвращает опции
|
|
||||||
func parseTagOptions(tag string) TagOptions {
|
|
||||||
options := TagOptions{}
|
|
||||||
parts := strings.Split(tag, ",")
|
|
||||||
|
|
||||||
for _, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case part == "required":
|
|
||||||
options.Required = true
|
|
||||||
case part == "email":
|
|
||||||
options.Email = true
|
|
||||||
case strings.HasPrefix(part, "min="):
|
|
||||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
|
||||||
options.Min = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "max="):
|
|
||||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
|
||||||
options.Max = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "minint="):
|
|
||||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
|
||||||
options.MinInt = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "maxint="):
|
|
||||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
|
||||||
options.MaxInt = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "oneof="):
|
|
||||||
options.OneOf = strings.Split(part[6:], " ")
|
|
||||||
case strings.HasPrefix(part, "maxlen="):
|
|
||||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
|
||||||
options.MaxLength = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "minlen="):
|
|
||||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
|
||||||
options.MinLength = &val
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(part, "custom="):
|
|
||||||
options.Custom = part[7:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFieldName возвращает имя поля для сообщений об ошибках
|
|
||||||
func getFieldName(field reflect.StructField) string {
|
|
||||||
jsonTag := field.Tag.Get("json")
|
|
||||||
if jsonTag != "" {
|
|
||||||
parts := strings.Split(jsonTag, ",")
|
|
||||||
if parts[0] != "" {
|
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return field.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateField валидирует отдельное поле
|
|
||||||
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
|
|
||||||
var errors []ValidationError
|
|
||||||
|
|
||||||
// Проверка required
|
|
||||||
if options.Required {
|
|
||||||
if isEmptyValue(field) {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: "field is required",
|
|
||||||
})
|
|
||||||
return errors // Если поле обязательно и пустое, дальше не проверяем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если поле пустое и не обязательное, дальше не проверяем
|
|
||||||
if isEmptyValue(field) {
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Валидация в зависимости от типа поля
|
|
||||||
switch field.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
errors = append(errors, validateString(field.String(), fieldName, options)...)
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
|
|
||||||
case reflect.Struct:
|
|
||||||
// Для time.Time и других структур
|
|
||||||
if field.Type().String() == "time.Time" {
|
|
||||||
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateString валидирует строковые поля
|
|
||||||
func validateString(value, fieldName string, options TagOptions) []ValidationError {
|
|
||||||
var errors []ValidationError
|
|
||||||
|
|
||||||
// Проверка email
|
|
||||||
if options.Email {
|
|
||||||
if !isValidEmail(value) {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: "invalid email format",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка длины строки
|
|
||||||
if options.MinLength != nil && len(value) < *options.MinLength {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.MaxLength != nil && len(value) > *options.MaxLength {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка oneof
|
|
||||||
if len(options.OneOf) > 0 {
|
|
||||||
valid := false
|
|
||||||
for _, allowed := range options.OneOf {
|
|
||||||
if value == allowed {
|
|
||||||
valid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateInt валидирует целочисленные поля
|
|
||||||
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
|
|
||||||
var errors []ValidationError
|
|
||||||
|
|
||||||
if options.MinInt != nil && value < *options.MinInt {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.MaxInt != nil && value > *options.MaxInt {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateFloat валидирует поля с плавающей точкой
|
|
||||||
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
|
|
||||||
var errors []ValidationError
|
|
||||||
|
|
||||||
if options.Min != nil && value < *options.Min {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.Max != nil && value > *options.Max {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateTime валидирует временные поля
|
|
||||||
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
|
|
||||||
var errors []ValidationError
|
|
||||||
|
|
||||||
// Проверка, что дата не нулевая
|
|
||||||
if value.IsZero() && options.Required {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: "date is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка, что дата не в будущем (пример кастомной валидации)
|
|
||||||
if options.Custom == "not_future" && value.After(time.Now()) {
|
|
||||||
errors = append(errors, ValidationError{
|
|
||||||
Field: fieldName,
|
|
||||||
Message: "date cannot be in the future",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// isEmptyValue проверяет, является ли значение пустым
|
|
||||||
func isEmptyValue(v reflect.Value) bool {
|
|
||||||
switch v.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
return v.String() == ""
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
return v.Int() == 0
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
return v.Float() == 0
|
|
||||||
case reflect.Bool:
|
|
||||||
return !v.Bool()
|
|
||||||
case reflect.Struct:
|
|
||||||
if v.Type().String() == "time.Time" {
|
|
||||||
return v.Interface().(time.Time).IsZero()
|
|
||||||
}
|
|
||||||
case reflect.Ptr, reflect.Interface:
|
|
||||||
return v.IsNil()
|
|
||||||
case reflect.Slice, reflect.Map, reflect.Array:
|
|
||||||
return v.Len() == 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidEmail проверяет валидность email
|
|
||||||
func isValidEmail(email string) bool {
|
|
||||||
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
|
||||||
matched, _ := regexp.MatchString(emailRegex, email)
|
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error возвращает строковое представление ошибок валидации
|
|
||||||
func (vr *ValidationResult) Error() string {
|
|
||||||
var errorMessages []string
|
|
||||||
for _, err := range vr.Errors {
|
|
||||||
errorMessages = append(errorMessages, err.Error())
|
|
||||||
}
|
|
||||||
return strings.Join(errorMessages, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValidationErrors возвращает ошибки валидации в структурированном виде
|
|
||||||
func GetValidationErrors(err error) []ValidationError {
|
|
||||||
if vr, ok := err.(*ValidationResult); ok {
|
|
||||||
return vr.Errors
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogValidationErrors логирует ошибки валидации
|
|
||||||
func LogValidationErrors(logger *zap.Logger, err error, context string) {
|
|
||||||
if vr, ok := err.(*ValidationResult); ok {
|
|
||||||
for _, validationErr := range vr.Errors {
|
|
||||||
logger.Warn("validation error",
|
|
||||||
zap.String("context", context),
|
|
||||||
zap.String("field", validationErr.Field),
|
|
||||||
zap.String("error", validationErr.Message),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseUintFromQuery парсит uint из query параметра
|
|
||||||
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
|
|
||||||
if queryParam == "" {
|
|
||||||
return defaultValue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.ParseUint(queryParam, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return defaultValue, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(value), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseIntFromQuery парсит int из query параметра
|
|
||||||
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
|
|
||||||
if queryParam == "" {
|
|
||||||
return defaultValue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.Atoi(queryParam)
|
|
||||||
if err != nil {
|
|
||||||
return defaultValue, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseBoolFromQuery парсит bool из query параметра
|
|
||||||
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
|
|
||||||
if queryParam == "" {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.ToLower(queryParam) == "true" || queryParam == "1"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// pkg/logger/helpers.go
|
|
||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogApplicationStart логирует запуск приложения
|
|
||||||
func LogApplicationStart(version, environment, port string) {
|
|
||||||
Get().Info("application starting",
|
|
||||||
zap.String("version", version),
|
|
||||||
zap.String("environment", environment),
|
|
||||||
zap.String("port", port),
|
|
||||||
zap.Time("start_time", time.Now()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogApplicationShutdown логирует graceful shutdown
|
|
||||||
func LogApplicationShutdown(reason string) {
|
|
||||||
Get().Info("application shutting down",
|
|
||||||
zap.String("reason", reason),
|
|
||||||
zap.Time("shutdown_time", time.Now()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogDatabaseStats логирует статистику базы данных
|
|
||||||
func LogDatabaseStats(stats map[string]interface{}) {
|
|
||||||
fields := make([]zap.Field, 0, len(stats))
|
|
||||||
for key, value := range stats {
|
|
||||||
fields = append(fields, zap.Any(key, value))
|
|
||||||
}
|
|
||||||
Get().Info("database statistics", fields...)
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// pkg/logger/interface.go
|
|
||||||
package logger
|
|
||||||
|
|
||||||
import "go.uber.org/zap"
|
|
||||||
|
|
||||||
// LoggerInterface определяет контракт для логгера
|
|
||||||
type LoggerInterface interface {
|
|
||||||
Debug(msg string, fields ...zap.Field)
|
|
||||||
Info(msg string, fields ...zap.Field)
|
|
||||||
Warn(msg string, fields ...zap.Field)
|
|
||||||
Error(msg string, fields ...zap.Field)
|
|
||||||
Fatal(msg string, fields ...zap.Field)
|
|
||||||
|
|
||||||
Debugf(template string, args ...interface{})
|
|
||||||
Infof(template string, args ...interface{})
|
|
||||||
Warnf(template string, args ...interface{})
|
|
||||||
Errorf(template string, args ...interface{})
|
|
||||||
Fatalf(template string, args ...interface{})
|
|
||||||
|
|
||||||
With(fields ...zap.Field) LoggerInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapper обертка для zap.Logger
|
|
||||||
type wrapper struct {
|
|
||||||
logger *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWrapper создает новую обертку
|
|
||||||
func NewWrapper(logger *zap.Logger) LoggerInterface {
|
|
||||||
return &wrapper{logger: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Debug(msg string, fields ...zap.Field) {
|
|
||||||
w.logger.Debug(msg, fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Info(msg string, fields ...zap.Field) {
|
|
||||||
w.logger.Info(msg, fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Warn(msg string, fields ...zap.Field) {
|
|
||||||
w.logger.Warn(msg, fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Error(msg string, fields ...zap.Field) {
|
|
||||||
w.logger.Error(msg, fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Fatal(msg string, fields ...zap.Field) {
|
|
||||||
w.logger.Fatal(msg, fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Debugf(template string, args ...interface{}) {
|
|
||||||
w.logger.Sugar().Debugf(template, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Infof(template string, args ...interface{}) {
|
|
||||||
w.logger.Sugar().Infof(template, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Warnf(template string, args ...interface{}) {
|
|
||||||
w.logger.Sugar().Warnf(template, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Errorf(template string, args ...interface{}) {
|
|
||||||
w.logger.Sugar().Errorf(template, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) Fatalf(template string, args ...interface{}) {
|
|
||||||
w.logger.Sugar().Fatalf(template, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrapper) With(fields ...zap.Field) LoggerInterface {
|
|
||||||
return &wrapper{logger: w.logger.With(fields...)}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// pkg/logger/logger.go
|
|
||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"go.uber.org/zap/zapcore"
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
var globalLogger *zap.Logger
|
|
||||||
|
|
||||||
// Init инициализирует глобальный логгер
|
|
||||||
func Init(level string, environment string) error {
|
|
||||||
var config zap.Config
|
|
||||||
|
|
||||||
if environment == "production" {
|
|
||||||
config = zap.NewProductionConfig()
|
|
||||||
} else {
|
|
||||||
config = zap.NewDevelopmentConfig()
|
|
||||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем уровень логирования
|
|
||||||
switch level {
|
|
||||||
case "debug":
|
|
||||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
|
||||||
case "info":
|
|
||||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
|
||||||
case "warn":
|
|
||||||
config.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
|
|
||||||
case "error":
|
|
||||||
config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
|
|
||||||
default:
|
|
||||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger, err := config.Build()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
globalLogger = logger
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get возвращает глобальный логгер
|
|
||||||
func Get() *zap.Logger {
|
|
||||||
if globalLogger == nil {
|
|
||||||
// Fallback на стандартный логгер если не инициализирован
|
|
||||||
logger, _ := zap.NewProduction()
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
return globalLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync синхронизирует буферы логгера
|
|
||||||
func Sync() {
|
|
||||||
if globalLogger != nil {
|
|
||||||
globalLogger.Sync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sugar возвращает SugaredLogger
|
|
||||||
func Sugar() *zap.SugaredLogger {
|
|
||||||
return Get().Sugar()
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RouteLogger struct {
|
|
||||||
logger LoggerInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouteLogger(log LoggerInterface) *RouteLogger {
|
|
||||||
return &RouteLogger{
|
|
||||||
logger: log,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RouteLogger) LogRoutes(router *chi.Mux) {
|
|
||||||
routes := rl.extractRoutes(router)
|
|
||||||
rl.printFormattedRoutes(routes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RouteLogger) extractRoutes(router *chi.Mux) []RouteInfo {
|
|
||||||
var routes []RouteInfo
|
|
||||||
|
|
||||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
|
||||||
if route != "" {
|
|
||||||
routes = append(routes, RouteInfo{
|
|
||||||
Method: method,
|
|
||||||
Path: route,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := chi.Walk(router, walkFunc); err != nil {
|
|
||||||
rl.logger.Error("Failed to walk routes", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RouteLogger) printFormattedRoutes(routes []RouteInfo) {
|
|
||||||
if len(routes) == 0 {
|
|
||||||
rl.logger.Info("No routes found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Группируем по пути
|
|
||||||
routesByPath := make(map[string][]string)
|
|
||||||
for _, route := range routes {
|
|
||||||
routesByPath[route.Path] = append(routesByPath[route.Path], route.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сортируем пути
|
|
||||||
var paths []string
|
|
||||||
for path := range routesByPath {
|
|
||||||
paths = append(paths, path)
|
|
||||||
}
|
|
||||||
sort.Strings(paths)
|
|
||||||
|
|
||||||
rl.logger.Info("📋 Registered API Routes:")
|
|
||||||
rl.logger.Info("┌──────────────────────────────────────────────────────────────┐")
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
methods := routesByPath[path]
|
|
||||||
sort.Strings(methods)
|
|
||||||
methodsStr := strings.Join(methods, ", ")
|
|
||||||
|
|
||||||
if len(methodsStr) > 12 {
|
|
||||||
methodsStr = methodsStr[:9] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
methodField := methodsStr
|
|
||||||
if len(methodField) < 12 {
|
|
||||||
methodField = methodField + strings.Repeat(" ", 12-len(methodField))
|
|
||||||
}
|
|
||||||
|
|
||||||
pathField := path
|
|
||||||
if len(pathField) > 45 {
|
|
||||||
pathField = pathField[:42] + "..."
|
|
||||||
} else {
|
|
||||||
pathField = pathField + strings.Repeat(" ", 45-len(pathField))
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.logger.Info("│ " + methodField + " " + pathField + " │")
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.logger.Info("└──────────────────────────────────────────────────────────────┘")
|
|
||||||
rl.logger.Info("Total routes registered: %d", zap.Int("count", len(routes)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type RouteInfo struct {
|
|
||||||
Method string
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# EasySite BackEnd
|
|
||||||
|
|
||||||
## Stack golang gorm chi
|
|
||||||
|
|
||||||
models:
|
|
||||||
user, object
|
|
||||||
@@ -44,6 +44,8 @@ func autoMigrate(db *gorm.DB) error {
|
|||||||
&models.Account{},
|
&models.Account{},
|
||||||
&models.UpdateHistory{},
|
&models.UpdateHistory{},
|
||||||
&models.Object{},
|
&models.Object{},
|
||||||
|
&models.ObjectImage{},
|
||||||
|
&models.Amenity{},
|
||||||
&models.RatingVote{},
|
&models.RatingVote{},
|
||||||
&models.VoteBreakdown{},
|
&models.VoteBreakdown{},
|
||||||
&models.Rating{},
|
&models.Rating{},
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package amenity
|
||||||
|
|
||||||
|
import "api_yal/internal/models"
|
||||||
|
|
||||||
|
type CreateAmenityRequest struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateAmenityRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Category *string `json:"category"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmenityResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToAmenityResponse(a *models.Amenity) AmenityResponse {
|
||||||
|
return AmenityResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
Category: a.Category,
|
||||||
|
Icon: a.Icon,
|
||||||
|
Description: a.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package amenity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"api_yal/internal/logger"
|
||||||
|
"api_yal/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmenityHandler struct {
|
||||||
|
service AmenityService
|
||||||
|
validator *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service AmenityService) *AmenityHandler {
|
||||||
|
return &AmenityHandler{
|
||||||
|
service: service,
|
||||||
|
validator: validator.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreateAmenityRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.service.Create(&req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Get().Error("failed to create amenity", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to create amenity", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.service.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errors.New("amenity not found")) {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Failed to get amenity", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UpdateAmenityRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.service.Update(uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Get().Error("failed to update amenity", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to update amenity", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid amenity ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Delete(uint(id)); err != nil {
|
||||||
|
logger.Get().Error("failed to delete amenity", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to delete amenity", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
category := r.URL.Query().Get("category")
|
||||||
|
var resp []AmenityResponse
|
||||||
|
var err error
|
||||||
|
if category != "" {
|
||||||
|
resp, err = h.service.ListByCategory(category)
|
||||||
|
} else {
|
||||||
|
resp, err = h.service.List()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Get().Error("failed to list amenities", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to list amenities", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
resp = []AmenityResponse{}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) GetByObject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
objectID, err := strconv.ParseUint(chi.URLParam(r, "objectId"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid object ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.service.GetByObject(uint(objectID))
|
||||||
|
if err != nil {
|
||||||
|
logger.Get().Error("failed to get object amenities", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to get object amenities", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
resp = []AmenityResponse{}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmenityHandler) ReplaceObjectAmenities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
objectID, err := strconv.ParseUint(chi.URLParam(r, "objectId"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid object ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = userID
|
||||||
|
var req struct {
|
||||||
|
AmenityIDs []uint `json:"amenity_ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.ReplaceObjectAmenities(uint(objectID), req.AmenityIDs); err != nil {
|
||||||
|
logger.Get().Error("failed to replace object amenities", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to replace amenities", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "Amenities updated"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package amenity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"api_yal/internal/logger"
|
||||||
|
"api_yal/internal/middleware"
|
||||||
|
"api_yal/internal/repository"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Debug("Регистрация маршрутов для amenity")
|
||||||
|
|
||||||
|
amenityRepo := repository.NewAmenityRepository(db)
|
||||||
|
amenityService := NewService(amenityRepo)
|
||||||
|
amenityHandler := NewHandler(amenityService)
|
||||||
|
|
||||||
|
r.Route("/amenities", func(r chi.Router) {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Get("/", amenityHandler.List)
|
||||||
|
r.Get("/{id}", amenityHandler.GetByID)
|
||||||
|
r.Get("/object/{objectId}", amenityHandler.GetByObject)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.AuthMiddleware(jwtSecret))
|
||||||
|
|
||||||
|
r.Post("/", amenityHandler.Create)
|
||||||
|
r.Put("/{id}", amenityHandler.Update)
|
||||||
|
r.Delete("/{id}", amenityHandler.Delete)
|
||||||
|
r.Put("/object/{objectId}", amenityHandler.ReplaceObjectAmenities)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package amenity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"api_yal/internal/models"
|
||||||
|
"api_yal/internal/repository"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmenityService interface {
|
||||||
|
Create(req *CreateAmenityRequest) (*AmenityResponse, error)
|
||||||
|
GetByID(id uint) (*AmenityResponse, error)
|
||||||
|
Update(id uint, req *UpdateAmenityRequest) (*AmenityResponse, error)
|
||||||
|
Delete(id uint) error
|
||||||
|
List() ([]AmenityResponse, error)
|
||||||
|
ListByCategory(category string) ([]AmenityResponse, error)
|
||||||
|
GetByObject(objectID uint) ([]AmenityResponse, error)
|
||||||
|
ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type amenityServiceImpl struct {
|
||||||
|
repo repository.AmenityRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo repository.AmenityRepository) AmenityService {
|
||||||
|
return &amenityServiceImpl{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) Create(req *CreateAmenityRequest) (*AmenityResponse, error) {
|
||||||
|
amenity := &models.Amenity{
|
||||||
|
Name: req.Name,
|
||||||
|
Category: req.Category,
|
||||||
|
Icon: req.Icon,
|
||||||
|
Description: req.Description,
|
||||||
|
}
|
||||||
|
if err := s.repo.Create(amenity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := ToAmenityResponse(amenity)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) GetByID(id uint) (*AmenityResponse, error) {
|
||||||
|
amenity, err := s.repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("amenity not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := ToAmenityResponse(amenity)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) Update(id uint, req *UpdateAmenityRequest) (*AmenityResponse, error) {
|
||||||
|
amenity, err := s.repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("amenity not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
amenity.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Category != nil {
|
||||||
|
amenity.Category = *req.Category
|
||||||
|
}
|
||||||
|
if req.Icon != nil {
|
||||||
|
amenity.Icon = *req.Icon
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
amenity.Description = *req.Description
|
||||||
|
}
|
||||||
|
if err := s.repo.Update(amenity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := ToAmenityResponse(amenity)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) Delete(id uint) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) List() ([]AmenityResponse, error) {
|
||||||
|
amenities, err := s.repo.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := make([]AmenityResponse, len(amenities))
|
||||||
|
for i, a := range amenities {
|
||||||
|
resp[i] = ToAmenityResponse(&a)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) ListByCategory(category string) ([]AmenityResponse, error) {
|
||||||
|
amenities, err := s.repo.ListByCategory(category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := make([]AmenityResponse, len(amenities))
|
||||||
|
for i, a := range amenities {
|
||||||
|
resp[i] = ToAmenityResponse(&a)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) GetByObject(objectID uint) ([]AmenityResponse, error) {
|
||||||
|
amenities, err := s.repo.GetByObject(objectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := make([]AmenityResponse, len(amenities))
|
||||||
|
for i, a := range amenities {
|
||||||
|
resp[i] = ToAmenityResponse(&a)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *amenityServiceImpl) ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error {
|
||||||
|
return s.repo.ReplaceObjectAmenities(objectID, amenityIDs)
|
||||||
|
}
|
||||||
@@ -344,6 +344,33 @@ func (h *AuthHandler) MobileLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMe возвращает информацию о текущем пользователе
|
||||||
|
func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.authService.GetUserFromID(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"user": UserInfo{
|
||||||
|
ID: account.Base.ID,
|
||||||
|
Email: account.Email,
|
||||||
|
FirstName: account.FirstName,
|
||||||
|
LastName: account.LastName,
|
||||||
|
FullName: account.FullName,
|
||||||
|
Role: account.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleValidationError обрабатывает ошибки валидации
|
// handleValidationError обрабатывает ошибки валидации
|
||||||
func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) {
|
func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) {
|
||||||
var invalidValidationError *validator.InvalidValidationError
|
var invalidValidationError *validator.InvalidValidationError
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
|
|||||||
|
|
||||||
r.Post("/logout", handler.Logout)
|
r.Post("/logout", handler.Logout)
|
||||||
r.Post("/change-password", handler.RequestPasswordReset)
|
r.Post("/change-password", handler.RequestPasswordReset)
|
||||||
|
r.Get("/me", handler.GetMe)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type AuthService interface {
|
|||||||
Logout(userID uint) error
|
Logout(userID uint) error
|
||||||
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
|
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
|
||||||
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
|
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
|
||||||
|
GetUserFromID(userID uint) (*models.Account, error)
|
||||||
|
|
||||||
// Reset password methods
|
// Reset password methods
|
||||||
RequestPasswordReset(email string) (string, error) // Возвращает reset token
|
RequestPasswordReset(email string) (string, error) // Возвращает reset token
|
||||||
@@ -381,6 +382,11 @@ func (s *authServiceImpl) GetUserFromToken(claims *jwt.MapClaims) (*models.Accou
|
|||||||
return s.accountRepo.GetByID(userID)
|
return s.accountRepo.GetByID(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserFromID получает пользователя по ID
|
||||||
|
func (s *authServiceImpl) GetUserFromID(userID uint) (*models.Account, error) {
|
||||||
|
return s.accountRepo.GetByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
// generateAccessToken генерирует access token
|
// generateAccessToken генерирует access token
|
||||||
func (s *authServiceImpl) generateAccessToken(account *models.Account) (string, time.Time, error) {
|
func (s *authServiceImpl) generateAccessToken(account *models.Account) (string, time.Time, error) {
|
||||||
expiresAt := time.Now().Add(s.accessTokenTTL)
|
expiresAt := time.Now().Add(s.accessTokenTTL)
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import (
|
|||||||
// CreateObjectRequest - DTO для создания объекта
|
// CreateObjectRequest - DTO для создания объекта
|
||||||
type CreateObjectRequest struct {
|
type CreateObjectRequest struct {
|
||||||
OwnerID uint `json:"owner_id" binding:"required"`
|
OwnerID uint `json:"owner_id" binding:"required"`
|
||||||
|
Title string `json:"title"`
|
||||||
ShortName string `json:"short_name" binding:"required,min=1,max=255"`
|
ShortName string `json:"short_name" binding:"required,min=1,max=255"`
|
||||||
LongName string `json:"long_name"`
|
LongName string `json:"long_name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PricePeriod string `json:"price_period"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Email string `json:"email" binding:"omitempty,email"`
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
Site string `json:"site" binding:"omitempty,url"`
|
Site string `json:"site" binding:"omitempty,url"`
|
||||||
@@ -23,15 +26,20 @@ type CreateObjectRequest struct {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Latitude float64 `json:"latitude" binding:"omitempty,latitude"`
|
Latitude float64 `json:"latitude" binding:"omitempty,latitude"`
|
||||||
Longitude float64 `json:"longitude" binding:"omitempty,longitude"`
|
Longitude float64 `json:"longitude" binding:"omitempty,longitude"`
|
||||||
IsActive *bool `json:"is_active"` // указатель, чтобы отличать false от отсутствия значения
|
Status string `json:"status"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
IsVerified *bool `json:"is_verified"`
|
IsVerified *bool `json:"is_verified"`
|
||||||
|
AmenityIDs []uint `json:"amenity_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateObjectRequest - DTO для обновления объекта (все поля опциональны)
|
// UpdateObjectRequest - DTO для обновления объекта (все поля опциональны)
|
||||||
type UpdateObjectRequest struct {
|
type UpdateObjectRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
ShortName *string `json:"short_name" binding:"omitempty,min=1,max=255"`
|
ShortName *string `json:"short_name" binding:"omitempty,min=1,max=255"`
|
||||||
LongName *string `json:"long_name"`
|
LongName *string `json:"long_name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
PricePeriod *string `json:"price_period"`
|
||||||
Phone *string `json:"phone"`
|
Phone *string `json:"phone"`
|
||||||
Email *string `json:"email" binding:"omitempty,email"`
|
Email *string `json:"email" binding:"omitempty,email"`
|
||||||
Site *string `json:"site" binding:"omitempty,url"`
|
Site *string `json:"site" binding:"omitempty,url"`
|
||||||
@@ -40,8 +48,10 @@ type UpdateObjectRequest struct {
|
|||||||
Address *string `json:"address"`
|
Address *string `json:"address"`
|
||||||
Latitude *float64 `json:"latitude" binding:"omitempty,latitude"`
|
Latitude *float64 `json:"latitude" binding:"omitempty,latitude"`
|
||||||
Longitude *float64 `json:"longitude" binding:"omitempty,longitude"`
|
Longitude *float64 `json:"longitude" binding:"omitempty,longitude"`
|
||||||
|
Status *string `json:"status"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
IsVerified *bool `json:"is_verified"`
|
IsVerified *bool `json:"is_verified"`
|
||||||
|
AmenityIDs []uint `json:"amenity_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectResponse - DTO для полного ответа с объектом (включая связанные данные)
|
// ObjectResponse - DTO для полного ответа с объектом (включая связанные данные)
|
||||||
@@ -52,9 +62,12 @@ type ObjectResponse struct {
|
|||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
OwnerID uint `json:"owner_id"`
|
OwnerID uint `json:"owner_id"`
|
||||||
Owner *account.AccountResponse `json:"owner,omitempty"`
|
Owner *account.AccountResponse `json:"owner,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
ShortName string `json:"short_name"`
|
ShortName string `json:"short_name"`
|
||||||
LongName string `json:"long_name"`
|
LongName string `json:"long_name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PricePeriod string `json:"price_period"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Site string `json:"site"`
|
Site string `json:"site"`
|
||||||
@@ -65,27 +78,53 @@ type ObjectResponse struct {
|
|||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
IsVerified bool `json:"is_verified"`
|
IsVerified bool `json:"is_verified"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ViewCount int `json:"view_count"`
|
||||||
FeedbackCount int `json:"feedback_count"`
|
FeedbackCount int `json:"feedback_count"`
|
||||||
TouristRating *RatingResponse `json:"tourist_rating,omitempty"`
|
TouristRating *RatingResponse `json:"tourist_rating,omitempty"`
|
||||||
EntrepreneurRating *RatingResponse `json:"entrepreneur_rating,omitempty"`
|
EntrepreneurRating *RatingResponse `json:"entrepreneur_rating,omitempty"`
|
||||||
Feedbacks []FeedbackShortResponse `json:"feedbacks,omitempty"`
|
Feedbacks []FeedbackShortResponse `json:"feedbacks,omitempty"`
|
||||||
|
Images []ImageResponse `json:"images,omitempty"`
|
||||||
|
Amenities []AmenityResponse `json:"amenities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectShortResponse - DTO для краткого ответа (списки, вложенные данные)
|
// ObjectShortResponse - DTO для краткого ответа (списки, вложенные данные)
|
||||||
type ObjectShortResponse struct {
|
type ObjectShortResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
ShortName string `json:"short_name"`
|
ShortName string `json:"short_name"`
|
||||||
LongName string `json:"long_name"`
|
LongName string `json:"long_name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PricePeriod string `json:"price_period"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
IsVerified bool `json:"is_verified"`
|
IsVerified bool `json:"is_verified"`
|
||||||
|
Status string `json:"status"`
|
||||||
FeedbackCount int `json:"feedback_count"`
|
FeedbackCount int `json:"feedback_count"`
|
||||||
// Агрегированные рейтинги для списка
|
// Агрегированные рейтинги для списка
|
||||||
TouristAverageScore float64 `json:"tourist_average_score,omitempty"`
|
TouristAverageScore float64 `json:"tourist_average_score,omitempty"`
|
||||||
EntrepreneurAverageScore float64 `json:"entrepreneur_average_score,omitempty"`
|
EntrepreneurAverageScore float64 `json:"entrepreneur_average_score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImageResponse - DTO для изображения
|
||||||
|
type ImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ObjectID uint `json:"object_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
IsPrimary bool `json:"is_primary"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmenityResponse - DTO для удобства
|
||||||
|
type AmenityResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ObjectListResponse - DTO для списка объектов с пагинацией
|
// ObjectListResponse - DTO для списка объектов с пагинацией
|
||||||
type ObjectListResponse struct {
|
type ObjectListResponse struct {
|
||||||
Items []ObjectShortResponse `json:"items"`
|
Items []ObjectShortResponse `json:"items"`
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func (h *ObjectHandler) ListObjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
PageSize: h.getQueryParamInt(r, "page_size", 10),
|
PageSize: h.getQueryParamInt(r, "page_size", 10),
|
||||||
Type: r.URL.Query().Get("type"),
|
Type: r.URL.Query().Get("type"),
|
||||||
Query: r.URL.Query().Get("q"),
|
Query: r.URL.Query().Get("q"),
|
||||||
|
ObjectStatus: r.URL.Query().Get("status"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusStr := r.URL.Query().Get("is_active"); statusStr != "" {
|
if statusStr := r.URL.Query().Get("is_active"); statusStr != "" {
|
||||||
@@ -127,6 +128,26 @@ func (h *ObjectHandler) ListObjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.respondWithJSON(w, http.StatusOK, response)
|
h.respondWithJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMyObjects обрабатывает GET /objects/my
|
||||||
|
func (h *ObjectHandler) GetMyObjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := h.getQueryParamInt(r, "page", 1)
|
||||||
|
pageSize := h.getQueryParamInt(r, "page_size", 10)
|
||||||
|
|
||||||
|
response, err := h.objectService.GetObjectsByOwner(r.Context(), userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondWithJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
// GetObjectsByOwner обрабатывает GET /objects/owner/{ownerId}
|
// GetObjectsByOwner обрабатывает GET /objects/owner/{ownerId}
|
||||||
func (h *ObjectHandler) GetObjectsByOwner(w http.ResponseWriter, r *http.Request) {
|
func (h *ObjectHandler) GetObjectsByOwner(w http.ResponseWriter, r *http.Request) {
|
||||||
ownerID, err := strconv.ParseUint(chi.URLParam(r, "ownerId"), 10, 32)
|
ownerID, err := strconv.ParseUint(chi.URLParam(r, "ownerId"), 10, 32)
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.AuthMiddleware(jwtSecret))
|
r.Use(middleware.AuthMiddleware(jwtSecret))
|
||||||
|
|
||||||
|
// Мои объекты
|
||||||
|
r.Get("/objects/my", objectHandler.GetMyObjects)
|
||||||
|
|
||||||
// CRUD для объектов
|
// CRUD для объектов
|
||||||
r.Post("/objects", objectHandler.CreateObject)
|
r.Post("/objects", objectHandler.CreateObject)
|
||||||
r.Put("/objects/{id}", objectHandler.UpdateObject)
|
r.Put("/objects/{id}", objectHandler.UpdateObject)
|
||||||
|
|||||||
@@ -83,11 +83,23 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
|
|||||||
isVerified = *req.IsVerified
|
isVerified = *req.IsVerified
|
||||||
}
|
}
|
||||||
|
|
||||||
|
title := req.Title
|
||||||
|
if title == "" {
|
||||||
|
title = req.ShortName
|
||||||
|
}
|
||||||
|
status := models.ObjectStatusActive
|
||||||
|
if req.Status != "" {
|
||||||
|
status = models.ObjectStatus(req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
object := &models.Object{
|
object := &models.Object{
|
||||||
OwnerID: req.OwnerID,
|
OwnerID: req.OwnerID,
|
||||||
|
Title: title,
|
||||||
ShortName: req.ShortName,
|
ShortName: req.ShortName,
|
||||||
LongName: req.LongName,
|
LongName: req.LongName,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
|
Price: req.Price,
|
||||||
|
PricePeriod: req.PricePeriod,
|
||||||
Phone: req.Phone,
|
Phone: req.Phone,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Site: req.Site,
|
Site: req.Site,
|
||||||
@@ -98,6 +110,8 @@ func (s *objectServiceImpl) CreateObject(ctx context.Context, req *CreateObjectR
|
|||||||
Longitude: req.Longitude,
|
Longitude: req.Longitude,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
IsVerified: isVerified,
|
IsVerified: isVerified,
|
||||||
|
Status: status,
|
||||||
|
ViewCount: 0,
|
||||||
FeedbackCount: 0,
|
FeedbackCount: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +187,11 @@ func (s *objectServiceImpl) ListObjects(ctx context.Context, req *ListObjectsReq
|
|||||||
|
|
||||||
// Применяем фильтры
|
// Применяем фильтры
|
||||||
switch {
|
switch {
|
||||||
|
case req.ObjectStatus != "":
|
||||||
|
objects, err = s.objectRepository.ListByObjectStatus(req.ObjectStatus, offset, pageSize)
|
||||||
|
if err == nil {
|
||||||
|
total, _ = s.countObjectsByStatusString(req.ObjectStatus)
|
||||||
|
}
|
||||||
case req.Type != "":
|
case req.Type != "":
|
||||||
objects, err = s.objectRepository.ListByType(req.Type, offset, pageSize)
|
objects, err = s.objectRepository.ListByType(req.Type, offset, pageSize)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -553,6 +572,9 @@ func (s *objectServiceImpl) validateCreateRequest(req *CreateObjectRequest) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjectRequest) {
|
func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjectRequest) {
|
||||||
|
if req.Title != nil {
|
||||||
|
object.Title = *req.Title
|
||||||
|
}
|
||||||
if req.ShortName != nil {
|
if req.ShortName != nil {
|
||||||
object.ShortName = *req.ShortName
|
object.ShortName = *req.ShortName
|
||||||
}
|
}
|
||||||
@@ -562,6 +584,12 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
|
|||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
object.Type = *req.Type
|
object.Type = *req.Type
|
||||||
}
|
}
|
||||||
|
if req.Price != nil {
|
||||||
|
object.Price = *req.Price
|
||||||
|
}
|
||||||
|
if req.PricePeriod != nil {
|
||||||
|
object.PricePeriod = *req.PricePeriod
|
||||||
|
}
|
||||||
if req.Phone != nil {
|
if req.Phone != nil {
|
||||||
object.Phone = *req.Phone
|
object.Phone = *req.Phone
|
||||||
}
|
}
|
||||||
@@ -586,6 +614,9 @@ func (s *objectServiceImpl) applyUpdates(object *models.Object, req *UpdateObjec
|
|||||||
if req.Longitude != nil {
|
if req.Longitude != nil {
|
||||||
object.Longitude = *req.Longitude
|
object.Longitude = *req.Longitude
|
||||||
}
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
object.Status = models.ObjectStatus(*req.Status)
|
||||||
|
}
|
||||||
if req.IsActive != nil {
|
if req.IsActive != nil {
|
||||||
object.IsActive = *req.IsActive
|
object.IsActive = *req.IsActive
|
||||||
}
|
}
|
||||||
@@ -610,9 +641,12 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
|
|||||||
CreatedAt: object.CreatedAt,
|
CreatedAt: object.CreatedAt,
|
||||||
UpdatedAt: object.UpdatedAt,
|
UpdatedAt: object.UpdatedAt,
|
||||||
OwnerID: object.OwnerID,
|
OwnerID: object.OwnerID,
|
||||||
|
Title: object.Title,
|
||||||
ShortName: object.ShortName,
|
ShortName: object.ShortName,
|
||||||
LongName: object.LongName,
|
LongName: object.LongName,
|
||||||
Type: object.Type,
|
Type: object.Type,
|
||||||
|
Price: object.Price,
|
||||||
|
PricePeriod: object.PricePeriod,
|
||||||
Phone: object.Phone,
|
Phone: object.Phone,
|
||||||
Email: object.Email,
|
Email: object.Email,
|
||||||
Site: object.Site,
|
Site: object.Site,
|
||||||
@@ -623,6 +657,8 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
|
|||||||
Longitude: object.Longitude,
|
Longitude: object.Longitude,
|
||||||
IsActive: object.IsActive,
|
IsActive: object.IsActive,
|
||||||
IsVerified: object.IsVerified,
|
IsVerified: object.IsVerified,
|
||||||
|
Status: string(object.Status),
|
||||||
|
ViewCount: object.ViewCount,
|
||||||
FeedbackCount: object.FeedbackCount,
|
FeedbackCount: object.FeedbackCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,18 +697,48 @@ func (s *objectServiceImpl) mapToObjectResponse(object *models.Object, owner *mo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(object.Images) > 0 {
|
||||||
|
resp.Images = make([]ImageResponse, len(object.Images))
|
||||||
|
for i, img := range object.Images {
|
||||||
|
resp.Images[i] = ImageResponse{
|
||||||
|
ID: img.ID,
|
||||||
|
ObjectID: img.ObjectID,
|
||||||
|
URL: img.URL,
|
||||||
|
IsPrimary: img.IsPrimary,
|
||||||
|
SortOrder: img.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(object.Amenities) > 0 {
|
||||||
|
resp.Amenities = make([]AmenityResponse, len(object.Amenities))
|
||||||
|
for i, a := range object.Amenities {
|
||||||
|
resp.Amenities[i] = AmenityResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
Category: a.Category,
|
||||||
|
Icon: a.Icon,
|
||||||
|
Description: a.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *objectServiceImpl) mapToObjectShortResponse(object *models.Object) ObjectShortResponse {
|
func (s *objectServiceImpl) mapToObjectShortResponse(object *models.Object) ObjectShortResponse {
|
||||||
return ObjectShortResponse{
|
return ObjectShortResponse{
|
||||||
ID: object.ID,
|
ID: object.ID,
|
||||||
|
Title: object.Title,
|
||||||
ShortName: object.ShortName,
|
ShortName: object.ShortName,
|
||||||
LongName: object.LongName,
|
LongName: object.LongName,
|
||||||
Type: object.Type,
|
Type: object.Type,
|
||||||
|
Price: object.Price,
|
||||||
|
PricePeriod: object.PricePeriod,
|
||||||
Address: object.Address,
|
Address: object.Address,
|
||||||
IsActive: object.IsActive,
|
IsActive: object.IsActive,
|
||||||
IsVerified: object.IsVerified,
|
IsVerified: object.IsVerified,
|
||||||
|
Status: string(object.Status),
|
||||||
FeedbackCount: object.FeedbackCount,
|
FeedbackCount: object.FeedbackCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,3 +795,7 @@ func (s *objectServiceImpl) countObjectsBySearch(query string) (int64, error) {
|
|||||||
// TODO: Добавить метод CountBySearch в репозиторий
|
// TODO: Добавить метод CountBySearch в репозиторий
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *objectServiceImpl) countObjectsByStatusString(status string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package object
|
|||||||
|
|
||||||
// ListObjectsRequest параметры для получения списка объектов
|
// ListObjectsRequest параметры для получения списка объектов
|
||||||
type ListObjectsRequest struct {
|
type ListObjectsRequest struct {
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
Type string
|
Type string
|
||||||
Status *bool
|
Status *bool
|
||||||
Query string
|
ObjectStatus string
|
||||||
|
Query string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedbackListResponse ответ со списком отзывов
|
// FeedbackListResponse ответ со списком отзывов
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"api_yal/internal/logger"
|
||||||
|
"api_yal/internal/middleware"
|
||||||
|
"api_yal/internal/models"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UploadHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
uploadPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB, uploadPath string) *UploadHandler {
|
||||||
|
if err := os.MkdirAll(uploadPath, 0755); err != nil {
|
||||||
|
logger.Get().Warn("failed to create upload directory", zap.String("path", uploadPath), zap.Error(err))
|
||||||
|
}
|
||||||
|
return &UploadHandler{db: db, uploadPath: uploadPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "File is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
objectIDStr := r.FormValue("object_id")
|
||||||
|
if objectIDStr == "" {
|
||||||
|
http.Error(w, "object_id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectID uint
|
||||||
|
if _, err := fmt.Sscan(objectIDStr, &objectID); err != nil {
|
||||||
|
http.Error(w, "Invalid object_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext)
|
||||||
|
filePath := filepath.Join(h.uploadPath, filename)
|
||||||
|
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Get().Error("failed to create file", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, file); err != nil {
|
||||||
|
logger.Get().Error("failed to write file", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPrimary := false
|
||||||
|
if r.FormValue("is_primary") == "true" {
|
||||||
|
isPrimary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
image := models.ObjectImage{
|
||||||
|
ObjectID: objectID,
|
||||||
|
URL: "/uploads/" + filename,
|
||||||
|
IsPrimary: isPrimary,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&image).Error; err != nil {
|
||||||
|
logger.Get().Error("failed to save image record", zap.Error(err))
|
||||||
|
http.Error(w, "Failed to save image", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": image.ID,
|
||||||
|
"url": image.URL,
|
||||||
|
"object_id": image.ObjectID,
|
||||||
|
"is_primary": image.IsPrimary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UploadHandler) DeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageIDStr := r.URL.Query().Get("id")
|
||||||
|
if imageIDStr == "" {
|
||||||
|
http.Error(w, "id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageID uint
|
||||||
|
if _, err := fmt.Sscan(imageIDStr, &imageID); err != nil {
|
||||||
|
http.Error(w, "Invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.ObjectImage
|
||||||
|
if err := h.db.First(&image, imageID).Error; err != nil {
|
||||||
|
http.Error(w, "Image not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var object models.Object
|
||||||
|
if err := h.db.First(&object, image.ObjectID).Error; err != nil {
|
||||||
|
http.Error(w, "Object not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if object.OwnerID != userID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(h.uploadPath, filepath.Base(image.URL))
|
||||||
|
os.Remove(filePath)
|
||||||
|
|
||||||
|
h.db.Delete(&image)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "Image deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"api_yal/internal/logger"
|
||||||
|
"api_yal/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string, uploadPath string) {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Debug("Регистрация маршрутов для upload")
|
||||||
|
|
||||||
|
handler := NewHandler(db, uploadPath)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.AuthMiddleware(jwtSecret))
|
||||||
|
|
||||||
|
r.Post("/upload", handler.Upload)
|
||||||
|
r.Delete("/upload", handler.DeleteImage)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@ package models
|
|||||||
|
|
||||||
import ()
|
import ()
|
||||||
|
|
||||||
|
type ObjectStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ObjectStatusDraft ObjectStatus = "draft"
|
||||||
|
ObjectStatusModeration ObjectStatus = "moderation"
|
||||||
|
ObjectStatusActive ObjectStatus = "active"
|
||||||
|
ObjectStatusInactive ObjectStatus = "inactive"
|
||||||
|
ObjectStatusRejected ObjectStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
type Object struct {
|
type Object struct {
|
||||||
/*ID, CreatedAt, UpdatedAt, DeletedAt (Update's history)*/
|
/*ID, CreatedAt, UpdatedAt, DeletedAt (Update's history)*/
|
||||||
Base `gorm:"embedded"`
|
Base `gorm:"embedded"`
|
||||||
@@ -11,12 +21,14 @@ type Object struct {
|
|||||||
Owner Account `gorm:"foreignKey:OwnerID;references:ID" json:"owner"`
|
Owner Account `gorm:"foreignKey:OwnerID;references:ID" json:"owner"`
|
||||||
|
|
||||||
// Основная информация
|
// Основная информация
|
||||||
// короткое название
|
Title string `gorm:"default:''" json:"title"`
|
||||||
ShortName string `gorm:"not null" json:"short_name"`
|
ShortName string `gorm:"not null" json:"short_name"`
|
||||||
// длинное название
|
LongName string `json:"long_name"`
|
||||||
LongName string `json:"long_name"`
|
Type string `json:"type"`
|
||||||
// тип места отдыха
|
|
||||||
Type string `json:"type"`
|
// Цена
|
||||||
|
Price float64 `gorm:"default:0" json:"price"`
|
||||||
|
PricePeriod string `gorm:"default:'per_unit'" json:"price_period"`
|
||||||
|
|
||||||
// контактные данные
|
// контактные данные
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
@@ -35,8 +47,10 @@ type Object struct {
|
|||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
|
|
||||||
// Статус объекта
|
// Статус объекта
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
||||||
|
Status ObjectStatus `gorm:"default:active" json:"status"`
|
||||||
|
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||||
|
|
||||||
// Связи с рейтингами (для разных платформ)
|
// Связи с рейтингами (для разных платформ)
|
||||||
TouristRating *Rating `gorm:"foreignKey:ObjectID;references:ID;where:platform='tourist'" json:"tourist_rating,omitempty"`
|
TouristRating *Rating `gorm:"foreignKey:ObjectID;references:ID;where:platform='tourist'" json:"tourist_rating,omitempty"`
|
||||||
@@ -48,4 +62,26 @@ type Object struct {
|
|||||||
// Связи с отзывами
|
// Связи с отзывами
|
||||||
Feedbacks []Feedback `gorm:"foreignKey:ObjectID" json:"feedbacks,omitempty"`
|
Feedbacks []Feedback `gorm:"foreignKey:ObjectID" json:"feedbacks,omitempty"`
|
||||||
FeedbackCount int `gorm:"default:0" json:"feedback_count"`
|
FeedbackCount int `gorm:"default:0" json:"feedback_count"`
|
||||||
|
|
||||||
|
// Изображения
|
||||||
|
Images []ObjectImage `gorm:"foreignKey:ObjectID" json:"images,omitempty"`
|
||||||
|
|
||||||
|
// Удобства (many-to-many)
|
||||||
|
Amenities []Amenity `gorm:"many2many:object_amenities;" json:"amenities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectImage struct {
|
||||||
|
Base `gorm:"embedded"`
|
||||||
|
ObjectID uint `gorm:"not null;index" json:"object_id"`
|
||||||
|
URL string `gorm:"not null" json:"url"`
|
||||||
|
IsPrimary bool `gorm:"default:false" json:"is_primary"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Amenity struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"api_yal/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmenityRepository interface {
|
||||||
|
Create(amenity *models.Amenity) error
|
||||||
|
GetByID(id uint) (*models.Amenity, error)
|
||||||
|
Update(amenity *models.Amenity) error
|
||||||
|
Delete(id uint) error
|
||||||
|
List() ([]models.Amenity, error)
|
||||||
|
ListByCategory(category string) ([]models.Amenity, error)
|
||||||
|
GetByObject(objectID uint) ([]models.Amenity, error)
|
||||||
|
AttachToObject(objectID uint, amenityIDs []uint) error
|
||||||
|
DetachFromObject(objectID uint, amenityIDs []uint) error
|
||||||
|
ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"api_yal/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type amenityRepositoryImpl struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAmenityRepository(db *gorm.DB) AmenityRepository {
|
||||||
|
return &amenityRepositoryImpl{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) Create(amenity *models.Amenity) error {
|
||||||
|
return r.db.Create(amenity).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) GetByID(id uint) (*models.Amenity, error) {
|
||||||
|
var amenity models.Amenity
|
||||||
|
err := r.db.First(&amenity, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &amenity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) Update(amenity *models.Amenity) error {
|
||||||
|
return r.db.Save(amenity).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Amenity{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) List() ([]models.Amenity, error) {
|
||||||
|
var amenities []models.Amenity
|
||||||
|
err := r.db.Find(&amenities).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return amenities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) ListByCategory(category string) ([]models.Amenity, error) {
|
||||||
|
var amenities []models.Amenity
|
||||||
|
err := r.db.Where("category = ?", category).Find(&amenities).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return amenities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) GetByObject(objectID uint) ([]models.Amenity, error) {
|
||||||
|
var object models.Object
|
||||||
|
err := r.db.Preload("Amenities").First(&object, objectID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return object.Amenities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) AttachToObject(objectID uint, amenityIDs []uint) error {
|
||||||
|
var object models.Object
|
||||||
|
if err := r.db.First(&object, objectID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var amenities []models.Amenity
|
||||||
|
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.db.Model(&object).Association("Amenities").Append(&amenities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) DetachFromObject(objectID uint, amenityIDs []uint) error {
|
||||||
|
var object models.Object
|
||||||
|
if err := r.db.First(&object, objectID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var amenities []models.Amenity
|
||||||
|
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.db.Model(&object).Association("Amenities").Delete(&amenities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *amenityRepositoryImpl) ReplaceObjectAmenities(objectID uint, amenityIDs []uint) error {
|
||||||
|
var object models.Object
|
||||||
|
if err := r.db.First(&object, objectID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var amenities []models.Amenity
|
||||||
|
if len(amenityIDs) > 0 {
|
||||||
|
if err := r.db.Find(&amenities, amenityIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.db.Model(&object).Association("Amenities").Replace(&amenities)
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ type ObjectRepository interface {
|
|||||||
// ListByStatus возвращает объекты по статусу
|
// ListByStatus возвращает объекты по статусу
|
||||||
ListByStatus(isActive bool, offset, limit int) ([]models.Object, error)
|
ListByStatus(isActive bool, offset, limit int) ([]models.Object, error)
|
||||||
|
|
||||||
|
// ListByObjectStatus возвращает объекты по статусу объекта (draft, active, etc.)
|
||||||
|
ListByObjectStatus(status string, offset, limit int) ([]models.Object, error)
|
||||||
|
|
||||||
// Search находит объекты по названию, типу или адресу
|
// Search находит объекты по названию, типу или адресу
|
||||||
Search(query string, offset, limit int) ([]models.Object, error)
|
Search(query string, offset, limit int) ([]models.Object, error)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (r *objectRepositoryImpl) Create(object *models.Object) error {
|
|||||||
// GetByID возвращает объект по ID
|
// GetByID возвращает объект по ID
|
||||||
func (r *objectRepositoryImpl) GetByID(id uint) (*models.Object, error) {
|
func (r *objectRepositoryImpl) GetByID(id uint) (*models.Object, error) {
|
||||||
var object models.Object
|
var object models.Object
|
||||||
err := r.db.Preload("Owner").Preload("TouristRating").Preload("EntrepreneurRating").Preload("Ratings").Preload("Feedbacks").First(&object, id).Error
|
err := r.db.Preload("Owner").Preload("TouristRating").Preload("EntrepreneurRating").Preload("Ratings").Preload("Feedbacks").Preload("Images").Preload("Amenities").First(&object, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func (r *objectRepositoryImpl) Delete(id uint) error {
|
|||||||
// List возвращает список объектов с пагинацией
|
// List возвращает список объектов с пагинацией
|
||||||
func (r *objectRepositoryImpl) List(offset, limit int) ([]models.Object, error) {
|
func (r *objectRepositoryImpl) List(offset, limit int) ([]models.Object, error) {
|
||||||
var objects []models.Object
|
var objects []models.Object
|
||||||
err := r.db.Preload("Owner").Offset(offset).Limit(limit).Find(&objects).Error
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ func (r *objectRepositoryImpl) Count() (int64, error) {
|
|||||||
// ListByOwner возвращает объекты по владельцу
|
// ListByOwner возвращает объекты по владельцу
|
||||||
func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]models.Object, error) {
|
func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]models.Object, error) {
|
||||||
var objects []models.Object
|
var objects []models.Object
|
||||||
err := r.db.Preload("Owner").Where("owner_id = ?", ownerID).Offset(offset).Limit(limit).Find(&objects).Error
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("owner_id = ?", ownerID).Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ func (r *objectRepositoryImpl) ListByOwner(ownerID uint, offset, limit int) ([]m
|
|||||||
// ListByType возвращает объекты по типу
|
// ListByType возвращает объекты по типу
|
||||||
func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int) ([]models.Object, error) {
|
func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int) ([]models.Object, error) {
|
||||||
var objects []models.Object
|
var objects []models.Object
|
||||||
err := r.db.Preload("Owner").Where("type = ?", objectType).Offset(offset).Limit(limit).Find(&objects).Error
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("type = ?", objectType).Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,17 @@ func (r *objectRepositoryImpl) ListByType(objectType string, offset, limit int)
|
|||||||
// ListByStatus возвращает объекты по статусу
|
// ListByStatus возвращает объекты по статусу
|
||||||
func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) {
|
func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([]models.Object, error) {
|
||||||
var objects []models.Object
|
var objects []models.Object
|
||||||
err := r.db.Preload("Owner").Where("is_active = ?", isActive).Offset(offset).Limit(limit).Find(&objects).Error
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("is_active = ?", isActive).Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByObjectStatus возвращает объекты по статусу объекта (draft, active, etc.)
|
||||||
|
func (r *objectRepositoryImpl) ListByObjectStatus(status string, offset, limit int) ([]models.Object, error) {
|
||||||
|
var objects []models.Object
|
||||||
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("status = ?", status).Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,7 +100,7 @@ func (r *objectRepositoryImpl) ListByStatus(isActive bool, offset, limit int) ([
|
|||||||
// Search находит объекты по названию, типу или адресу
|
// Search находит объекты по названию, типу или адресу
|
||||||
func (r *objectRepositoryImpl) Search(query string, offset, limit int) ([]models.Object, error) {
|
func (r *objectRepositoryImpl) Search(query string, offset, limit int) ([]models.Object, error) {
|
||||||
var objects []models.Object
|
var objects []models.Object
|
||||||
err := r.db.Preload("Owner").Where("short_name LIKE ? OR long_name LIKE ? OR type LIKE ? OR address LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Offset(offset).Limit(limit).Find(&objects).Error
|
err := r.db.Preload("Owner").Preload("Images").Preload("Amenities").Where("short_name LIKE ? OR long_name LIKE ? OR type LIKE ? OR address LIKE ? OR title LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Offset(offset).Limit(limit).Find(&objects).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package router
|
|||||||
import (
|
import (
|
||||||
"api_yal/internal/config"
|
"api_yal/internal/config"
|
||||||
"api_yal/internal/domain/account"
|
"api_yal/internal/domain/account"
|
||||||
|
"api_yal/internal/domain/amenity"
|
||||||
"api_yal/internal/domain/appeal"
|
"api_yal/internal/domain/appeal"
|
||||||
"api_yal/internal/domain/auth"
|
"api_yal/internal/domain/auth"
|
||||||
"api_yal/internal/domain/comment"
|
"api_yal/internal/domain/comment"
|
||||||
"api_yal/internal/domain/feetback"
|
"api_yal/internal/domain/feetback"
|
||||||
"api_yal/internal/domain/object"
|
"api_yal/internal/domain/object"
|
||||||
"api_yal/internal/domain/rating"
|
"api_yal/internal/domain/rating"
|
||||||
|
"api_yal/internal/domain/upload"
|
||||||
"api_yal/internal/logger"
|
"api_yal/internal/logger"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -74,6 +76,12 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
|
|||||||
// Регистрируем маршруты обращений
|
// Регистрируем маршруты обращений
|
||||||
appeal.RegisterRoutes(r, db, config.JWTSecret)
|
appeal.RegisterRoutes(r, db, config.JWTSecret)
|
||||||
|
|
||||||
|
// Регистрируем маршруты для удобств
|
||||||
|
amenity.RegisterRoutes(r, db, config.JWTSecret)
|
||||||
|
|
||||||
|
// Регистрируем маршруты для загрузки файлов
|
||||||
|
upload.RegisterRoutes(r, db, config.JWTSecret, config.UploadPath)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
zapLogger.Info("Настройка маршрутов завершена")
|
zapLogger.Info("Настройка маршрутов завершена")
|
||||||
@@ -106,9 +114,6 @@ func addProductionMiddleware(r *chi.Mux, config *config.Config) {
|
|||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Content-Type проверка
|
|
||||||
r.Use(ChiMiddleware.AllowContentType("application/json", "application/xml"))
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
if config.RateLimit.Enabled {
|
if config.RateLimit.Enabled {
|
||||||
r.Use(ChiMiddleware.Throttle(config.RateLimit.RequestsPerSecond))
|
r.Use(ChiMiddleware.Throttle(config.RateLimit.RequestsPerSecond))
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ func (m *MockObjectRepository) ListByStatus(isActive bool, offset, limit int) ([
|
|||||||
return result[start:end], nil
|
return result[start:end], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockObjectRepository) ListByObjectStatus(status string, offset, limit int) ([]models.Object, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var result []models.Object
|
||||||
|
for _, obj := range m.objects {
|
||||||
|
if string(obj.Status) == status {
|
||||||
|
result = append(result, *obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := min(offset, len(result))
|
||||||
|
end := min(start+limit, len(result))
|
||||||
|
return result[start:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockObjectRepository) Search(query string, offset, limit int) ([]models.Object, error) {
|
func (m *MockObjectRepository) Search(query string, offset, limit int) ([]models.Object, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
1. **easysite** – Nuxt.js приложение (easysite102.ru)
|
1. **easysite** – Nuxt.js приложение (easysite102.ru)
|
||||||
2. **yalarba** – Vue.js SPA приложение (yalarba.ru)
|
2. **yalarba** – Vue.js SPA приложение (yalarba.ru)
|
||||||
3. **api_tp** – REST API для YalArba (Go)
|
3. **api_tp** – REST API для YalArba (Go)
|
||||||
4. **api_es** – REST API для EasySite (Go)
|
4. **api_yal** – REST API для EasySite (Go)
|
||||||
5. **api_bb** – REST API для "Бегущий Башкир" (Go)
|
5. **api_bb** – REST API для "Бегущий Башкир" (Go)
|
||||||
6. **db, db_bb** – PostgreSQL базы данных
|
6. **db, db_bb** – PostgreSQL базы данных
|
||||||
7. **nginx** – Веб-сервер с reverse proxy и SSL
|
7. **nginx** – Веб-сервер с reverse proxy и SSL
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
Турист (yalarba.ru) → API_TP → БД (поиск, отзывы, маршруты)
|
Турист (yalarba.ru) → API_TP → БД (поиск, отзывы, маршруты)
|
||||||
Владелец (easysite102.ru) → API_ES → БД (добавление объектов, управление)
|
Владелец (easysite102.ru) → api_yal → БД (добавление объектов, управление)
|
||||||
Администратор → Nginx + аналитика (мониторинг, логи)
|
Администратор → Nginx + аналитика (мониторинг, логи)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,87 @@
|
|||||||
<!-- components/ObjectCard.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card cursor-pointer" @click="$emit('click')">
|
<div class="card cursor-pointer" @click="$emit('click')">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
:src="object.image"
|
:src="imageSrc"
|
||||||
:alt="object.title"
|
:alt="object.title"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
>
|
>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<span class="badge badge-primary">
|
<span class="badge" :class="statusBadgeClass">
|
||||||
{{ getTypeLabel(object.type) }}
|
{{ statusLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="text-lg font-semibold mb-2">{{ object.title }}</h3>
|
<h3 class="text-lg font-semibold mb-2">{{ object.title || object.short_name }}</h3>
|
||||||
<p class="text-gray-600 text-sm mb-3 line-clamp-2">
|
<p class="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||||
{{ object.description }}
|
{{ object.address || 'Адрес не указан' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-1">
|
<div class="flex items-center space-x-1">
|
||||||
<span class="text-yellow-500">⭐</span>
|
<span class="text-yellow-500">⭐</span>
|
||||||
<span class="text-sm font-medium">{{ object.rating }}</span>
|
<span class="text-sm font-medium">{{ averageScore }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-bold text-primary-600">
|
<div class="font-bold text-primary-600">
|
||||||
{{ formatPrice(object.price) }}
|
{{ formatPrice(object.price) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500">за ночь</div>
|
<div class="text-xs text-gray-500">{{ object.price_period || 'за единицу' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center text-sm text-gray-500">
|
<div class="mt-3 flex items-center text-sm text-gray-500">
|
||||||
<span class="mr-2">📍</span>
|
<span class="mr-2">📍</span>
|
||||||
<span>{{ object.city }}</span>
|
<span>{{ object.address || 'Адрес не указан' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface ObjectItem {
|
import type { ObjectShortResponse } from '~/types/objects'
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
city: string
|
|
||||||
price: number
|
|
||||||
rating: number
|
|
||||||
image: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
object: ObjectItem
|
object: ObjectShortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
defineEmits<{
|
defineEmits<{ click: [] }>()
|
||||||
click: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const imageSrc = computed(() => {
|
||||||
const types: Record<string, string> = {
|
return '/images/placeholder.jpg'
|
||||||
hotel: 'Отель',
|
})
|
||||||
sanatorium: 'Санаторий',
|
|
||||||
guest_house: 'Гостевой дом',
|
const averageScore = computed(() => {
|
||||||
tour: 'Тур',
|
return props.object.tourist_average_score || props.object.entrepreneur_average_score || '—'
|
||||||
excursion: 'Экскурсия'
|
})
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: 'Активен',
|
||||||
|
draft: 'Черновик',
|
||||||
|
moderation: 'На модерации',
|
||||||
|
inactive: 'Неактивен',
|
||||||
|
rejected: 'Отклонён'
|
||||||
}
|
}
|
||||||
return types[type] || type
|
return labels[props.object.status] || props.object.status
|
||||||
}
|
})
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
const statusBadgeClass = computed(() => {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
active: 'badge-success',
|
||||||
|
draft: 'badge-secondary',
|
||||||
|
moderation: 'badge-warning',
|
||||||
|
inactive: 'badge-secondary',
|
||||||
|
rejected: 'badge-error'
|
||||||
|
}
|
||||||
|
return classes[props.object.status] || 'badge-secondary'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPrice = (price: number | undefined) => {
|
||||||
|
if (!price && price !== 0) return '—'
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<!-- components/ObjectForm.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||||
<!-- Основная информация -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="text-lg font-semibold">Основная информация</h3>
|
<h3 class="text-lg font-semibold">Основная информация</h3>
|
||||||
@@ -11,12 +9,22 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Название объекта *</label>
|
<label class="form-label">Название объекта *</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.title"
|
v-model="formData.short_name"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
required
|
required
|
||||||
placeholder="Введите название">
|
placeholder="Короткое название">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Полное название</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.long_name"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Полное название (если отличается)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Тип объекта *</label>
|
<label class="form-label">Тип объекта *</label>
|
||||||
<select v-model="formData.type" class="form-select" required>
|
<select v-model="formData.type" class="form-select" required>
|
||||||
@@ -29,91 +37,95 @@
|
|||||||
<option value="restaurant">Ресторан</option>
|
<option value="restaurant">Ресторан</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Статус</label>
|
||||||
|
<select v-model="formData.status" class="form-select">
|
||||||
|
<option value="draft">Черновик</option>
|
||||||
|
<option value="moderation">Отправить на модерацию</option>
|
||||||
|
<option value="active">Активен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Описание *</label>
|
<label class="form-label">Описание</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="formData.description" class="form-input" rows="4" required
|
v-model="formData.description" class="form-input" rows="4"
|
||||||
placeholder="Подробное описание объекта"/>
|
placeholder="Подробное описание объекта"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Краткое описание</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.short_description" class="form-input" rows="2"
|
||||||
|
placeholder="Краткое описание для списка"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Местоположение -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="text-lg font-semibold">Местоположение</h3>
|
<h3 class="text-lg font-semibold">Местоположение</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label class="form-label">Адрес</label>
|
||||||
<label class="form-label">Город *</label>
|
<input
|
||||||
<input v-model="formData.city" type="text" class="form-input" required placeholder="Город">
|
v-model="formData.address" type="text" class="form-input"
|
||||||
</div>
|
placeholder="Полный адрес">
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Адрес *</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.address" type="text" class="form-input" required
|
|
||||||
placeholder="Полный адрес">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Цены и контакты -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="text-lg font-semibold">Цены и контакты</h3>
|
<h3 class="text-lg font-semibold">Цены и контакты</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Цена</label>
|
||||||
|
<input v-model="formData.price" type="number" class="form-input" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Период цены</label>
|
||||||
|
<select v-model="formData.price_period" class="form-select">
|
||||||
|
<option value="">Не указано</option>
|
||||||
|
<option value="per_night">За ночь</option>
|
||||||
|
<option value="per_person">За человека</option>
|
||||||
|
<option value="per_tour">За тур</option>
|
||||||
|
<option value="per_hour">За час</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Сайт</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.site" type="url" class="form-input"
|
||||||
|
placeholder="https://">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Цена за ночь/услугу *</label>
|
<label class="form-label">Телефон</label>
|
||||||
<input v-model="formData.price" type="number" class="form-input" required placeholder="0">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Телефон *</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="formData.contact.phone" type="tel" class="form-input" required
|
v-model="formData.phone" type="tel" class="form-input"
|
||||||
placeholder="+7 (XXX) XXX-XX-XX">
|
placeholder="+7 (XXX) XXX-XX-XX">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Email</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.contact.email" type="email" class="form-input"
|
|
||||||
placeholder="email@example.com">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Удобства -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="text-lg font-semibold">Удобства и услуги</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
||||||
<label v-for="amenity in availableAmenities" :key="amenity" class="flex items-center space-x-2">
|
|
||||||
<input
|
<input
|
||||||
v-model="formData.amenities" type="checkbox" :value="amenity"
|
v-model="formData.email" type="email" class="form-input"
|
||||||
class="rounded border-gray-300">
|
placeholder="email@example.com">
|
||||||
<span class="text-sm">{{ amenity }}</span>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Кнопки действий -->
|
|
||||||
<div class="flex gap-4 justify-end">
|
<div class="flex gap-4 justify-end">
|
||||||
<button type="button" class="btn btn-outline" :disabled="loading" @click="$emit('cancel')">
|
<button type="button" class="btn btn-outline" :disabled="loading" @click="$emit('cancel')">
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
<span v-if="loading">Сохранение...</span>
|
<span v-if="loading">Сохранение...</span>
|
||||||
<span v-else>{{ props.object ? 'Обновить' : 'Создать' }}</span>
|
<span v-else>{{ object ? 'Обновить' : 'Создать' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -121,18 +133,18 @@ v-model="formData.amenities" type="checkbox" :value="amenity"
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface ObjectFormData {
|
interface ObjectFormData {
|
||||||
title: string
|
short_name: string
|
||||||
|
long_name: string
|
||||||
type: string
|
type: string
|
||||||
description: string
|
description: string
|
||||||
city: string
|
short_description: string
|
||||||
address: string
|
address: string
|
||||||
price: number
|
price: number | null
|
||||||
images: string[]
|
price_period: string
|
||||||
amenities: string[]
|
phone: string
|
||||||
contact: {
|
email: string
|
||||||
phone: string
|
site: string
|
||||||
email: string
|
status: string
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -150,27 +162,21 @@ const emit = defineEmits<{
|
|||||||
cancel: []
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const availableAmenities = [
|
|
||||||
'Wi-Fi', 'Парковка', 'Бассейн', 'СПА', 'Завтрак',
|
|
||||||
'Кондиционер', 'Трансфер', 'Экскурсии', 'Баня', 'Ресторан'
|
|
||||||
]
|
|
||||||
|
|
||||||
const formData = reactive<ObjectFormData>({
|
const formData = reactive<ObjectFormData>({
|
||||||
title: '',
|
short_name: '',
|
||||||
|
long_name: '',
|
||||||
type: '',
|
type: '',
|
||||||
description: '',
|
description: '',
|
||||||
city: '',
|
short_description: '',
|
||||||
address: '',
|
address: '',
|
||||||
price: 0,
|
price: null,
|
||||||
images: [],
|
price_period: '',
|
||||||
amenities: [],
|
phone: '',
|
||||||
contact: {
|
email: '',
|
||||||
phone: '',
|
site: '',
|
||||||
email: ''
|
status: 'draft'
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Заполнение формы данными при редактировании
|
|
||||||
watch(() => props.object, (newObject) => {
|
watch(() => props.object, (newObject) => {
|
||||||
if (newObject) {
|
if (newObject) {
|
||||||
Object.assign(formData, newObject)
|
Object.assign(formData, newObject)
|
||||||
@@ -178,6 +184,11 @@ watch(() => props.object, (newObject) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit('submit', { ...formData })
|
const data = { ...formData }
|
||||||
|
if (data.price === null) {
|
||||||
|
delete (data as Record<string, unknown>).price
|
||||||
|
}
|
||||||
|
if (!data.price_period) delete (data as Record<string, unknown>).price_period
|
||||||
|
emit('submit', data)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
// composables/useAuth.ts
|
import type { UserInfo, LoginForm, RegisterForm, AuthResponse } from '~/types/auth'
|
||||||
import type { User, LoginForm, RegisterForm } from '~/types/auth'
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const user = useState<User | null>('user', () => null)
|
const config = useRuntimeConfig()
|
||||||
|
const apiBase = config.public.apiBase
|
||||||
|
|
||||||
|
const user = useState<UserInfo | null>('user', () => null)
|
||||||
const isAuthenticated = computed(() => !!user.value)
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const login = async (credentials: LoginForm) => {
|
const login = async (credentials: LoginForm) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<{ user: User; token: string }>(
|
const response = await $fetch<AuthResponse>(`${apiBase}/auth/login`, {
|
||||||
'https://easysite102.ru/api/auth/login',
|
method: 'POST',
|
||||||
{
|
body: credentials
|
||||||
method: 'POST',
|
})
|
||||||
body: credentials
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
user.value = response.user
|
user.value = response.user
|
||||||
// Сохраняем токен в localStorage или cookies
|
|
||||||
localStorage.setItem('auth_token', response.token)
|
localStorage.setItem('auth_token', response.token)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
// eslint-disable-next-line no-useless-catch
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
@@ -34,21 +31,16 @@ export const useAuth = () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { passwordConfirm, ...registerData } = userData
|
const { passwordConfirm, ...registerData } = userData
|
||||||
const full_name = `${userData.first_name} ${userData.last_name}`
|
|
||||||
|
|
||||||
const response = await $fetch<{ user: User }>(
|
const response = await $fetch<AuthResponse>(`${apiBase}/auth/register`, {
|
||||||
'https://easysite102.ru/api/auth/register',
|
method: 'POST',
|
||||||
{
|
body: registerData
|
||||||
method: 'POST',
|
})
|
||||||
body: {
|
|
||||||
...registerData,
|
user.value = response.user
|
||||||
full_name
|
localStorage.setItem('auth_token', response.token)
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
// eslint-disable-next-line no-useless-catch
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,9 +49,11 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
try {
|
try {
|
||||||
await $fetch('https://easysite102.ru/api/auth/logout', {
|
await $fetch(`${apiBase}/auth/logout`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
@@ -75,14 +69,9 @@ export const useAuth = () => {
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<{ user: User }>(
|
const response = await $fetch<{ user: UserInfo }>(`${apiBase}/auth/me`, {
|
||||||
'https://easysite102.ru/api/auth/me',
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
{
|
})
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
user.value = response.user
|
user.value = response.user
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check failed:', error)
|
console.error('Auth check failed:', error)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type {
|
||||||
|
ObjectShortResponse,
|
||||||
|
ObjectResponse,
|
||||||
|
ObjectListResponse,
|
||||||
|
CreateObjectRequest,
|
||||||
|
UpdateObjectRequest
|
||||||
|
} from '~/types/objects'
|
||||||
|
|
||||||
|
export const useObjects = () => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiBase = config.public.apiBase
|
||||||
|
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getList = async (params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
type?: string
|
||||||
|
q?: string
|
||||||
|
status?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}) => {
|
||||||
|
return $fetch<ObjectListResponse>(`${apiBase}/objects`, {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getById = async (id: number) => {
|
||||||
|
return $fetch<ObjectResponse>(`${apiBase}/objects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMy = async (params?: { page?: number; page_size?: number; status?: string }) => {
|
||||||
|
return $fetch<ObjectListResponse>(`${apiBase}/objects/my`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getByOwner = async (ownerId: number, params?: { page?: number; page_size?: number }) => {
|
||||||
|
return $fetch<ObjectListResponse>(`${apiBase}/objects/owner/${ownerId}`, {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = async (q: string, params?: { page?: number; page_size?: number }) => {
|
||||||
|
return $fetch<ObjectListResponse>(`${apiBase}/objects/search`, {
|
||||||
|
params: { q, ...params }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async (data: CreateObjectRequest) => {
|
||||||
|
return $fetch<ObjectResponse>(`${apiBase}/objects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async (id: number, data: UpdateObjectRequest) => {
|
||||||
|
return $fetch<ObjectResponse>(`${apiBase}/objects/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
return $fetch<void>(`${apiBase}/objects/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getList,
|
||||||
|
getById,
|
||||||
|
getMy,
|
||||||
|
getByOwner,
|
||||||
|
search,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<main class="edit-object-page">
|
<main class="edit-object-page">
|
||||||
<div class="container max-w-4xl">
|
<div class="container max-w-4xl">
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
@@ -16,13 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Загрузка -->
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="loading-spinner"/>
|
<div class="loading-spinner"/>
|
||||||
<p class="loading-text">Загрузка данных объекта...</p>
|
<p class="loading-text">Загрузка данных объекта...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма -->
|
|
||||||
<ObjectForm
|
<ObjectForm
|
||||||
v-else-if="object"
|
v-else-if="object"
|
||||||
:object="object"
|
:object="object"
|
||||||
@@ -30,7 +27,6 @@
|
|||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel" />
|
@cancel="handleCancel" />
|
||||||
|
|
||||||
<!-- Объект не найден -->
|
|
||||||
<div v-else class="error-state">
|
<div v-else class="error-state">
|
||||||
<div class="error-icon">❌</div>
|
<div class="error-icon">❌</div>
|
||||||
<h3 class="error-title">Объект не найден</h3>
|
<h3 class="error-title">Объект не найден</h3>
|
||||||
@@ -51,70 +47,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
interface ObjectData {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
description: string
|
|
||||||
city: string
|
|
||||||
address: string
|
|
||||||
price: number
|
|
||||||
images: string[]
|
|
||||||
amenities: string[]
|
|
||||||
contact: {
|
|
||||||
phone: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const object = ref<ObjectData | null>(null)
|
const { getById, update } = useObjects()
|
||||||
|
|
||||||
|
const object = ref<Record<string, unknown> | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const updating = ref(false)
|
const updating = ref(false)
|
||||||
|
|
||||||
// Мок-данные объекта
|
|
||||||
const mockObject: ObjectData = {
|
|
||||||
id: 1,
|
|
||||||
title: 'Гостевой дом "У озера"',
|
|
||||||
type: 'guest_house',
|
|
||||||
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты.',
|
|
||||||
city: 'Карелия',
|
|
||||||
address: 'ул. Озерная, 15',
|
|
||||||
price: 2800,
|
|
||||||
images: [
|
|
||||||
'/images/objects/lake-house-1.jpg',
|
|
||||||
'/images/objects/lake-house-2.jpg'
|
|
||||||
],
|
|
||||||
amenities: ['Wi-Fi', 'Парковка', 'Завтрак', 'Баня'],
|
|
||||||
contact: {
|
|
||||||
phone: '+7 (911) 123-45-67',
|
|
||||||
email: 'lakehouse@example.com'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Имитация загрузки данных
|
loading.value = true
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
try {
|
||||||
object.value = mockObject
|
const id = parseInt(route.params.id as string)
|
||||||
loading.value = false
|
const data = await getById(id)
|
||||||
|
object.value = {
|
||||||
|
short_name: data.short_name,
|
||||||
|
long_name: data.long_name,
|
||||||
|
type: data.type,
|
||||||
|
description: data.description,
|
||||||
|
short_description: data.short_description,
|
||||||
|
address: data.address,
|
||||||
|
price: data.price,
|
||||||
|
price_period: data.price_period,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
site: data.site,
|
||||||
|
status: data.status
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading object:', error)
|
||||||
|
object.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||||
const handleSubmit = async (formData: any) => {
|
|
||||||
updating.value = true
|
updating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Имитация обновления
|
const id = parseInt(route.params.id as string)
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await update(id, formData as Parameters<typeof update>[1])
|
||||||
|
await navigateTo(`/objects/${id}`)
|
||||||
console.log('Обновление объекта:', {
|
|
||||||
id: parseInt(route.params.id as string),
|
|
||||||
...formData
|
|
||||||
})
|
|
||||||
|
|
||||||
alert('Объект успешно обновлен!')
|
|
||||||
await navigateTo(`/objects/${route.params.id}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating object:', error)
|
console.error('Error updating object:', error)
|
||||||
alert('Ошибка при обновлении объекта')
|
alert('Ошибка при обновлении объекта')
|
||||||
@@ -124,8 +96,7 @@ const handleSubmit = async (formData: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
const objectId = route.params.id
|
navigateTo(`/objects/${route.params.id}`)
|
||||||
navigateTo(`/objects/${objectId}`)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -232,16 +203,10 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% { transform: rotate(0deg); }
|
||||||
transform: rotate(0deg);
|
100% { transform: rotate(360deg); }
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-main {
|
.header-main {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -257,19 +222,5 @@ const handleCancel = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-actions .btn {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.page-header {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-state {
|
|
||||||
padding: var(--space-xl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,397 +3,276 @@
|
|||||||
|
|
||||||
<main class="object-page">
|
<main class="object-page">
|
||||||
<div class="container max-w-6xl">
|
<div class="container max-w-6xl">
|
||||||
<!-- Хлебные крошки -->
|
|
||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs">
|
||||||
<NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink>
|
<NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink>
|
||||||
<span class="breadcrumb-separator">/</span>
|
<span class="breadcrumb-separator">/</span>
|
||||||
<span class="breadcrumb-current">{{ object?.title }}</span>
|
<span class="breadcrumb-current">{{ object?.title || object?.short_name }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Заголовок и действия -->
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="page-header">
|
<div class="loading-spinner"/>
|
||||||
<div class="header-content">
|
<p class="loading-text">Загрузка объекта...</p>
|
||||||
<div class="header-text">
|
|
||||||
<div class="object-meta">
|
|
||||||
<span class="object-type">{{ getTypeLabel(object?.type) }}</span>
|
|
||||||
<div class="rating">
|
|
||||||
<div class="rating-stars">
|
|
||||||
<span
|
|
||||||
v-for="star in 5"
|
|
||||||
:key="star"
|
|
||||||
class="rating-star"
|
|
||||||
:class="{ empty: star > Math.round(object?.rating || 0) }">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="rating-value">{{ object?.rating }}</span>
|
|
||||||
<span class="reviews-count">({{ object?.reviewsCount }} отзывов)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="page-title">{{ object?.title }}</h1>
|
|
||||||
<div class="object-location">
|
|
||||||
<span class="location-icon">📍</span>
|
|
||||||
{{ object?.city }}, {{ object?.address }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<div class="price-section">
|
|
||||||
<div class="price">{{ formatPrice(object?.price) }}</div>
|
|
||||||
<div class="price-period">за ночь</div>
|
|
||||||
</div>
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button class="btn btn-primary btn-large" @click="showBookingModal = true">
|
|
||||||
Забронировать
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline btn-with-icon">
|
|
||||||
<span>❤️</span>
|
|
||||||
В избранное
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Галерея изображений -->
|
<div v-else-if="!object" class="empty-state">
|
||||||
<div class="gallery-section">
|
<div class="empty-icon">🏢</div>
|
||||||
<div class="main-image">
|
<h3 class="empty-title">Объект не найден</h3>
|
||||||
<img :src="object?.images[0]" :alt="object?.title" class="gallery-image" @click="openGallery(0)" >
|
<p class="empty-description">Возможно, объект был удалён или у вас нет к нему доступа</p>
|
||||||
</div>
|
<NuxtLink to="/objects" class="btn btn-primary">Вернуться к списку</NuxtLink>
|
||||||
<div v-if="object?.images && object.images.length > 1" class="thumbnails">
|
|
||||||
<div
|
|
||||||
v-for="(image, index) in object.images.slice(1, 5)"
|
|
||||||
:key="index" class="thumbnail"
|
|
||||||
@click="openGallery(index + 1)">
|
|
||||||
<img :src="image" :alt="`${object.title} - фото ${index + 2}`">
|
|
||||||
<div v-if="index === 3 && object.images.length > 5" class="more-images">
|
|
||||||
+{{ object.images.length - 5 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<template v-else>
|
||||||
<div class="content-grid">
|
<div class="page-header">
|
||||||
<!-- Информация об объекте -->
|
<div class="header-content">
|
||||||
<div class="content-main">
|
<div class="header-text">
|
||||||
<!-- Описание -->
|
<div class="object-meta">
|
||||||
<section class="content-section">
|
<span class="object-type">{{ getTypeLabel(object.type) }}</span>
|
||||||
<h2 class="section-title">Описание</h2>
|
<div class="rating">
|
||||||
<p class="object-description">{{ object?.description }}</p>
|
<div class="rating-stars">
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Удобства -->
|
|
||||||
<section class="content-section">
|
|
||||||
<h2 class="section-title">Удобства</h2>
|
|
||||||
<div class="amenities-grid">
|
|
||||||
<div v-for="amenity in object?.amenities" :key="amenity" class="amenity-item">
|
|
||||||
<span class="amenity-icon">✅</span>
|
|
||||||
<span class="amenity-text">{{ amenity }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Контакты -->
|
|
||||||
<section class="content-section">
|
|
||||||
<h2 class="section-title">Контакты</h2>
|
|
||||||
<div class="contact-info">
|
|
||||||
<div class="contact-item">
|
|
||||||
<span class="contact-icon">📞</span>
|
|
||||||
<a :href="`tel:${object?.contact.phone}`" class="contact-link">
|
|
||||||
{{ object?.contact.phone }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="contact-item">
|
|
||||||
<span class="contact-icon">✉️</span>
|
|
||||||
<a :href="`mailto:${object?.contact.email}`" class="contact-link">
|
|
||||||
{{ object?.contact.email }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="contact-item">
|
|
||||||
<span class="contact-icon">📍</span>
|
|
||||||
<span class="contact-text">{{ object?.address }}, {{ object?.city }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Отзывы -->
|
|
||||||
<section v-if="reviews.length > 0" class="content-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">Отзывы</h2>
|
|
||||||
<div class="reviews-summary">
|
|
||||||
<div class="average-rating">{{ object?.rating }}</div>
|
|
||||||
<div class="reviews-stats">
|
|
||||||
<div class="rating-stars small">
|
|
||||||
<span
|
<span
|
||||||
v-for="star in 5"
|
v-for="star in 5"
|
||||||
:key="star"
|
:key="star"
|
||||||
class="rating-star"
|
class="rating-star"
|
||||||
:class="{ empty: star > Math.round(object?.rating || 0) }">
|
:class="{ empty: star > Math.round(touristScore) }">
|
||||||
★
|
★
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="reviews-count">{{ object?.reviewsCount }} отзывов</div>
|
<span class="rating-value">{{ touristScore }}</span>
|
||||||
|
<span class="reviews-count">({{ object.feedback_count }} отзывов)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h1 class="page-title">{{ object.title || object.short_name }}</h1>
|
||||||
|
<div class="object-location">
|
||||||
<div class="reviews-list">
|
<span class="location-icon">📍</span>
|
||||||
<div v-for="review in reviews" :key="review.id" class="review-card">
|
{{ object.address }}
|
||||||
<div class="review-header">
|
|
||||||
<div class="reviewer-info">
|
|
||||||
<div class="reviewer-avatar">
|
|
||||||
{{ review.author.name.charAt(0) }}
|
|
||||||
</div>
|
|
||||||
<div class="reviewer-details">
|
|
||||||
<div class="reviewer-name">{{ review.author.name }}</div>
|
|
||||||
<div class="review-date">{{ formatDate(review.date) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="review-rating">
|
|
||||||
<div class="rating-stars small">
|
|
||||||
<span
|
|
||||||
v-for="star in 5"
|
|
||||||
:key="star"
|
|
||||||
class="rating-star"
|
|
||||||
:class="{ empty: star > review.rating }">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="review-text">{{ review.text }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="header-actions">
|
||||||
</div>
|
<div class="price-section">
|
||||||
|
<div class="price">{{ formatPrice(object.price) }}</div>
|
||||||
<!-- Боковая панель -->
|
<div class="price-period">{{ pricePeriodLabel }}</div>
|
||||||
<div class="content-sidebar">
|
|
||||||
<!-- Блок бронирования -->
|
|
||||||
<div class="booking-card card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Бронирование</h3>
|
|
||||||
<div class="price-info">
|
|
||||||
<span class="price-large">{{ formatPrice(object?.price) }}</span>
|
|
||||||
<span class="price-period">за ночь</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="action-buttons">
|
||||||
<div class="card-body">
|
<button class="btn btn-primary btn-large" @click="showBookingModal = true">
|
||||||
<div class="booking-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Даты</label>
|
|
||||||
<div class="date-inputs">
|
|
||||||
<input v-model="bookingDates.checkIn" type="date" class="form-input" placeholder="Заезд">
|
|
||||||
<input v-model="bookingDates.checkOut" type="date" class="form-input" placeholder="Выезд">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Гости</label>
|
|
||||||
<select v-model="bookingGuests" class="form-select">
|
|
||||||
<option value="1">1 гость</option>
|
|
||||||
<option value="2">2 гостя</option>
|
|
||||||
<option value="3">3 гостя</option>
|
|
||||||
<option value="4">4 гостя</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-large"
|
|
||||||
:disabled="!bookingDates.checkIn || !bookingDates.checkOut"
|
|
||||||
@click="showBookingModal = true">
|
|
||||||
Забронировать
|
Забронировать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Контактная информация -->
|
<div class="gallery-section">
|
||||||
<div class="contact-card card">
|
<div class="main-image">
|
||||||
<div class="card-header">
|
<img :src="mainImage" :alt="object.title || object.short_name" class="gallery-image" @click="openGallery(0)">
|
||||||
<h3 class="card-title">Контактная информация</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="contact-actions">
|
|
||||||
<a :href="`tel:${object?.contact.phone}`" class="btn btn-outline btn-with-icon">
|
|
||||||
<span>📞</span>
|
|
||||||
Позвонить
|
|
||||||
</a>
|
|
||||||
<a :href="`mailto:${object?.contact.email}`" class="btn btn-outline btn-with-icon">
|
|
||||||
<span>✉️</span>
|
|
||||||
Написать
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="object.images && object.images.length > 1" class="thumbnails">
|
||||||
<!-- Действия владельца -->
|
<div
|
||||||
<div v-if="isOwner" class="owner-actions card">
|
v-for="(image, index) in object.images.slice(1, 5)"
|
||||||
<div class="card-header">
|
:key="image.id"
|
||||||
<h3 class="card-title">Управление объектом</h3>
|
class="thumbnail"
|
||||||
</div>
|
@click="openGallery(index + 1)">
|
||||||
<div class="card-body">
|
<img :src="image.url" :alt="`${object.title} - фото ${index + 2}`">
|
||||||
<div class="action-buttons">
|
<div v-if="index === 3 && object.images.length > 5" class="more-images">
|
||||||
<NuxtLink :to="`/objects/${object?.id}/edit`" class="btn btn-outline btn-with-icon">
|
+{{ object.images.length - 5 }}
|
||||||
<span>✏️</span>
|
|
||||||
Редактировать
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
class="btn btn-outline btn-with-icon"
|
|
||||||
:class="{ 'btn-primary': !object?.isActive }"
|
|
||||||
@click="toggleObjectStatus">
|
|
||||||
<span>{{ object?.isActive ? '⏸️' : '▶️' }}</span>
|
|
||||||
{{ object?.isActive ? 'Деактивировать' : 'Активировать' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="content-grid">
|
||||||
|
<div class="content-main">
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">Описание</h2>
|
||||||
|
<p class="object-description">{{ object.description || object.short_description }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="object.amenities && object.amenities.length" class="content-section">
|
||||||
|
<h2 class="section-title">Удобства</h2>
|
||||||
|
<div class="amenities-grid">
|
||||||
|
<div v-for="amenity in object.amenities" :key="amenity.id" class="amenity-item">
|
||||||
|
<span class="amenity-icon">{{ amenity.icon || '✅' }}</span>
|
||||||
|
<span class="amenity-text">{{ amenity.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">Контакты</h2>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div v-if="object.phone" class="contact-item">
|
||||||
|
<span class="contact-icon">📞</span>
|
||||||
|
<a :href="`tel:${object.phone}`" class="contact-link">{{ object.phone }}</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="object.email" class="contact-item">
|
||||||
|
<span class="contact-icon">✉️</span>
|
||||||
|
<a :href="`mailto:${object.email}`" class="contact-link">{{ object.email }}</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="object.site" class="contact-item">
|
||||||
|
<span class="contact-icon">🌐</span>
|
||||||
|
<a :href="object.site" target="_blank" class="contact-link">{{ object.site }}</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="object.address" class="contact-item">
|
||||||
|
<span class="contact-icon">📍</span>
|
||||||
|
<span class="contact-text">{{ object.address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-sidebar">
|
||||||
|
<div class="booking-card card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Бронирование</h3>
|
||||||
|
<div class="price-info">
|
||||||
|
<span class="price-large">{{ formatPrice(object.price) }}</span>
|
||||||
|
<span class="price-period">{{ pricePeriodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="booking-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Даты</label>
|
||||||
|
<div class="date-inputs">
|
||||||
|
<input v-model="bookingDates.checkIn" type="date" class="form-input" placeholder="Заезд">
|
||||||
|
<input v-model="bookingDates.checkOut" type="date" class="form-input" placeholder="Выезд">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Гости</label>
|
||||||
|
<select v-model="bookingGuests" class="form-select">
|
||||||
|
<option value="1">1 гость</option>
|
||||||
|
<option value="2">2 гостя</option>
|
||||||
|
<option value="3">3 гостя</option>
|
||||||
|
<option value="4">4 гостя</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-large"
|
||||||
|
:disabled="!bookingDates.checkIn || !bookingDates.checkOut"
|
||||||
|
@click="showBookingModal = true">
|
||||||
|
Забронировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-card card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Контактная информация</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="contact-actions">
|
||||||
|
<a v-if="object.phone" :href="`tel:${object.phone}`" class="btn btn-outline btn-with-icon">
|
||||||
|
<span>📞</span>
|
||||||
|
Позвонить
|
||||||
|
</a>
|
||||||
|
<a v-if="object.email" :href="`mailto:${object.email}`" class="btn btn-outline btn-with-icon">
|
||||||
|
<span>✉️</span>
|
||||||
|
Написать
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isOwner" class="owner-actions card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Управление объектом</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<NuxtLink :to="`/objects/${object.id}/edit`" class="btn btn-outline btn-with-icon">
|
||||||
|
<span>✏️</span>
|
||||||
|
Редактировать
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-with-icon"
|
||||||
|
:class="{ 'btn-primary': !object.is_active }"
|
||||||
|
@click="toggleObjectStatus">
|
||||||
|
<span>{{ object.is_active ? '⏸️' : '▶️' }}</span>
|
||||||
|
{{ object.is_active ? 'Деактивировать' : 'Активировать' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-with-icon delete-btn"
|
||||||
|
@click="deleteObject">
|
||||||
|
<span>🗑️</span>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<!-- Модальное окно бронирования -->
|
|
||||||
<BookingModal
|
|
||||||
v-if="showBookingModal"
|
|
||||||
:object="object"
|
|
||||||
:dates="bookingDates"
|
|
||||||
:guests="bookingGuests"
|
|
||||||
@close="showBookingModal = false"
|
|
||||||
@confirm="handleBooking" />
|
|
||||||
|
|
||||||
<!-- Галерея изображений -->
|
|
||||||
<ImageGallery
|
|
||||||
v-if="showGallery"
|
|
||||||
:images="object?.images || []"
|
|
||||||
:initial-index="galleryIndex"
|
|
||||||
@close="showGallery = false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ObjectResponse } from '~/types/objects'
|
||||||
|
|
||||||
interface ObjectData {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
description: string
|
|
||||||
city: string
|
|
||||||
address: string
|
|
||||||
price: number
|
|
||||||
rating: number
|
|
||||||
reviewsCount: number
|
|
||||||
images: string[]
|
|
||||||
amenities: string[]
|
|
||||||
contact: {
|
|
||||||
phone: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
isActive: boolean
|
|
||||||
ownerId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Review {
|
|
||||||
id: number
|
|
||||||
author: {
|
|
||||||
name: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
rating: number
|
|
||||||
text: string
|
|
||||||
date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route и состояние
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const object = ref<ObjectData | null>(null)
|
const { getById, remove } = useObjects()
|
||||||
const reviews = ref<Review[]>([])
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const object = ref<ObjectResponse | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const showBookingModal = ref(false)
|
const showBookingModal = ref(false)
|
||||||
const showGallery = ref(false)
|
const showGallery = ref(false)
|
||||||
const galleryIndex = ref(0)
|
const galleryIndex = ref(0)
|
||||||
const isOwner = ref(false)
|
|
||||||
|
|
||||||
// Данные бронирования
|
const bookingDates = ref({ checkIn: '', checkOut: '' })
|
||||||
const bookingDates = ref({
|
|
||||||
checkIn: '',
|
|
||||||
checkOut: ''
|
|
||||||
})
|
|
||||||
const bookingGuests = ref('2')
|
const bookingGuests = ref('2')
|
||||||
|
|
||||||
// Мок-данные объекта
|
const touristScore = computed(() => {
|
||||||
const mockObject: ObjectData = {
|
return object.value?.feedback_count || 0
|
||||||
id: parseInt(route.params.id as string),
|
})
|
||||||
title: 'Гостевой дом "У озера"',
|
|
||||||
type: 'guest_house',
|
|
||||||
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты. Предлагаем комфортабельные номера с видом на озеро, домашнюю кухню и множество развлечений на природе.',
|
|
||||||
city: 'Карелия',
|
|
||||||
address: 'ул. Озерная, 15',
|
|
||||||
price: 2800,
|
|
||||||
rating: 4.8,
|
|
||||||
reviewsCount: 24,
|
|
||||||
images: [
|
|
||||||
'/images/objects/lake-house-1.jpg',
|
|
||||||
'/images/objects/lake-house-2.jpg',
|
|
||||||
'/images/objects/lake-house-3.jpg',
|
|
||||||
'/images/objects/lake-house-4.jpg',
|
|
||||||
'/images/objects/lake-house-5.jpg'
|
|
||||||
],
|
|
||||||
amenities: ['Wi-Fi', 'Парковка', 'Завтрак', 'Баня', 'Прокат велосипедов', 'Рыбалка', 'Камин'],
|
|
||||||
contact: {
|
|
||||||
phone: '+7 (911) 123-45-67',
|
|
||||||
email: 'lakehouse@example.com'
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
ownerId: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мок-отзывы
|
const mainImage = computed(() => {
|
||||||
const mockReviews: Review[] = [
|
if (object.value?.images?.length) {
|
||||||
{
|
return object.value.images[0].url
|
||||||
id: 1,
|
|
||||||
author: { name: 'Анна Петрова' },
|
|
||||||
rating: 5,
|
|
||||||
text: 'Прекрасное место для отдыха! Очень уютные номера, вкусные завтраки и великолепный вид на озеро. Обязательно вернемся снова.',
|
|
||||||
date: '2024-01-15'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
author: { name: 'Иван Сидоров' },
|
|
||||||
rating: 4,
|
|
||||||
text: 'Отличный гостевой дом, все понравилось. Особенно порадовала баня и рыбалка. Персонал очень внимательный и доброжелательный.',
|
|
||||||
date: '2024-01-10'
|
|
||||||
}
|
}
|
||||||
]
|
return '/images/placeholder.jpg'
|
||||||
|
})
|
||||||
|
|
||||||
|
const pricePeriodLabel = computed(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
per_night: 'за ночь',
|
||||||
|
per_person: 'за человека',
|
||||||
|
per_tour: 'за тур',
|
||||||
|
per_hour: 'за час'
|
||||||
|
}
|
||||||
|
const p = object.value?.price_period
|
||||||
|
return p ? labels[p] || p : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOwner = computed(() => {
|
||||||
|
if (!user.value || !object.value) return false
|
||||||
|
return user.value.id === object.value.owner_id
|
||||||
|
})
|
||||||
|
|
||||||
// Загрузка данных
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Имитация загрузки данных
|
const id = parseInt(route.params.id as string)
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
object.value = await getById(id)
|
||||||
object.value = mockObject
|
|
||||||
reviews.value = mockReviews
|
|
||||||
|
|
||||||
// Проверка владельца (в реальном приложении - по ID пользователя)
|
|
||||||
isOwner.value = object.value.ownerId === 1
|
|
||||||
|
|
||||||
// SEO
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: `${object.value.title} - EasySite`,
|
title: `${object.value.title || object.value.short_name} - EasySite`,
|
||||||
description: object.value.description,
|
description: object.value.description || object.value.short_description,
|
||||||
ogTitle: object.value.title,
|
ogTitle: object.value.title || object.value.short_name,
|
||||||
ogDescription: object.value.description,
|
ogDescription: object.value.description || object.value.short_description,
|
||||||
ogImage: object.value.images[0]
|
ogImage: mainImage.value
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading object:', error)
|
console.error('Error loading object:', error)
|
||||||
showError({ statusCode: 404, statusMessage: 'Объект не найден' })
|
object.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Методы
|
|
||||||
const getTypeLabel = (type: string | undefined) => {
|
const getTypeLabel = (type: string | undefined) => {
|
||||||
const types: Record<string, string> = {
|
const types: Record<string, string> = {
|
||||||
hotel: '🏨 Гостиница',
|
hotel: '🏨 Гостиница',
|
||||||
@@ -402,11 +281,11 @@ const getTypeLabel = (type: string | undefined) => {
|
|||||||
tour: '🧳 Тур',
|
tour: '🧳 Тур',
|
||||||
restaurant: '🍴 Ресторан'
|
restaurant: '🍴 Ресторан'
|
||||||
}
|
}
|
||||||
return types[type || ''] || type
|
return types[type || ''] || type || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPrice = (price: number | undefined) => {
|
const formatPrice = (price: number | undefined) => {
|
||||||
if (!price) return '0 ₽'
|
if (!price && price !== 0) return '—'
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -414,14 +293,6 @@ const formatPrice = (price: number | undefined) => {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openGallery = (index: number) => {
|
const openGallery = (index: number) => {
|
||||||
galleryIndex.value = index
|
galleryIndex.value = index
|
||||||
showGallery.value = true
|
showGallery.value = true
|
||||||
@@ -429,15 +300,24 @@ const openGallery = (index: number) => {
|
|||||||
|
|
||||||
const toggleObjectStatus = async () => {
|
const toggleObjectStatus = async () => {
|
||||||
if (!object.value) return
|
if (!object.value) return
|
||||||
|
// TODO: Implement status toggle via update API
|
||||||
object.value.isActive = !object.value.isActive
|
object.value.is_active = !object.value.is_active
|
||||||
// В реальном приложении здесь был бы API-запрос
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBooking = (bookingData: unknown) => {
|
const deleteObject = async () => {
|
||||||
console.log('Booking confirmed:', bookingData)
|
if (!object.value) return
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этот объект?')) return
|
||||||
|
try {
|
||||||
|
await remove(object.value.id)
|
||||||
|
await navigateTo('/objects/my-objects')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting object:', error)
|
||||||
|
alert('Ошибка при удалении объекта')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBooking = () => {
|
||||||
showBookingModal.value = false
|
showBookingModal.value = false
|
||||||
// Здесь обработка бронирования
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -535,10 +415,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
color: var(--gray-300);
|
color: var(--gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating-stars.small .rating-star {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rating-value {
|
.rating-value {
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -612,7 +488,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Галерея */
|
|
||||||
.gallery-section {
|
.gallery-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
@@ -680,7 +555,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Основной контент */
|
|
||||||
.content-grid {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
@@ -710,32 +584,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviews-summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.average-rating {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviews-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-description {
|
.object-description {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
@@ -799,74 +647,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reviews-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-card {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviewer-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviewer-avatar {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
background: var(--primary-500);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviewer-details {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reviewer-name {
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-date {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-rating {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-text {
|
|
||||||
line-height: var(--leading-relaxed);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Боковая панель */
|
|
||||||
.content-sidebar {
|
.content-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -908,13 +688,46 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-actions .action-buttons {
|
.delete-btn {
|
||||||
display: flex;
|
color: var(--danger-500);
|
||||||
flex-direction: column;
|
border-color: var(--danger-200);
|
||||||
gap: var(--space-sm);
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: var(--danger-50);
|
||||||
|
border-color: var(--danger-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top: 3px solid var(--primary-500);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.content-grid {
|
.content-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -958,16 +771,6 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.amenities-grid {
|
.amenities-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -984,23 +787,14 @@ const handleBooking = (bookingData: unknown) => {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-section {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnails {
|
.thumbnails {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-inputs {
|
.date-inputs {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<main class="create-object-page">
|
<main class="create-object-page">
|
||||||
<div class="container max-w-4xl">
|
<div class="container max-w-4xl">
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Форма -->
|
|
||||||
<ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" />
|
<ObjectForm :loading="loading" @submit="handleSubmit" @cancel="handleCancel" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -24,28 +22,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const { create } = useObjects()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||||
const handleSubmit = async (formData: any) => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Имитация создания объекта
|
await create(formData as Parameters<typeof create>[0])
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
navigateTo('/objects/my-objects')
|
||||||
|
|
||||||
console.log('Создание объекта:', {
|
|
||||||
...formData,
|
|
||||||
userId: 1,
|
|
||||||
isActive: true,
|
|
||||||
images: formData.images || ['/images/placeholder.jpg'],
|
|
||||||
amenities: formData.amenities || []
|
|
||||||
})
|
|
||||||
|
|
||||||
// Показываем уведомление об успехе
|
|
||||||
alert('Объект успешно создан!')
|
|
||||||
await navigateTo('/objects/my-objects')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating object:', error)
|
console.error('Error creating object:', error)
|
||||||
alert('Ошибка при создании объекта')
|
alert('Ошибка при создании объекта')
|
||||||
@@ -79,11 +63,13 @@ const handleCancel = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text {
|
.header-text {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -91,34 +77,18 @@ const handleCancel = () => {
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: var(--space-xs);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content {
|
.header-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content .btn {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.page-header {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,12 +3,11 @@
|
|||||||
|
|
||||||
<main class="objects-page">
|
<main class="objects-page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Заголовок и действия -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h1 class="page-title">Все объекты</h1>
|
<h1 class="page-title">Все объекты</h1>
|
||||||
<p class="page-subtitle">Найдено {{ filteredObjects.length }} объектов</p>
|
<p class="page-subtitle">Найдено {{ totalObjects }} объектов</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-outline btn-with-icon" @click="showFilters = !showFilters">
|
<button class="btn btn-outline btn-with-icon" @click="showFilters = !showFilters">
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Быстрые фильтры -->
|
|
||||||
<div class="quick-filters">
|
<div class="quick-filters">
|
||||||
<button
|
<button
|
||||||
v-for="type in quickTypes"
|
v-for="type in quickTypes"
|
||||||
@@ -38,7 +36,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Расширенные фильтры -->
|
|
||||||
<div v-if="showFilters" class="search-filters card">
|
<div v-if="showFilters" class="search-filters card">
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -52,46 +49,27 @@
|
|||||||
<option value="guest_house">🏡 Гостевой дом</option>
|
<option value="guest_house">🏡 Гостевой дом</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Город</label>
|
<label class="form-label">Поиск</label>
|
||||||
<input v-model="filters.city" type="text" class="form-input" placeholder="Введите город">
|
<input v-model="filters.q" type="text" class="form-input" placeholder="Название или адрес">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Цена до</label>
|
<label class="form-label">Цена до</label>
|
||||||
<input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена">
|
<input v-model="filters.maxPrice" type="number" class="form-input" placeholder="Макс. цена">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Рейтинг от</label>
|
|
||||||
<select v-model="filters.minRating" class="form-select">
|
|
||||||
<option value="0">Любой рейтинг</option>
|
|
||||||
<option value="4">⭐ 4.0+</option>
|
|
||||||
<option value="4.5">⭐ 4.5+</option>
|
|
||||||
<option value="4.8">⭐ 4.8+</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button class="btn btn-primary" @click="applyFilters">
|
<button class="btn btn-primary" @click="applyFilters">Применить фильтры</button>
|
||||||
Применить фильтры
|
<button class="btn btn-outline" @click="resetFilters">Сбросить</button>
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline" @click="resetFilters">
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Управление видом -->
|
|
||||||
<div class="view-controls">
|
<div class="view-controls">
|
||||||
<div class="sort-controls">
|
<div class="sort-controls">
|
||||||
<select v-model="sortBy" class="form-select">
|
<select v-model="sortBy" class="form-select">
|
||||||
<option value="title">По названию</option>
|
<option value="title">По названию</option>
|
||||||
<option value="price">По цене</option>
|
<option value="price">По цене</option>
|
||||||
<option value="rating">По рейтингу</option>
|
<option value="rating">По рейтингу</option>
|
||||||
<option value="city">По городу</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline btn-sm" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
|
<button class="btn btn-outline btn-sm" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
|
||||||
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
@@ -112,22 +90,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Результаты -->
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="loading-spinner"/>
|
<div class="loading-spinner"/>
|
||||||
<p class="loading-text">Загрузка объектов...</p>
|
<p class="loading-text">Загрузка объектов...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredObjects.length === 0" class="empty-state">
|
<div v-else-if="objects.length === 0" class="empty-state">
|
||||||
<div class="empty-icon">🏢</div>
|
<div class="empty-icon">🏢</div>
|
||||||
<h3 class="empty-title">Объекты не найдены</h3>
|
<h3 class="empty-title">Объекты не найдены</h3>
|
||||||
<p class="empty-description">Попробуйте изменить параметры поиска</p>
|
<p class="empty-description">Попробуйте изменить параметры поиска</p>
|
||||||
<button class="btn btn-primary" @click="resetFilters">
|
<button class="btn btn-primary" @click="resetFilters">Сбросить фильтры</button>
|
||||||
Сбросить фильтры
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Сетка объектов -->
|
|
||||||
<div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'">
|
<div v-else class="objects-grid" :class="viewMode === 'grid' ? 'grid-view' : 'list-view'">
|
||||||
<ObjectCard
|
<ObjectCard
|
||||||
v-for="object in paginatedObjects"
|
v-for="object in paginatedObjects"
|
||||||
@@ -137,14 +111,13 @@
|
|||||||
@click="navigateToObject(object.id)" />
|
@click="navigateToObject(object.id)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Пагинация -->
|
<div v-if="!loading && objects.length > 0" class="pagination">
|
||||||
<div v-if="!loading && filteredObjects.length > 0" class="pagination">
|
|
||||||
<button
|
<button
|
||||||
v-for="page in totalPages"
|
v-for="page in totalPages"
|
||||||
:key="page"
|
:key="page"
|
||||||
class="pagination-btn"
|
class="pagination-btn"
|
||||||
:class="{ active: currentPage === page }"
|
:class="{ active: currentPage === page }"
|
||||||
@click="currentPage = page" >
|
@click="currentPage = page">
|
||||||
{{ page }}
|
{{ page }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,44 +128,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ObjectShortResponse } from '~/types/objects'
|
||||||
|
|
||||||
interface ObjectItem {
|
useHead({ title: 'Все объекты - EasySite' })
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
city: string
|
|
||||||
price: number
|
|
||||||
rating: number
|
|
||||||
image: string
|
|
||||||
description: string
|
|
||||||
isActive: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Навигация
|
const { getList } = useObjects()
|
||||||
useHead({
|
|
||||||
title: 'Все объекты - EasySite'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Состояние
|
const objects = ref<ObjectShortResponse[]>([])
|
||||||
const objects = ref<ObjectItem[]>([])
|
const totalObjects = ref(0)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const showFilters = ref(false)
|
const showFilters = ref(false)
|
||||||
const viewMode = ref<'grid' | 'list'>('grid')
|
const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
const sortBy = ref<'title' | 'price' | 'rating' | 'city'>('title')
|
const sortBy = ref<'title' | 'price' | 'rating'>('title')
|
||||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const itemsPerPage = 9
|
const itemsPerPage = 9
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
search: '',
|
q: '',
|
||||||
type: '',
|
type: '',
|
||||||
city: '',
|
maxPrice: null as number | null
|
||||||
maxPrice: null as number | null,
|
|
||||||
minRating: 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Быстрые фильтры
|
|
||||||
const quickTypes = [
|
const quickTypes = [
|
||||||
{ value: 'hotel', label: 'Гостиницы', icon: '🏨' },
|
{ value: 'hotel', label: 'Гостиницы', icon: '🏨' },
|
||||||
{ value: 'sanatorium', label: 'Санатории', icon: '🏥' },
|
{ value: 'sanatorium', label: 'Санатории', icon: '🏥' },
|
||||||
@@ -200,107 +157,28 @@ const quickTypes = [
|
|||||||
{ value: 'restaurant', label: 'Рестораны', icon: '🍴' }
|
{ value: 'restaurant', label: 'Рестораны', icon: '🍴' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Мок-данные
|
|
||||||
const mockObjects: ObjectItem[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Горный отель "Эдельвейс"',
|
|
||||||
type: 'hotel',
|
|
||||||
city: 'Сочи',
|
|
||||||
price: 4500,
|
|
||||||
rating: 4.8,
|
|
||||||
image: '/images/hotels/edelweiss.jpg',
|
|
||||||
description: 'Комфортабельный отель в горах с видом на море. Идеальное место для отдыха от городской суеты.',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-15'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Санаторий "Здоровье"',
|
|
||||||
type: 'sanatorium',
|
|
||||||
city: 'Кисловодск',
|
|
||||||
price: 3200,
|
|
||||||
rating: 4.6,
|
|
||||||
image: '/images/sanatoriums/health.jpg',
|
|
||||||
description: 'Лечебно-оздоровительный комплекс с минеральными водами и современным оборудованием.',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-10'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Тур по Золотому кольцу',
|
|
||||||
type: 'tour',
|
|
||||||
city: 'Москва',
|
|
||||||
price: 12500,
|
|
||||||
rating: 4.9,
|
|
||||||
image: '/images/tours/golden-ring.jpg',
|
|
||||||
description: '7-дневный тур по древним городам России с опытным гидом и комфортабельным транспортом.',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-08'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Вычисляемые свойства
|
|
||||||
const activeFiltersCount = computed(() => {
|
const activeFiltersCount = computed(() => {
|
||||||
return Object.values(filters.value).filter(val =>
|
return Object.values(filters.value).filter(val =>
|
||||||
val !== '' && val !== null && val !== 0
|
val !== '' && val !== null
|
||||||
).length
|
).length
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredObjects = computed(() => {
|
|
||||||
let filtered = [...objects.value]
|
|
||||||
|
|
||||||
if (filters.value.search) {
|
|
||||||
const search = filters.value.search.toLowerCase()
|
|
||||||
filtered = filtered.filter(obj =>
|
|
||||||
obj.title.toLowerCase().includes(search) ||
|
|
||||||
obj.city.toLowerCase().includes(search) ||
|
|
||||||
obj.description.toLowerCase().includes(search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.type) {
|
|
||||||
filtered = filtered.filter(obj => obj.type === filters.value.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.city) {
|
|
||||||
filtered = filtered.filter(obj =>
|
|
||||||
obj.city.toLowerCase().includes(filters.value.city.toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.maxPrice) {
|
|
||||||
filtered = filtered.filter(obj => obj.price <= filters.value.maxPrice!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.minRating) {
|
|
||||||
filtered = filtered.filter(obj => obj.rating >= filters.value.minRating!)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
})
|
|
||||||
|
|
||||||
const sortedObjects = computed(() => {
|
const sortedObjects = computed(() => {
|
||||||
const sorted = [...filteredObjects.value].sort((a, b) => {
|
const sorted = [...objects.value].sort((a, b) => {
|
||||||
let aVal = a[sortBy.value]
|
if (sortBy.value === 'price') {
|
||||||
let bVal = b[sortBy.value]
|
const aVal = a.price || 0
|
||||||
|
const bVal = b.price || 0
|
||||||
if (sortBy.value === 'price' || sortBy.value === 'rating') {
|
return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
|
||||||
const aNum = Number(aVal)
|
|
||||||
const bNum = Number(bVal)
|
|
||||||
return sortOrder.value === 'asc' ? aNum - bNum : bNum - aNum
|
|
||||||
}
|
}
|
||||||
|
if (sortBy.value === 'rating') {
|
||||||
aVal = String(aVal).toLowerCase()
|
const aVal = a.tourist_average_score || a.entrepreneur_average_score || 0
|
||||||
bVal = String(bVal).toLowerCase()
|
const bVal = b.tourist_average_score || b.entrepreneur_average_score || 0
|
||||||
|
return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal
|
||||||
if (sortOrder.value === 'asc') {
|
|
||||||
return aVal.localeCompare(bVal)
|
|
||||||
} else {
|
|
||||||
return bVal.localeCompare(aVal)
|
|
||||||
}
|
}
|
||||||
|
const aVal = (a.title || a.short_name || '').toLowerCase()
|
||||||
|
const bVal = (b.title || b.short_name || '').toLowerCase()
|
||||||
|
return sortOrder.value === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
|
||||||
})
|
})
|
||||||
|
|
||||||
return sorted
|
return sorted
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -310,10 +188,9 @@ const paginatedObjects = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
return Math.ceil(filteredObjects.value.length / itemsPerPage)
|
return Math.ceil(sortedObjects.value.length / itemsPerPage)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Методы
|
|
||||||
const toggleQuickFilter = (type: string) => {
|
const toggleQuickFilter = (type: string) => {
|
||||||
filters.value.type = filters.value.type === type ? '' : type
|
filters.value.type = filters.value.type === type ? '' : type
|
||||||
applyFilters()
|
applyFilters()
|
||||||
@@ -321,35 +198,44 @@ const toggleQuickFilter = (type: string) => {
|
|||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
loadObjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
filters.value = {
|
filters.value = { q: '', type: '', maxPrice: null }
|
||||||
search: '',
|
|
||||||
type: '',
|
|
||||||
city: '',
|
|
||||||
maxPrice: null,
|
|
||||||
minRating: 0
|
|
||||||
}
|
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
loadObjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateToObject = (id: number) => {
|
const navigateToObject = (id: number) => {
|
||||||
navigateTo(`/objects/${id}`)
|
navigateTo(`/objects/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация
|
const loadObjects = async () => {
|
||||||
onMounted(async () => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
try {
|
||||||
objects.value = mockObjects
|
const response = await getList({
|
||||||
loading.value = false
|
page: currentPage.value,
|
||||||
})
|
page_size: 50,
|
||||||
|
type: filters.value.type || undefined,
|
||||||
|
q: filters.value.q || undefined
|
||||||
|
})
|
||||||
|
let items = response.items
|
||||||
|
if (filters.value.maxPrice) {
|
||||||
|
items = items.filter(o => (o.price || 0) <= filters.value.maxPrice!)
|
||||||
|
}
|
||||||
|
objects.value = items
|
||||||
|
totalObjects.value = response.total
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading objects:', error)
|
||||||
|
objects.value = []
|
||||||
|
totalObjects.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Следим за изменениями фильтров
|
onMounted(loadObjects)
|
||||||
watch([filters, sortBy, sortOrder], () => {
|
|
||||||
applyFilters()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -372,12 +258,14 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-xl);
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text {
|
.header-text {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -385,12 +273,13 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: var(--space-xs);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -401,37 +290,36 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
|
|
||||||
.quick-filters {
|
.quick-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-filter {
|
.quick-filter {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-xs) var(--space-md);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-full);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-medium);
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-filter:hover {
|
.quick-filter:hover,
|
||||||
border-color: var(--primary-300);
|
|
||||||
background: var(--primary-50);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-filter.active {
|
.quick-filter.active {
|
||||||
background: var(--primary-500);
|
background: var(--primary-500);
|
||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
border-color: var(--primary-500);
|
border-color: var(--primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-filters {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
@@ -439,7 +327,6 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
.filter-actions {
|
.filter-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
.view-controls {
|
||||||
@@ -447,16 +334,12 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-controls {
|
.sort-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
@@ -464,21 +347,27 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.objects-grid.grid-view {
|
.objects-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.objects-grid.grid-view {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.objects-grid.list-view {
|
.objects-grid.list-view {
|
||||||
display: flex;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state {
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-2xl);
|
padding: var(--space-2xl);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@@ -496,18 +385,10 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-2xl);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
opacity: 0.5;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
@@ -527,97 +408,44 @@ watch([filters, sortBy, sortOrder], () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
margin-top: var(--space-xl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-btn {
|
.pagination-btn {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-xs) var(--space-md);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-weight: var(--font-medium);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-btn.active,
|
||||||
.pagination-btn:hover {
|
.pagination-btn:hover {
|
||||||
border-color: var(--primary-300);
|
|
||||||
background: var(--primary-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn.active {
|
|
||||||
background: var(--primary-500);
|
background: var(--primary-500);
|
||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
border-color: var(--primary-500);
|
border-color: var(--primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-pointer:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% { transform: rotate(0deg); }
|
||||||
transform: rotate(0deg);
|
100% { transform: rotate(360deg); }
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content {
|
.header-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
justify-content: stretch;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .btn {
|
|
||||||
flex: 1;
|
|
||||||
min-width: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-md);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-controls {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.objects-grid.grid-view {
|
.objects-grid.grid-view {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.page-header {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-filters {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-filter {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<main class="my-objects-page">
|
<main class="my-objects-page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Фильтры -->
|
|
||||||
<div class="search-filters card">
|
<div class="search-filters card">
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -31,7 +29,10 @@
|
|||||||
<select v-model="filters.status" class="form-select">
|
<select v-model="filters.status" class="form-select">
|
||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
<option value="active">Активные</option>
|
<option value="active">Активные</option>
|
||||||
|
<option value="draft">Черновики</option>
|
||||||
|
<option value="moderation">На модерации</option>
|
||||||
<option value="inactive">Неактивные</option>
|
<option value="inactive">Неактивные</option>
|
||||||
|
<option value="rejected">Отклонённые</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -46,96 +47,77 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Поиск</label>
|
<label class="form-label">Поиск</label>
|
||||||
<input v-model="filters.search" type="text" class="form-input" placeholder="Название или город">
|
<input v-model="filters.q" type="text" class="form-input" placeholder="Название или адрес">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button class="btn btn-primary" @click="applyFilters">
|
<button class="btn btn-primary" @click="applyFilters">Применить</button>
|
||||||
Применить
|
<button class="btn btn-outline" @click="resetFilters">Сбросить</button>
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline" @click="resetFilters">
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Сетка карточек -->
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="objects-grid">
|
<div class="loading-spinner"/>
|
||||||
<!-- Карточка добавления нового объекта -->
|
<p class="loading-text">Загрузка объектов...</p>
|
||||||
<div class="add-card" @click="navigateToCreate">
|
</div>
|
||||||
<div class="add-card-content">
|
|
||||||
<div class="add-icon">➕</div>
|
|
||||||
<h3 class="add-title">Добавить объект</h3>
|
|
||||||
<p class="add-description">Создайте новое место для размещения</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Карточки объектов -->
|
<template v-else>
|
||||||
<div v-for="object in myObjects" :key="object.id" class="object-card">
|
<div class="objects-grid">
|
||||||
<div class="card-image">
|
<div class="add-card" @click="navigateTo('/objects/create')">
|
||||||
<img :src="object.image || '/images/placeholder.jpg'" :alt="object.title" >
|
<div class="add-card-content">
|
||||||
<div class="card-badge" :class="object.isActive ? 'badge-success' : 'badge-secondary'">
|
<div class="add-icon">➕</div>
|
||||||
{{ object.isActive ? 'Активен' : 'Неактивен' }}
|
<h3 class="add-title">Добавить объект</h3>
|
||||||
|
<p class="add-description">Создайте новое место для размещения</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div v-for="item in filteredObjects" :key="item.id" class="object-card">
|
||||||
<h3 class="card-title">{{ object.title }}</h3>
|
<div class="card-image">
|
||||||
<div class="card-meta">
|
<img :src="'/images/placeholder.jpg'" :alt="item.title || item.short_name">
|
||||||
<span class="card-type">{{ getTypeLabel(object.type) }}</span>
|
<div class="card-badge" :class="statusBadgeClass(item.status)">
|
||||||
<span class="card-location">📍 {{ object.city }}</span>
|
{{ statusLabel(item.status) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-description">{{ truncateDescription(object.description) }}</p>
|
|
||||||
<div class="card-price">{{ formatPrice(object.price) }}</div>
|
|
||||||
<div class="card-date">Добавлен: {{ formatDate(object.createdAt) }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-content">
|
||||||
<!-- Кнопка просмотра объекта -->
|
<h3 class="card-title">{{ item.title || item.short_name }}</h3>
|
||||||
<NuxtLink
|
<div class="card-meta">
|
||||||
:to="`/objects/${object.id}`"
|
<span class="card-type">{{ getTypeLabel(item.type) }}</span>
|
||||||
class="btn btn-outline btn-sm btn-with-icon view-btn"
|
</div>
|
||||||
title="Просмотреть объект">
|
<p class="card-description">{{ item.address || 'Адрес не указан' }}</p>
|
||||||
<span>👁️</span>
|
<div class="card-price">{{ formatPrice(item.price) }}</div>
|
||||||
Просмотр
|
</div>
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="card-actions">
|
||||||
<NuxtLink
|
<NuxtLink :to="`/objects/${item.id}`" class="btn btn-outline btn-sm btn-with-icon view-btn">
|
||||||
:to="`/objects/${object.id}/edit`"
|
<span>👁️</span>
|
||||||
class="btn btn-outline btn-sm btn-with-icon"
|
Просмотр
|
||||||
title="Редактировать">
|
|
||||||
<span>✏️</span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<div class="action-buttons">
|
||||||
class="btn btn-outline btn-sm btn-with-icon delete-btn"
|
<NuxtLink :to="`/objects/${item.id}/edit`" class="btn btn-outline btn-sm btn-with-icon">
|
||||||
title="Удалить"
|
<span>✏️</span>
|
||||||
@click="deleteObject(object.id)">
|
</NuxtLink>
|
||||||
<span>🗑️</span>
|
<button
|
||||||
</button>
|
class="btn btn-outline btn-sm btn-with-icon delete-btn"
|
||||||
|
@click="deleteObject(item.id)">
|
||||||
|
<span>🗑️</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Пустой state -->
|
<div v-if="!loading && filteredObjects.length === 0" class="empty-state">
|
||||||
<div v-if="!loading && myObjects.length === 0" class="empty-state">
|
<div class="empty-icon">🏢</div>
|
||||||
<div class="empty-icon">🏢</div>
|
<h3 class="empty-title">У вас пока нет объектов</h3>
|
||||||
<h3 class="empty-title">У вас пока нет объектов</h3>
|
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
|
||||||
<p class="empty-description">Добавьте первый объект, чтобы начать работу</p>
|
<NuxtLink to="/objects/create" class="btn btn-primary">Добавить первый объект</NuxtLink>
|
||||||
<NuxtLink to="/objects/create" class="btn btn-primary">
|
</div>
|
||||||
Добавить первый объект
|
</template>
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Навигация -->
|
|
||||||
<div class="page-navigation">
|
<div class="page-navigation">
|
||||||
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">
|
<NuxtLink to="/objects" class="btn btn-outline btn-with-icon">← Все объекты</NuxtLink>
|
||||||
← Все объекты
|
<NuxtLink to="/" class="btn btn-outline btn-with-icon">🏠 На главную</NuxtLink>
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/" class="btn btn-outline btn-with-icon">
|
|
||||||
🏠 На главную
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -144,130 +126,58 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface MyObjectItem {
|
import type { ObjectShortResponse } from '~/types/objects'
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
type: string
|
|
||||||
city: string
|
|
||||||
price: number
|
|
||||||
isActive: boolean
|
|
||||||
createdAt: string
|
|
||||||
image?: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мок-данные с изображениями и описаниями
|
const { getMy, remove } = useObjects()
|
||||||
const mockMyObjects: MyObjectItem[] = [
|
|
||||||
{
|
const objects = ref<ObjectShortResponse[]>([])
|
||||||
id: 1,
|
const loading = ref(true)
|
||||||
title: 'Гостевой дом "У озера"',
|
|
||||||
type: 'guest_house',
|
const filters = ref({ status: '', type: '', q: '' })
|
||||||
city: 'Карелия',
|
|
||||||
price: 2800,
|
const filteredObjects = computed(() => {
|
||||||
isActive: true,
|
let result = [...objects.value]
|
||||||
createdAt: '2024-01-15',
|
if (filters.value.status) {
|
||||||
image: '/images/objects/lake-house-1.jpg',
|
result = result.filter(o => o.status === filters.value.status)
|
||||||
description: 'Уютный гостевой дом на берегу живописного озера в Карелии. Идеальное место для отдыха от городской суеты.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Экскурсия по историческому центру',
|
|
||||||
type: 'tour',
|
|
||||||
city: 'Санкт-Петербург',
|
|
||||||
price: 1500,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-10',
|
|
||||||
image: '/images/objects/city-tour.jpg',
|
|
||||||
description: 'Увлекательная пешеходная экскурсия по самым знаковым местам Северной столицы с опытным гидом.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Горнолыжный курорт "Снежный"',
|
|
||||||
type: 'hotel',
|
|
||||||
city: 'Красная Поляна',
|
|
||||||
price: 5200,
|
|
||||||
isActive: false,
|
|
||||||
createdAt: '2024-01-05',
|
|
||||||
image: '/images/objects/ski-resort.jpg',
|
|
||||||
description: 'Современный горнолыжный комплекс с комфортабельными номерами и прямым доступом к склонам.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'Санаторий "Здоровье"',
|
|
||||||
type: 'sanatorium',
|
|
||||||
city: 'Кисловодск',
|
|
||||||
price: 3200,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-03',
|
|
||||||
image: '/images/objects/sanatorium.jpg',
|
|
||||||
description: 'Лечебно-оздоровительный комплекс с минеральными водами и современным медицинским оборудованием.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: 'Тур по Золотому кольцу',
|
|
||||||
type: 'tour',
|
|
||||||
city: 'Москва',
|
|
||||||
price: 12500,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
image: '/images/objects/golden-ring.jpg',
|
|
||||||
description: '7-дневный автобусный тур по древним городам России с проживанием в комфортабельных отелях.'
|
|
||||||
}
|
}
|
||||||
]
|
if (filters.value.type) {
|
||||||
|
result = result.filter(o => o.type === filters.value.type)
|
||||||
const myObjects = ref<MyObjectItem[]>(mockMyObjects)
|
}
|
||||||
const loading = ref(false)
|
if (filters.value.q) {
|
||||||
|
const q = filters.value.q.toLowerCase()
|
||||||
const filters = ref({
|
result = result.filter(o =>
|
||||||
status: '',
|
(o.title || o.short_name || '').toLowerCase().includes(q) ||
|
||||||
type: '',
|
(o.address || '').toLowerCase().includes(q)
|
||||||
search: ''
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
// Навигация
|
const applyFilters = () => {}
|
||||||
const navigateToCreate = () => {
|
|
||||||
navigateTo('/objects/create')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Методы
|
|
||||||
const applyFilters = () => {
|
|
||||||
let filtered = [...mockMyObjects]
|
|
||||||
|
|
||||||
if (filters.value.status) {
|
|
||||||
filtered = filtered.filter(obj =>
|
|
||||||
filters.value.status === 'active' ? obj.isActive : !obj.isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.type) {
|
|
||||||
filtered = filtered.filter(obj => obj.type === filters.value.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.value.search) {
|
|
||||||
const search = filters.value.search.toLowerCase()
|
|
||||||
filtered = filtered.filter(obj =>
|
|
||||||
obj.title.toLowerCase().includes(search) ||
|
|
||||||
obj.city.toLowerCase().includes(search) ||
|
|
||||||
obj.description.toLowerCase().includes(search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
myObjects.value = filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
filters.value = {
|
filters.value = { status: '', type: '', q: '' }
|
||||||
status: '',
|
|
||||||
type: '',
|
|
||||||
search: ''
|
|
||||||
}
|
|
||||||
myObjects.value = mockMyObjects
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteObject = async (id: number) => {
|
const statusLabel = (status: string) => {
|
||||||
if (confirm('Вы уверены, что хотите удалить этот объект?')) {
|
const labels: Record<string, string> = {
|
||||||
myObjects.value = myObjects.value.filter(obj => obj.id !== id)
|
active: 'Активен',
|
||||||
|
draft: 'Черновик',
|
||||||
|
moderation: 'На модерации',
|
||||||
|
inactive: 'Неактивен',
|
||||||
|
rejected: 'Отклонён'
|
||||||
}
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeClass = (status: string) => {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
active: 'badge-success',
|
||||||
|
draft: 'badge-secondary',
|
||||||
|
moderation: 'badge-warning',
|
||||||
|
inactive: 'badge-secondary',
|
||||||
|
rejected: 'badge-error'
|
||||||
|
}
|
||||||
|
return classes[status] || 'badge-secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
@@ -281,12 +191,8 @@ const getTypeLabel = (type: string) => {
|
|||||||
return types[type] || type
|
return types[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncateDescription = (description: string, maxLength: number = 100) => {
|
const formatPrice = (price: number | undefined) => {
|
||||||
if (description.length <= maxLength) return description
|
if (!price && price !== 0) return '—'
|
||||||
return description.substring(0, maxLength) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -294,9 +200,30 @@ const formatPrice = (price: number) => {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const deleteObject = async (id: number) => {
|
||||||
return new Date(dateString).toLocaleDateString('ru-RU')
|
if (confirm('Вы уверены, что хотите удалить этот объект?')) {
|
||||||
|
try {
|
||||||
|
await remove(id)
|
||||||
|
objects.value = objects.value.filter(o => o.id !== id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting object:', error)
|
||||||
|
alert('Ошибка при удалении объекта')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getMy()
|
||||||
|
objects.value = response.items
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading my objects:', error)
|
||||||
|
objects.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -319,11 +246,13 @@ const formatDate = (dateString: string) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text {
|
.header-text {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -331,12 +260,13 @@ const formatDate = (dateString: string) => {
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: var(--space-xs);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -345,6 +275,11 @@ const formatDate = (dateString: string) => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-filters {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
@@ -355,92 +290,70 @@ const formatDate = (dateString: string) => {
|
|||||||
.filter-actions {
|
.filter-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Сетка карточек */
|
|
||||||
.objects-grid {
|
.objects-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
margin-bottom: var(--space-xl);
|
margin-bottom: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Карточка добавления */
|
|
||||||
.add-card {
|
.add-card {
|
||||||
background: var(--bg-primary);
|
border: 2px dashed var(--border-light);
|
||||||
border: 2px dashed var(--border-medium);
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--space-xl);
|
padding: var(--space-xl);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-height: 300px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-card:hover {
|
.add-card:hover {
|
||||||
border-color: var(--primary-500);
|
border-color: var(--primary-500);
|
||||||
background: var(--primary-50);
|
background: var(--primary-50);
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-card-content {
|
.add-card-content {
|
||||||
display: flex;
|
text-align: center;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-icon {
|
.add-icon {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
opacity: 0.7;
|
margin-bottom: var(--space-md);
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-card:hover .add-icon {
|
|
||||||
transform: scale(1.1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-title {
|
.add-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-lg);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-description {
|
.add-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Карточка объекта */
|
|
||||||
.object-card {
|
.object-card {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
overflow: hidden;
|
transition: box-shadow 0.3s ease;
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-card:hover {
|
.object-card:hover {
|
||||||
transform: translateY(-4px);
|
box-shadow: var(--shadow-md);
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 200px;
|
height: 180px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,11 +361,6 @@ const formatDate = (dateString: string) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:hover .card-image img {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-badge {
|
.card-badge {
|
||||||
@@ -463,13 +371,11 @@ const formatDate = (dateString: string) => {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background: var(--success-50);
|
background: var(--success-100);
|
||||||
color: var(--success-600);
|
color: var(--success-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-secondary {
|
.badge-secondary {
|
||||||
@@ -477,86 +383,62 @@ const formatDate = (dateString: string) => {
|
|||||||
color: var(--gray-600);
|
color: var(--gray-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: var(--warning-100);
|
||||||
|
color: var(--warning-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: var(--danger-100);
|
||||||
|
color: var(--danger-700);
|
||||||
|
}
|
||||||
|
|
||||||
.card-content {
|
.card-content {
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-lg);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin-bottom: var(--space-sm);
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: var(--space-sm);
|
||||||
gap: var(--space-xs);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-type {
|
.card-type {
|
||||||
font-size: var(--text-sm);
|
padding: 2px var(--space-xs);
|
||||||
color: var(--primary-600);
|
background: var(--bg-secondary);
|
||||||
font-weight: var(--font-medium);
|
border-radius: var(--radius-sm);
|
||||||
}
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
.card-location {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-description {
|
.card-description {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
line-height: var(--leading-relaxed);
|
margin-bottom: var(--space-md);
|
||||||
margin: var(--space-xs) 0;
|
line-height: 1.4;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-price {
|
.card-price {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-xl);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
color: var(--primary-600);
|
color: var(--primary-600);
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-date {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
padding: var(--space-lg);
|
padding: var(--space-md) var(--space-lg);
|
||||||
padding-top: 0;
|
border-top: 1px solid var(--border-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn:hover {
|
|
||||||
background: var(--primary-500);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
border-color: var(--primary-500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
@@ -564,63 +446,62 @@ const formatDate = (dateString: string) => {
|
|||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn {
|
||||||
background: var(--error-50);
|
color: var(--danger-500);
|
||||||
border-color: var(--error-300);
|
border-color: var(--danger-200);
|
||||||
color: var(--error-600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Пустой state */
|
.delete-btn:hover {
|
||||||
|
background: var(--danger-50);
|
||||||
|
border-color: var(--danger-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-2xl);
|
padding: var(--space-2xl);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
margin: var(--space-xl) 0;
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top: 3px solid var(--primary-500);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
opacity: 0.5;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-navigation {
|
.page-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: var(--space-xl);
|
margin-top: var(--space-xl);
|
||||||
padding-top: var(--space-lg);
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content {
|
.header-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .btn {
|
|
||||||
flex: 1;
|
|
||||||
min-width: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
@@ -629,95 +510,11 @@ const formatDate = (dateString: string) => {
|
|||||||
|
|
||||||
.objects-grid {
|
.objects-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
order: -1;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-navigation {
|
.page-navigation {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-navigation .btn {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.page-header {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.objects-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-card {
|
|
||||||
min-height: 250px;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
padding: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
padding: var(--space-md);
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Анимации для плавного появления */
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card {
|
|
||||||
animation: fadeInUp 0.5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:nth-child(1) {
|
|
||||||
animation-delay: 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:nth-child(3) {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:nth-child(4) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.object-card:nth-child(5) {
|
|
||||||
animation-delay: 0.5s;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
error?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,27 @@
|
|||||||
// types/auth.ts
|
|
||||||
export interface LoginForm {
|
export interface LoginForm {
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterForm {
|
export interface RegisterForm {
|
||||||
first_name: string
|
first_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
passwordConfirm: string
|
passwordConfirm: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
first_name: string
|
||||||
first_name?: string
|
last_name: string
|
||||||
last_name?: string
|
full_name: string
|
||||||
inn?: string | null
|
role: string
|
||||||
phone?: string | null
|
}
|
||||||
city?: string | null
|
|
||||||
org_type?: string | null
|
export interface AuthResponse {
|
||||||
org_full_name?: string | null
|
token: string
|
||||||
org_short_name?: string | null
|
expires_at: string
|
||||||
org_inn?: string | null
|
user: UserInfo
|
||||||
email_verified_at?: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
|
|
||||||
// Статистика (если есть в API)
|
|
||||||
objects_count?: number
|
|
||||||
reviews_count?: number
|
|
||||||
active_objects_count?: number
|
|
||||||
moderation_objects_count?: number
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
export interface ImageResponse {
|
||||||
|
id: number
|
||||||
|
object_id: number
|
||||||
|
url: string
|
||||||
|
is_primary: boolean
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmenityResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category?: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectShortResponse {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
short_name: string
|
||||||
|
long_name: string
|
||||||
|
type: string
|
||||||
|
price: number
|
||||||
|
price_period: string
|
||||||
|
address: string
|
||||||
|
is_active: boolean
|
||||||
|
is_verified: boolean
|
||||||
|
status: string
|
||||||
|
feedback_count: number
|
||||||
|
tourist_average_score?: number
|
||||||
|
entrepreneur_average_score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectListResponse {
|
||||||
|
items: ObjectShortResponse[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectResponse {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
owner_id: number
|
||||||
|
owner?: object | null
|
||||||
|
title: string
|
||||||
|
short_name: string
|
||||||
|
long_name: string
|
||||||
|
type: string
|
||||||
|
price: number
|
||||||
|
price_period: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
site: string
|
||||||
|
short_description: string
|
||||||
|
description: string
|
||||||
|
address: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
is_active: boolean
|
||||||
|
is_verified: boolean
|
||||||
|
status: string
|
||||||
|
view_count: number
|
||||||
|
feedback_count: number
|
||||||
|
images: ImageResponse[]
|
||||||
|
amenities: AmenityResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateObjectRequest {
|
||||||
|
short_name: string
|
||||||
|
title?: string
|
||||||
|
long_name?: string
|
||||||
|
type?: string
|
||||||
|
price?: number
|
||||||
|
price_period?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
site?: string
|
||||||
|
short_description?: string
|
||||||
|
description?: string
|
||||||
|
address?: string
|
||||||
|
latitude?: number
|
||||||
|
longitude?: number
|
||||||
|
status?: string
|
||||||
|
is_active?: boolean | null
|
||||||
|
is_verified?: boolean | null
|
||||||
|
amenity_ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateObjectRequest {
|
||||||
|
title?: string | null
|
||||||
|
short_name?: string | null
|
||||||
|
long_name?: string | null
|
||||||
|
type?: string | null
|
||||||
|
price?: number | null
|
||||||
|
price_period?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
email?: string | null
|
||||||
|
site?: string | null
|
||||||
|
short_description?: string | null
|
||||||
|
description?: string | null
|
||||||
|
address?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
status?: string | null
|
||||||
|
is_active?: boolean | null
|
||||||
|
is_verified?: boolean | null
|
||||||
|
amenity_ids?: number[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export default defineNuxtConfig({
|
|||||||
'~/assets/css/main.css'
|
'~/assets/css/main.css'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Настройки для работы за прокси
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api/v1',
|
||||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||||
telegramBotToken: process.env.NUXT_PUBLIC_TELEGRAM_BOT_TOKEN,
|
telegramBotToken: process.env.NUXT_PUBLIC_TELEGRAM_BOT_TOKEN,
|
||||||
telegramChatId: process.env.NUXT_PUBLIC_TELEGRAM_CHAT_ID,
|
telegramChatId: process.env.NUXT_PUBLIC_TELEGRAM_CHAT_ID,
|
||||||
|
|||||||
Reference in New Issue
Block a user