Init commit

This commit is contained in:
itqop 2025-04-07 10:02:54 +03:00
parent 88606c03bc
commit 459e2b1dea
52 changed files with 5573 additions and 0 deletions

32
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Environments
.env
.venv
venv/
env/
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.db
*.sqlite
*.sqlite3
# IDEs and editors
.idea/
.vscode/
*.swp
*.swo
# OS generated files
.DS_Store
Thumbs.db
# Test reports
htmlcov/
.pytest_cache/
.coverage
# Alembic temp
alembic/versions/*.py

50
backend/README.md Normal file
View File

@ -0,0 +1,50 @@
# Backend for Glass Cutting Optimization
This directory contains the FastAPI backend application.
## Setup
1. **Create a virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows use `venv\Scripts\activate`
```
2. **Install dependencies:**
```bash
pip install -r requirements.txt
```
3. **Configure environment variables:**
Create a `.env` file in this directory based on `.env.example` (you'll create this later).
4. **Run database migrations (using Alembic):**
```bash
# Initialize alembic (only once)
# alembic init alembic
# Edit alembic.ini and alembic/env.py to point to your models and database URL
# ...
# Create a migration
# alembic revision --autogenerate -m "Initial migration"
# Apply the migration
# alembic upgrade head
```
5. **Run the development server:**
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## Project Structure
* `app/`: Main application code
* `core/`: Configuration, settings, dependencies
* `db/`: Database session management
* `models/`: SQLAlchemy models
* `schemas/`: Pydantic schemas
* `routers/`: API route handlers
* `services/`: Business logic
* `alembic/`: Database migration scripts
* `tests/`: Application tests
* `.env`: Environment variables (local)
* `alembic.ini`: Alembic configuration
* `requirements.txt`: Python dependencies

58
backend/alembic.ini Normal file
View File

@ -0,0 +1,58 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template for migration file names, e.g. "%%(rev)s_%%(slug)s.py"
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library.
# Defaults to None if not specified.
# timezone =
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
# prepend_sys_path = .
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
# Database connection (placeholder - needs to be configured)
# sqlalchemy.url = driver://user:pass@localhost/dbname
# sqlalchemy.url = sqlite:///./test.db
sqlalchemy.url = ${DATABASE_URL}

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

93
backend/alembic/env.py Normal file
View File

@ -0,0 +1,93 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Добавляем импорты для доступа к моделям и переменным окружения
import os
import sys
from dotenv import load_dotenv
# Загружаем переменные окружения из .env файла
load_dotenv()
# Добавляем путь к корню проекта, чтобы можно было импортировать модули
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from app.models import Base, Calculation
# Получаем URL базы данных из переменной окружения
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///glass_cutting.db")
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Используем метаданные из наших моделей
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = DATABASE_URL
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,28 @@
import os
from pydantic_settings import BaseSettings
from dotenv import load_dotenv
# Load .env file from the backend directory (one level up from core)
# BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# load_dotenv(os.path.join(BASE_DIR, '..', '.env')) # Adjust path if needed
# Or simply let pydantic-settings handle it by default if .env is in the root execution dir
load_dotenv()
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite:///./default.db" # Default value if not in .env
# Настройки JWT удалены
# SECRET_KEY: str = "default_secret"
# ALGORITHM: str = "HS256"
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
env_file_encoding = 'utf-8'
# If your .env file is not in the root directory where you run uvicorn,
# you might need to specify the path explicitly:
# env_file = '../.env' # Example if running from inside 'app' dir
settings = Settings()

21
backend/app/db/session.py Normal file
View File

@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLAlchemy engine
engine = create_engine(
settings.DATABASE_URL,
# Required for SQLite to prevent issues with FastAPI's async nature
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
# Create a session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency to get DB session (we'll use this in routers)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,35 @@
import asyncio
import random
from typing import Dict, Any
# Делаем функцию асинхронной
async def solve_cutting_problem(input_params: Dict[str, Any]) -> Dict[str, Any]:
"""
Асинхронная заглушка для имитации сложного расчета раскройки стекла.
Возвращает предопределенный или слегка измененный JSON-ответ.
"""
print(f"Received input for async fuzzy solver: {input_params}")
# Имитация времени расчета с использованием asyncio.sleep
delay = random.uniform(0.5, 2.0)
print(f"Simulating calculation for {delay:.2f} seconds...")
await asyncio.sleep(delay) # Используем await asyncio.sleep вместо time.sleep
# Пример выходных данных (остается таким же)
output_data = {
"layout": [
{"piece_id": 1, "x": 10, "y": 10, "width": 500, "height": 300},
{"piece_id": 2, "x": 520, "y": 10, "width": 400, "height": 300},
# ... другие детали раскроя ...
],
"waste_percentage": round(random.uniform(5.0, 15.0), 2),
"number_of_cuts": random.randint(5, 20),
"processing_time_ms": int(delay * 1000) # Используем задержку как время обработки
}
# Можно добавить логику на основе input_params, если нужно для тестов
if input_params.get("optimize_for") == "speed":
output_data["number_of_cuts"] = random.randint(15, 25) # Больше резов - быстрее?
print(f"Async fuzzy solver generated output: {output_data}")
return output_data

37
backend/app/main.py Normal file
View File

@ -0,0 +1,37 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware # Импортируем middleware
from app.routers import calculation # Импортируем роутер
app = FastAPI(title="Glass Cutting Optimization API")
# --- Настройка CORS ---
# Список разрешенных источников (origins)
# Для разработки можно разрешить все или указать адрес вашего React-приложения
origins = [
"http://localhost", # Если React запускается на http://localhost:port
"http://localhost:3000", # Стандартный порт для create-react-app
"http://localhost:5173", # Стандартный порт для Vite/React
# "http://127.0.0.1:5173", # Можно добавить и IP-адрес
# "*" # Разрешить все источники (будьте осторожны в production)
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # Указываем разрешенные источники
allow_credentials=True, # Разрешаем куки (если потребуются в будущем)
allow_methods=["*"], # Разрешаем все методы (GET, POST, PUT, etc.)
allow_headers=["*"], # Разрешаем все заголовки
)
# --- Конец настройки CORS ---
# Подключаем роутер расчетов
app.include_router(calculation.router)
@app.get("/")
async def read_root():
return {"message": "Welcome to the Glass Cutting Optimization API"}
# Убедимся, что здесь нет подключения auth роутера
# from .routers import calculation # Позже подключим calculation
# app.include_router(calculation.router)

View File

@ -0,0 +1,2 @@
from .base import Base
from .calculation import Calculation

View File

@ -0,0 +1,8 @@
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer
from typing import Annotated
# Define a base class using SQLAlchemy 2.0 style
class Base(DeclarativeBase):
# Define a primary key type annotation for convenience
pass

View File

@ -0,0 +1,22 @@
import datetime
from sqlalchemy import Column, String, DateTime, JSON, Float, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from typing import Dict, Any
class Calculation(Base):
__tablename__ = "calculations"
#
id: Mapped[int] = mapped_column(primary_key=True, index=True)
input_params: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
output_results: Mapped[Dict[str, Any] | None] = mapped_column(JSON, nullable=True)
timestamp: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
objective_score: Mapped[float | None] = mapped_column(Float, nullable=True)
def __repr__(self):
return f"<Calculation(id={self.id}, timestamp='{self.timestamp}')>"

20
backend/app/models1.py Normal file
View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, JSON, Float, DateTime
from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Calculation(Base):
__tablename__ = "calculations"
id = Column(Integer, primary_key=True, index=True)
input_params = Column(JSON)
output_results = Column(JSON)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
model_name = Column(String, nullable=True)
objective_score = Column(Float, nullable=True)
# Удалите эти строки, если они есть:
# user_id = Column(Integer, ForeignKey("users.id"))
# user = relationship("User", back_populates="calculations")
# Можно вообще удалить класс User, если он больше не нужен

View File

@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.schemas.calculation import CalculationCreate, CalculationRead
from app.services import calculation_service as service
from app.db.session import get_db
router = APIRouter(
prefix="/calculation", # Общий префикс для эндпоинтов этого роутера
tags=["Calculations"], # Тег для группировки в документации Swagger
)
@router.post("/", response_model=CalculationRead, status_code=status.HTTP_201_CREATED)
async def run_calculation(
calc_in: CalculationCreate,
db: Session = Depends(get_db)
):
"""
Запускает новый расчет раскройки стекла (асинхронно).
- Принимает `input_params` и опционально `model_name`.
- Вызывает внутреннюю асинхронную логику расчета (заглушку).
- Сохраняет входные параметры и результаты в БД.
- Возвращает созданную запись о расчете.
"""
print("Received request to run calculation")
calculation = await service.create_calculation(db=db, calc_in=calc_in)
print("Calculation service finished, returning response")
return calculation
@router.get("/{calculation_id}", response_model=CalculationRead)
async def read_calculation(
calculation_id: int,
db: Session = Depends(get_db)
):
"""
Получает информацию о конкретном расчете по его ID.
"""
db_calc = service.get_calculation_by_id(db=db, calc_id=calculation_id)
if db_calc is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Calculation not found"
)
return db_calc
# Определяем эндпоинт для истории до эндпоинта с ID,
# чтобы FastAPI не принял "history" за ID
@router.get("/history/", response_model=List[CalculationRead])
async def read_calculation_history(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""
Получает историю всех выполненных расчетов (с пагинацией).
"""
calculations = service.get_calculations(db=db, skip=skip, limit=limit)
return calculations

View File

@ -0,0 +1,35 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, Dict, Any
# --- Базовая схема ---
# Общие поля для расчета
class CalculationBase(BaseModel):
input_params: Dict[str, Any]
model_name: Optional[str] = None # Модель может быть выбрана сервером
# --- Схема для создания (запуска) расчета ---
# Данные, которые пользователь отправляет для запуска
class CalculationCreate(CalculationBase):
pass # Пока совпадает с Base, но может быть расширена
# --- Схема для чтения ---
# Полная информация о расчете, возвращаемая API
class CalculationRead(CalculationBase):
id: int
output_results: Optional[Dict[str, Any]] = None
timestamp: datetime
objective_score: Optional[float] = None
# Включаем режим ORM
model_config = ConfigDict(from_attributes=True)
# В Pydantic v1 было:
# class Config:
# orm_mode = True
# --- Опционально: Схема для обновления (если результаты добавляются позже) ---
class CalculationUpdate(BaseModel):
output_results: Optional[Dict[str, Any]] = None
objective_score: Optional[float] = None
model_name: Optional[str] = None # Если модель определяется по результату

View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
# Можно использовать id пользователя, если удобнее
# user_id: Optional[int] = None

View File

@ -0,0 +1,68 @@
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from app.models.calculation import Calculation
from app.schemas.calculation import CalculationCreate, CalculationUpdate
from app.fuzzy_solver import solve_cutting_problem # Импортируем асинхронную заглушку
# Делаем функцию создания асинхронной
async def create_calculation(db: Session, calc_in: CalculationCreate) -> Calculation:
"""
Асинхронно создает запись о расчете, вызывает заглушку и сохраняет результат.
"""
# 1. Вызываем асинхронную заглушку расчета
print("Calling async solver...")
output_results = await solve_cutting_problem(calc_in.input_params)
print("Async solver finished.")
# 2. Определяем другие параметры (логика остается)
objective_score = output_results.get("waste_percentage")
model_name = calc_in.model_name or "default_fuzzy_model_v1"
# 3. Создаем объект модели SQLAlchemy
# Операции с БД остаются синхронными в этом примере
# Для полной асинхронности потребовался бы async-драйвер и AsyncSession
print("Creating DB object...")
db_calc = Calculation(
input_params=calc_in.input_params,
output_results=output_results,
objective_score=objective_score,
model_name=model_name
)
# 4. Сохраняем в базу данных (синхронные операции)
print("Adding to DB session...")
db.add(db_calc)
print("Committing DB session...")
db.commit()
print("Refreshing DB object...")
db.refresh(db_calc)
print("Calculation created and saved.")
return db_calc
# Функции чтения можно оставить синхронными, т.к. они быстрые
# и используют синхронную сессию
def get_calculation_by_id(db: Session, calc_id: int) -> Optional[Calculation]:
"""
Получает расчет по его ID.
"""
return db.query(Calculation).filter(Calculation.id == calc_id).first()
def get_calculations(db: Session, skip: int = 0, limit: int = 100) -> List[Calculation]:
"""
Получает список всех расчетов с пагинацией.
"""
return db.query(Calculation).offset(skip).limit(limit).all()
# Опционально: Функция для обновления расчета (если нужно)
# def update_calculation(db: Session, calc_id: int, calc_update: CalculationUpdate) -> Optional[Calculation]:
# db_calc = get_calculation_by_id(db, calc_id)
# if not db_calc:
# return None
# update_data = calc_update.model_dump(exclude_unset=True) # Pydantic v2
# # update_data = calc_update.dict(exclude_unset=True) # Pydantic v1
# for key, value in update_data.items():
# setattr(db_calc, key, value)
# db.commit()
# db.refresh(db_calc)
# return db_calc

10
backend/requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi[all]
uvicorn[standard]
sqlalchemy==2.0.*
pydantic[email]
python-dotenv
alembic
psycopg2-binary # Или другой драйвер БД, если нужно, для SQLite не требуется явно, но SQLAlchemy может его использовать
# Позже добавим: pytest, httpx, selenium, locust, etc.
pytest
httpx

60
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,60 @@
import pytest
from typing import Generator, Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
import os
import sys
# Добавляем корень проекта в sys.path для импорта app
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.main import app as main_app # Импортируем наше FastAPI приложение
from app.db.session import get_db, Base # Импортируем get_db и Base
from app.core.config import settings # Импортируем настройки
# Используем отдельную SQLite базу данных для тестов (in-memory)
TEST_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
TEST_DATABASE_URL, connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Фикстура для создания таблиц перед тестами и удаления после
@pytest.fixture(scope="session", autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine) # Создаем таблицы
yield
Base.metadata.drop_all(bind=engine) # Удаляем таблицы после тестов
# Фикстура для переопределения зависимости get_db
@pytest.fixture(scope="function") # scope="function" чтобы каждая функция получала чистую сессию
def db_session() -> Generator[Session, Any, None]:
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback() # Откатываем изменения после каждого теста
connection.close()
# Фикстура для FastAPI TestClient
@pytest.fixture(scope="module")
def client(db_session: Session) -> Generator[TestClient, Any, None]:
# Функция для переопределения зависимости get_db
def override_get_db():
try:
yield db_session
finally:
# Сессия закрывается в фикстуре db_session
pass
# Применяем переопределение зависимости к нашему приложению
main_app.dependency_overrides[get_db] = override_get_db
# Создаем TestClient
with TestClient(main_app) as c:
yield c
# Очищаем переопределение после тестов модуля
main_app.dependency_overrides.clear()

View File

@ -0,0 +1,97 @@
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
import pytest
# Импортируем модели и схемы (если нужно для assert'ов)
from app.models.calculation import Calculation
from app.schemas.calculation import CalculationRead
# Используем фикстуры client и db_session из conftest.py
def test_create_calculation(client: TestClient, db_session: Session):
"""Тест успешного создания расчета."""
input_data = {
"input_params": {"width": 1000, "height": 800, "pieces": [1, 2, 3]},
"model_name": "test_model"
}
response = client.post("/calculation/", json=input_data)
assert response.status_code == 201
data = response.json()
assert data["input_params"] == input_data["input_params"]
assert data["model_name"] == input_data["model_name"]
assert "id" in data
assert "timestamp" in data
assert "output_results" in data # Заглушка должна вернуть результаты
assert data["output_results"] is not None
# Можно добавить более детальные проверки output_results, если заглушка детерминирована
# assert "waste_percentage" in data["output_results"]
assert "objective_score" in data # waste_percentage используется как objective_score
# Проверяем, что запись действительно появилась в тестовой БД
calc_id = data["id"]
db_calc = db_session.query(Calculation).filter(Calculation.id == calc_id).first()
assert db_calc is not None
assert db_calc.input_params == input_data["input_params"]
assert db_calc.model_name == input_data["model_name"]
assert db_calc.output_results is not None
def test_read_calculation(client: TestClient, db_session: Session):
"""Тест получения расчета по ID."""
# Сначала создаем расчет для теста
input_data = {"input_params": {"size": "large"}, "model_name": "reader_test"}
response_create = client.post("/calculation/", json=input_data)
assert response_create.status_code == 201
created_data = response_create.json()
calc_id = created_data["id"]
# Теперь запрашиваем созданный расчет
response_read = client.get(f"/calculation/{calc_id}")
assert response_read.status_code == 200
read_data = response_read.json()
assert read_data["id"] == calc_id
assert read_data["input_params"] == input_data["input_params"]
assert read_data["model_name"] == input_data["model_name"]
assert read_data["output_results"] == created_data["output_results"] # Сравниваем с тем, что вернул POST
assert read_data["timestamp"] is not None
assert read_data["objective_score"] is not None
def test_read_calculation_not_found(client: TestClient):
"""Тест получения несуществующего расчета."""
response = client.get("/calculation/99999") # Используем ID, которого точно нет
assert response.status_code == 404
assert response.json() == {"detail": "Calculation not found"}
def test_read_calculation_history(client: TestClient, db_session: Session):
"""Тест получения истории расчетов."""
# Очистим таблицу на всякий случай (хотя фикстура должна это делать)
# db_session.query(Calculation).delete()
# db_session.commit()
# Создаем несколько расчетов
client.post("/calculation/", json={"input_params": {"test": 1}})
client.post("/calculation/", json={"input_params": {"test": 2}})
# Запрашиваем историю
response = client.get("/calculation/history/")
assert response.status_code == 200
history_data = response.json()
assert isinstance(history_data, list)
assert len(history_data) >= 2 # Проверяем, что созданные расчеты есть в истории
# Проверяем структуру элементов истории
if len(history_data) > 0:
item = history_data[0]
assert "id" in item
assert "input_params" in item
assert "output_results" in item
assert "timestamp" in item
assert "model_name" in item
assert "objective_score" in item
def test_create_calculation_invalid_input(client: TestClient):
"""Тест создания расчета с невалидными данными (проверка Pydantic)."""
# Отправляем данные без обязательного поля input_params
response = client.post("/calculation/", json={"model_name": "invalid"})
assert response.status_code == 422 # Ошибка валидации Pydantic
# Можно проверить тело ошибки, если нужно

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3608
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.8.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"react-router-dom": "^7.5.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

108
frontend/src/App.css Normal file
View File

@ -0,0 +1,108 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
.App {
display: flex;
flex-direction: column;
min-height: 100vh; /* Минимальная высота на весь экран */
}
.App-header {
background-color: #282c34; /* Темный фон шапки */
padding: 20px;
color: white;
display: flex;
justify-content: space-between; /* Разместить заголовок и навигацию по краям */
align-items: center;
}
.App-header h1 {
margin: 0;
font-size: 1.5rem;
}
.App-header nav a {
color: #61dafb; /* Цвет ссылок в шапке */
margin-left: 15px; /* Отступ между ссылками */
font-size: 1rem;
}
.App-header nav a:hover {
color: white;
text-decoration: none;
}
main {
flex-grow: 1; /* Основной контент занимает все доступное пространство */
padding: 20px;
max-width: 1200px; /* Ограничим максимальную ширину контента */
margin: 0 auto; /* Центрируем контент */
width: 90%; /* Небольшой отступ по бокам на больших экранах */
}
footer {
background-color: #f8f9fa; /* Светлый фон футера */
padding: 10px 20px;
text-align: center;
border-top: 1px solid #e7e7e7;
margin-top: auto; /* Прижимаем футер к низу */
color: #6c757d;
font-size: 0.9em;
}
/* Базовые стили для контейнеров страниц */
.page-container {
background-color: #fff; /* Белый фон для основного контента */
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Небольшая тень */
}
.page-container h2 {
margin-top: 0;
color: #343a40; /* Темный цвет заголовков страниц */
border-bottom: 2px solid #007bff; /* Подчеркивание заголовка */
padding-bottom: 10px;
margin-bottom: 20px;
}

26
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import { Link } from 'react-router-dom';
import AppRouter from './router';
import './App.css';
const App: React.FC = () => {
return (
<div className="App">
<header className="App-header">
<h1>Раскройка Стекла</h1>
<nav>
<Link to="/calculate" style={{ marginRight: '10px' }}>Новый расчет</Link>
<Link to="/history">История</Link>
</nav>
</header>
<main>
<AppRouter />
</main>
<footer>
<p>&copy; 2024 Система Раскройки</p>
</footer>
</div>
);
}
export default App;

View File

@ -0,0 +1,68 @@
import axios from 'axios';
// Заменяем заглушки на импорт реальных типов
import { CalculationCreate, CalculationRead } from '../types';
// URL вашего FastAPI бэкенда
// Убедитесь, что порт совпадает с тем, на котором запущен uvicorn (по умолчанию 8000)
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Запускает новый расчет
* @param data Входные параметры для расчета
* @returns Данные созданного расчета
*/
// Используем импортированные типы
export const runCalculation = async (data: CalculationCreate): Promise<CalculationRead> => {
try {
// Указываем тип ответа
const response = await apiClient.post<CalculationRead>('/calculation/', data);
return response.data;
} catch (error) {
console.error("Error running calculation:", error);
throw error;
}
};
/**
* Получает детали конкретного расчета по ID
* @param id ID расчета
* @returns Данные расчета
*/
// Используем импортированные типы
export const getCalculationById = async (id: number): Promise<CalculationRead> => {
try {
// Указываем тип ответа
const response = await apiClient.get<CalculationRead>(`/calculation/${id}`);
return response.data;
} catch (error) {
console.error(`Error fetching calculation ${id}:`, error);
throw error;
}
};
/**
* Получает историю расчетов
* @param skip Смещение для пагинации
* @param limit Лимит записей на страницу
* @returns Список расчетов
*/
// Используем импортированные типы
export const getCalculationHistory = async (skip: number = 0, limit: number = 100): Promise<CalculationRead[]> => {
try {
// Указываем тип ответа
const response = await apiClient.get<CalculationRead[]>(`/calculation/history/?skip=${skip}&limit=${limit}`);
return response.data;
} catch (error) {
console.error("Error fetching calculation history:", error);
throw error;
}
};
export default apiClient;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,17 @@
.pre {
background-color: #f8f9fa; /* Очень светлый фон */
padding: 15px;
border-radius: 4px;
white-space: pre-wrap; /* Перенос строк */
word-break: break-all; /* Перенос длинных слов */
border: 1px solid #dee2e6;
font-size: 0.9em;
max-height: 300px; /* Ограничим высоту */
overflow-y: auto; /* Добавим скролл */
}
.noData {
color: #6c757d;
}
.error {
color: #dc3545;
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import styles from './JsonViewer.module.css';
const JsonViewer: React.FC<{ data: any }> = ({ data }) => {
if (data === undefined || data === null) return <div className={styles.noData}>-</div>;
try {
return <pre className={styles.pre}>{JSON.stringify(data, null, 2)}</pre>;
} catch (e) {
return <div className={styles.error}>Не удалось отобразить JSON</div>;
}
};
export default JsonViewer;

View File

@ -0,0 +1,52 @@
.container {
border: 1px solid #dee2e6;
margin-top: 15px;
padding: 15px;
border-radius: 4px;
background-color: #fff;
}
.container h4 {
margin-top: 0;
margin-bottom: 10px;
color: #495057;
}
.svgCanvas {
border: 1px solid #e9ecef;
display: block; /* Убрать лишний отступ под SVG */
max-height: 500px; /* Ограничить высоту */
background-color: #f8f9fa; /* Фон для области SVG */
}
.pieceRect {
fill: #cfe2f3; /* Цвет детали */
stroke: #6c757d; /* Цвет обводки */
stroke-width: 1;
transition: fill 0.2s ease;
}
.pieceGroup:hover .pieceRect {
fill: #a4c2f4; /* Цвет при наведении */
}
.pieceText {
font-family: sans-serif;
fill: #212529; /* Цвет текста */
pointer-events: none; /* Чтобы текст не мешал наведению на прямоугольник */
}
.pieceId {
font-size: 10px;
text-anchor: middle;
alignment-baseline: middle;
font-weight: bold;
}
.pieceSize {
font-size: 8px;
}
.noData {
color: #6c757d;
font-style: italic;
}

View File

@ -0,0 +1,60 @@
import React from 'react';
import { LayoutPiece } from '../../types'; // Импортируем тип
import styles from './LayoutVisualizer.module.css';
const LayoutVisualizer: React.FC<{ layout: LayoutPiece[] | undefined | null}> = ({ layout }) => {
if (!layout || !Array.isArray(layout) || layout.length === 0) {
return <p className={styles.noData}>Данные раскроя отсутствуют или некорректны.</p>;
}
// Адаптивный viewBox
const PADDING = 10;
const maxX = Math.max(...layout.map(p => p.x + p.width)) + PADDING;
const maxY = Math.max(...layout.map(p => p.y + p.height)) + PADDING;
// Сохраняем соотношение сторон, если нужно, или используем фиксированную высоту/ширину
const viewBoxWidth = maxX || 200; // Fallback width
const viewBoxHeight = maxY || 200; // Fallback height
return (
<div className={styles.container}>
<h4>Визуализация раскроя</h4>
<svg
className={styles.svgCanvas}
width="100%"
// preserveAspectRatio="xMidYMid meet" // Сохранять пропорции
viewBox={`0 0 ${viewBoxWidth} ${viewBoxHeight}`}
>
{/* Можно добавить фон листа, если известны его размеры */}
{/* <rect x="0" y="0" width={sheetWidth} height={sheetHeight} fill="#eee" /> */}
{layout.map((piece) => (
<g key={piece.piece_id} className={styles.pieceGroup}>
<rect
x={piece.x}
y={piece.y}
width={piece.width}
height={piece.height}
className={styles.pieceRect}
/>
<text
x={piece.x + piece.width / 2}
y={piece.y + piece.height / 2}
className={`${styles.pieceText} ${styles.pieceId}`}
>
{piece.piece_id}
</text>
<text
x={piece.x + 5}
y={piece.y + 15} // Немного опустим текст размера
className={`${styles.pieceText} ${styles.pieceSize}`}
>
{piece.width}x{piece.height}
</text>
</g>
))}
</svg>
</div>
);
};
export default LayoutVisualizer;

31
frontend/src/index.css Normal file
View File

@ -0,0 +1,31 @@
/* Сброс базовых стилей */
body, html, #root {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f4f7f6; /* Светлый фон для всей страницы */
color: #333; /* Основной цвет текста */
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
}

13
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css' // Глобальные стили
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,63 @@
.formContainer {
/* Используем класс из App.css для общего стиля */
}
.formGroup {
margin-bottom: 15px;
}
.label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057; /* Цвет метки */
}
.input,
.textarea {
width: calc(100% - 18px); /* Учитываем padding и border */
padding: 8px;
border: 1px solid #ced4da; /* Граница поля ввода */
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.input:focus,
.textarea:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.textarea {
min-height: 100px;
resize: vertical; /* Разрешить изменять размер по вертикали */
}
.errorMessage {
color: #dc3545; /* Красный цвет для ошибок */
font-size: 0.875em;
margin-top: 5px;
}
.submitButton {
padding: 10px 20px;
font-size: 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
.submitButton:hover:not(:disabled) {
background-color: #0056b3;
}
.submitButton:disabled {
background-color: #6c757d; /* Серый цвет для неактивной кнопки */
cursor: not-allowed;
}

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { runCalculation } from '../api/calculationApi';
import { CalculationCreate, CalculationFormData } from '../types';
// Импортируем стили как объект
import styles from './CalculationForm.module.css'; // Убедитесь, что этот файл существует
const CalculationForm: React.FC = () => {
console.log("CalculationForm Рендерится! (Полная версия)"); // Обновим лог
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CalculationFormData>({
defaultValues: {
sheetWidth: 2500,
sheetHeight: 1600,
pieceList: "500x300\n400x300\n200x200",
modelName: "default_fuzzy_model_v1"
}
});
const onSubmit: SubmitHandler<CalculationFormData> = async (formData) => {
setIsLoading(true);
setError(null);
console.log('Form Data:', formData);
const apiData: CalculationCreate = {
input_params: {
sheet_dimensions: {
width: formData.sheetWidth,
height: formData.sheetHeight,
},
pieces_raw: formData.pieceList,
},
model_name: formData.modelName || null,
};
try {
console.log('Sending to API:', apiData);
const result = await runCalculation(apiData);
console.log('API Result:', result);
navigate(`/calculation/${result.id}`);
} catch (err) {
console.error("API Error:", err);
// Попробуем получить более детальную ошибку от axios, если возможно
let errorMessage = 'Ошибка при запуске расчета. Попробуйте снова.';
if (axios.isAxiosError(err)) { // Проверка типа ошибки axios
if (err.response) {
// Ошибка пришла с ответом от сервера (например, 4xx, 5xx)
errorMessage = `Ошибка сервера: ${err.response.status}. ${err.response.data?.detail || err.message}`;
} else if (err.request) {
// Запрос был сделан, но ответа не было (сеть, CORS)
errorMessage = `Ошибка сети или CORS. Не удалось связаться с сервером. (${err.message})`;
} else {
// Ошибка при настройке запроса
errorMessage = `Ошибка настройки запроса: ${err.message}`;
}
} else if (err instanceof Error) { // Обычная ошибка JS
errorMessage = err.message;
}
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
return (
// Используем класс из App.css и стили модуля
<div className={`page-container ${styles.formContainer}`}>
<h2>Ввод параметров расчета</h2>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Поле Ширина листа */}
<div className={styles.formGroup}>
<label htmlFor="sheetWidth" className={styles.label}>Ширина листа (мм):</label>
<input
id="sheetWidth"
type="number"
className={styles.input}
{...register('sheetWidth', {
required: 'Ширина обязательна',
valueAsNumber: true,
min: { value: 100, message: 'Минимальная ширина 100 мм' },
})}
/>
{errors.sheetWidth && <p className={styles.errorMessage}>{errors.sheetWidth.message}</p>}
</div>
{/* Поле Высота листа */}
<div className={styles.formGroup}>
<label htmlFor="sheetHeight" className={styles.label}>Высота листа (мм):</label>
<input
id="sheetHeight"
type="number"
className={styles.input}
{...register('sheetHeight', {
required: 'Высота обязательна',
valueAsNumber: true,
min: { value: 100, message: 'Минимальная высота 100 мм' },
})}
/>
{errors.sheetHeight && <p className={styles.errorMessage}>{errors.sheetHeight.message}</p>}
</div>
{/* Поле Список деталей */}
<div className={styles.formGroup}>
<label htmlFor="pieceList" className={styles.label}>Список деталей (формат: ШхВ, каждая с новой строки):</label>
<textarea
id="pieceList"
rows={6}
className={styles.textarea}
{...register('pieceList', {
required: 'Список деталей обязателен',
pattern: {
value: /^(\d+x\d+\s*(\r\n|\r|\n)?)+$/,
message: 'Неверный формат списка деталей (ожидается ШхВ на каждой строке)'
}
})}
/>
{errors.pieceList && <p className={styles.errorMessage}>{errors.pieceList.message}</p>}
</div>
{/* Поле Имя модели (опционально) */}
<div className={styles.formGroup}>
<label htmlFor="modelName" className={styles.label}>Имя модели (опционально):</label>
<input
id="modelName"
type="text"
className={styles.input}
{...register('modelName')}
/>
</div>
{/* Отображение общей ошибки API */}
{error && <p className={styles.errorMessage}>{error}</p>}
{/* Кнопка отправки */}
<button type="submit" disabled={isLoading} className={styles.submitButton}>
{isLoading ? 'Загрузка...' : 'Запустить расчет'}
</button>
</form>
</div>
);
};
// Импорт axios для проверки типа ошибки
import axios from 'axios';
export default CalculationForm;

View File

@ -0,0 +1,37 @@
.resultContainer {
/* Общий стиль страницы */
}
.detailSection {
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.detailSection:last-child {
border-bottom: none; /* Убрать границу у последнего блока */
margin-bottom: 0;
padding-bottom: 0;
}
.detailTitle {
font-weight: bold;
margin-bottom: 8px;
color: #495057; /* Цвет заголовка секции */
font-size: 1.1em;
}
.detailValue {
color: #343a40; /* Цвет значения */
/* Можно добавить стили для лучшего отображения данных */
}
.loading,
.error {
margin-top: 20px;
font-style: italic;
color: #6c757d;
}
.error {
color: #dc3545;
font-weight: bold;
}

View File

@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { getCalculationById } from '../api/calculationApi';
import { CalculationRead } from '../types';
// Импортируем вспомогательные компоненты
import JsonViewer from '../components/common/JsonViewer';
import LayoutVisualizer from '../components/common/LayoutVisualizer';
// Импортируем стили
import styles from './CalculationResult.module.css';
const CalculationResult: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [calculation, setCalculation] = useState<CalculationRead | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setError("ID расчета не указан.");
setIsLoading(false);
return;
}
const calculationId = parseInt(id, 10); // Преобразуем ID в число
if (isNaN(calculationId)) {
setError("Некорректный ID расчета.");
setIsLoading(false);
return;
}
const fetchCalculation = async () => {
setIsLoading(true);
setError(null);
try {
const data = await getCalculationById(calculationId);
setCalculation(data);
} catch (err: any) { // Указываем тип any или AxiosError
console.error(`Failed to fetch calculation ${calculationId}:`, err);
// Проверяем, была ли это ошибка 404 от нашего API
if (err.response && err.response.status === 404) {
setError(`Расчет с ID ${calculationId} не найден.`);
} else {
setError('Не удалось загрузить детали расчета.');
}
} finally {
setIsLoading(false);
}
};
fetchCalculation();
}, [id]); // Зависимость от ID, чтобы перезагружать при изменении ID в URL
if (isLoading) {
return <div className={styles.loading}>Загрузка данных расчета ID: {id}...</div>;
}
if (error) {
return <div className={styles.error}>Ошибка: {error}</div>;
}
if (!calculation) {
// Это состояние не должно достигаться, если ID корректен и нет ошибки,
// но лучше добавить проверку
return <div>Данные расчета не найдены.</div>;
}
return (
<div className={`page-container ${styles.resultContainer}`}>
<h2>Результаты расчета ID: {calculation.id}</h2>
<div className={styles.detailSection}>
<div className={styles.detailTitle}>Время запуска:</div>
<div className={styles.detailValue}>{new Date(calculation.timestamp).toLocaleString()}</div>
</div>
<div className={styles.detailSection}>
<div className={styles.detailTitle}>Использованная модель:</div>
<div className={styles.detailValue}>{calculation.model_name || '-'}</div>
</div>
<div className={styles.detailSection}>
<div className={styles.detailTitle}>Входные параметры:</div>
<JsonViewer data={calculation.input_params} />
</div>
<div className={styles.detailSection}>
<div className={styles.detailTitle}>Результаты расчета (JSON):</div>
<JsonViewer data={calculation.output_results} />
</div>
<div className={styles.detailSection}>
<div className={styles.detailTitle}>Целевая функция (Остаток %):</div>
<div className={styles.detailValue}>{calculation.objective_score?.toFixed(2) ?? '-'}</div>
</div>
{/* Добавляем визуализацию, если есть данные */}
{calculation.output_results && calculation.output_results.layout && (
<div className={styles.detailSection}>
<LayoutVisualizer layout={calculation.output_results.layout} />
</div>
)}
</div>
);
};
export default CalculationResult;

View File

@ -0,0 +1,50 @@
.historyContainer {
/* Общий стиль страницы */
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Тень для таблицы */
overflow: hidden; /* Для скругления углов */
border-radius: 8px; /* Скругление углов таблицы */
}
.th,
.td {
border: 1px solid #ddd;
padding: 12px 15px; /* Увеличим padding */
text-align: left;
}
.th {
background-color: #f8f9fa; /* Светлый фон заголовков */
font-weight: bold;
color: #495057;
}
/* Зебра для строк */
.tbody tr:nth-of-type(even) {
background-color: #fdfdfe;
}
.tbody tr:hover {
background-color: #f1f1f1; /* Подсветка строки при наведении */
}
.td a { /* Стилизация ссылки "Подробнее" */
font-weight: bold;
}
.loading,
.error,
.empty {
margin-top: 20px;
font-style: italic;
color: #6c757d;
}
.error {
color: #dc3545;
font-weight: bold;
}

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; // Для ссылок на детальные страницы
import { getCalculationHistory } from '../api/calculationApi'; // API функция
import { CalculationRead } from '../types'; // Тип расчета
// Импортируем стили
import styles from './History.module.css';
const History: React.FC = () => {
// Состояние для хранения списка расчетов
const [calculations, setCalculations] = useState<CalculationRead[]>([]);
// Состояние загрузки
const [isLoading, setIsLoading] = useState(true);
// Состояние ошибки
const [error, setError] = useState<string | null>(null);
// Загрузка данных при монтировании компонента
useEffect(() => {
const fetchHistory = async () => {
setIsLoading(true);
setError(null);
try {
const data = await getCalculationHistory(0, 100); // Загружаем первые 100 записей
setCalculations(data);
} catch (err) {
console.error("Failed to fetch calculation history:", err);
setError('Не удалось загрузить историю расчетов.');
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, []); // Пустой массив зависимостей означает, что эффект выполнится один раз при монтировании
// --- Отображение состояний ---
if (isLoading) {
return <div className={styles.loading}>Загрузка истории...</div>;
}
if (error) {
return <div className={styles.error}>Ошибка: {error}</div>;
}
if (calculations.length === 0) {
return <div className={styles.empty}>История расчетов пуста.</div>;
}
return (
// Используем класс из App.css и стили модуля
<div className={`page-container ${styles.historyContainer}`}>
<h2>История расчетов</h2>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>ID</th>
<th className={styles.th}>Время запуска</th>
<th className={styles.th}>Модель</th>
<th className={styles.th}>Целевая функция (Остаток %)</th>
<th className={styles.th}>Действия</th>
</tr>
</thead>
<tbody className={styles.tbody}>
{calculations.map((calc) => (
<tr key={calc.id}>
<td className={styles.td}>{calc.id}</td>
<td className={styles.td}>{new Date(calc.timestamp).toLocaleString()}</td>
<td className={styles.td}>{calc.model_name || '-'}</td>
<td className={styles.td}>{calc.objective_score?.toFixed(2) ?? '-'}</td>
<td className={styles.td}>
<Link to={`/calculation/${calc.id}`}>Подробнее</Link>
</td>
</tr>
))}
</tbody>
</table>
{/* Здесь можно добавить пагинацию, если записей много */}
</div>
);
};
export default History;

View File

@ -0,0 +1,25 @@
import React, { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
// Ленивая загрузка страниц
const CalculationFormPage = lazy(() => import('../pages/CalculationForm'));
const HistoryPage = lazy(() => import('../pages/History'));
const CalculationResultPage = lazy(() => import('../pages/CalculationResult'));
const LoadingIndicator = () => <div>Загрузка...</div>;
const AppRouter: React.FC = () => {
return (
<Suspense fallback={<LoadingIndicator />}>
<Routes>
<Route path="/calculate" element={<CalculationFormPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/calculation/:id" element={<CalculationResultPage />} />
<Route path="/" element={<Navigate to="/calculate" replace />} />
<Route path="*" element={<div>404 - Страница не найдена</div>} />
</Routes>
</Suspense>
);
};
export default AppRouter;

View File

@ -0,0 +1,39 @@
// Типы, соответствующие Pydantic схемам из backend/app/schemas/calculation.py
/**
* Данные, необходимые для запуска расчета (соответствует CalculationCreate)
*/
export interface CalculationCreate {
input_params: Record<string, any>; // JSON -> Record<string, any>
model_name?: string | null;
}
/**
* Полные данные о расчете (соответствует CalculationRead)
*/
export interface CalculationRead {
id: number;
input_params: Record<string, any>;
output_results?: Record<string, any> | null;
timestamp: string; // DateTime -> string
model_name?: string | null;
objective_score?: number | null;
}
// Опционально: Тип для элемента в массиве layout из output_results заглушки
export interface LayoutPiece {
piece_id: number;
x: number;
y: number;
width: number;
height: number;
}
// Добавим тип для данных формы (может немного отличаться от CalculationCreate)
export interface CalculationFormData {
// Примерные поля, которые могут быть в форме
sheetWidth: number;
sheetHeight: number;
pieceList: string; // Например, строка с размерами деталей "200x300, 150x100"
modelName?: string;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

32
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Add Vite client types */
"types": ["vite/client"],
/* Added for compatibility */
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

17
test.py Normal file
View File

@ -0,0 +1,17 @@
from sqlalchemy import create_engine, inspect
# Подключаемся к БД
engine = create_engine("sqlite:///./glass_cutting.db")
inspector = inspect(engine)
# Получаем список таблиц
print("Таблицы в БД:", inspector.get_table_names())
# Получаем информацию по конкретной таблице
if "calculations" in inspector.get_table_names():
columns = inspector.get_columns("calculations")
print("\nСтруктура таблицы 'calculations':")
for column in columns:
print(f"{column['name']} ({column['type']})")
else:
print("Таблица 'calculations' не найдена.")