Перейти к содержанию

Поток добавления вещи — полный код

Гардероб

AddItemSourcePage

Файл: frontend/src/pages/AddItemSourcePage.tsx

Страница выбора источника фотографии. Два варианта: камера устройства или галерея.

import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from 'antd';
import { Camera, Image, Sparkles } from 'lucide-react';
import PageHeader from '../components/layout/PageHeader';
import s from './AddItemSourcePage.module.css';

export default function AddItemSourcePage() {
  const navigate = useNavigate();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const cameraInputRef = useRef<HTMLInputElement>(null);

  // После выбора файла — переход на страницу деталей с передачей файла через state
  const handleFile = (file: File) => {
    navigate('/add/details', { state: { file } });
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) {
      handleFile(file);
    }
  };

  // Проверка доступности камеры перед попыткой открыть
  const handleCamera = () => {
    if (!navigator.mediaDevices?.getUserMedia) {
      Modal.info({
        title: 'Камера недоступна',
        content: 'Используйте галерею для добавления фото.',
        okText: 'Понятно',
      });
      return;
    }
    cameraInputRef.current?.click();
  };

  return (
    <>
      <PageHeader title="Добавить вещь" />
      <div className={`${s.pageContent} ${s.addSourcePage}`}>
        <p className={s.intro}>
          Оцифруйте вещь за минуту: фото, автоочистка фона и базовые теги.
        </p>

        <section className={s.hero}>
          <Sparkles size={34} strokeWidth={2.2} className={s.heroIcon} />
          <h2 className={s.heroText}>1 фото -&gt; чистая карточка вещи</h2>
        </section>

        <div className={s.options}>
          <button type="button" className={`${s.sourceOption} ${s.sourceOptionCamera}`}
            onClick={handleCamera}>
            <span className={s.sourceOptionIcon} aria-hidden="true">
              <Camera size={22} strokeWidth={2.1} />
            </span>
            <span className={s.sourceOptionLabel}>Сделать фото</span>
          </button>

          <button type="button" className={`${s.sourceOption} ${s.sourceOptionGallery}`}
            onClick={() => fileInputRef.current?.click()}>
            <span className={s.sourceOptionIcon} aria-hidden="true">
              <Image size={22} strokeWidth={2.1} />
            </span>
            <span className={s.sourceOptionLabel}>Добавить из галереи</span>
          </button>
        </div>

        {/* Скрытые input-элементы для выбора файла */}
        <input ref={fileInputRef} type="file" accept="image/*"
          onChange={handleFileChange} style={{ display: 'none' }} />
        <input ref={cameraInputRef} type="file" accept="image/*" capture="environment"
          onChange={handleFileChange} style={{ display: 'none' }} />
      </div>
    </>
  );
}

AddItemDetailsPage

Файл: frontend/src/pages/AddItemDetailsPage.tsx

Страница формы атрибутов. При наличии сети загружает фото на сервер, получает ML-предсказания и автозаполняет поля.

import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Form, Spin, message } from 'antd';
import { ImagePlus, Shirt } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PageHeader from '../components/layout/PageHeader';
import { mediaApi } from '../api/media';
import { offlineItemsService } from '../offline/services/offlineItemsService';
import { useLocalCategories, useLocalColors, useLocalStyles } from '../offline/hooks/useLocalReference';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import type { AttributePredictions, Season, MediaUploadResponse } from '../types/api';
import { showAppConfirm } from '../utils/showAppConfirm';
import TextField from '../components/ui/TextField';
import SelectField from '../components/ui/SelectField';
import PrimaryButton from '../components/ui/PrimaryButton';
import SecondaryButton from '../components/ui/SecondaryButton';
import DangerButton from '../components/ui/DangerButton';
import EmptyState from '../components/ui/EmptyState';
import {
  articleTypeToCategoryName,
  colourToColorName,
  ML_MIN_ARTICLE_TYPE_CONF,
  ML_MIN_BASE_COLOUR_CONF,
  seasonToValue,
} from '../config/attrMappings';
import s from './AddItemDetailsPage.module.css';

interface AddItemDetailsFormValues {
  title?: string;
  category_id: number;
  primary_color_id?: number | null;
  season?: Season | null;
  style_node_id?: number | null;
}

// Проверка порога уверенности ML-предсказания
function mlConfidenceOk(conf: number | undefined | null, min: number): boolean {
  if (conf == null || Number.isNaN(conf)) return true;
  return conf >= min;
}

export default function AddItemDetailsPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const queryClient = useQueryClient();
  const [form] = Form.useForm<AddItemDetailsFormValues>();
  const [uploading, setUploading] = useState(false);
  const [processing, setProcessing] = useState(false);
  const [previewLoaded, setPreviewLoaded] = useState(false);
  const [uploadedMedia, setUploadedMedia] = useState<MediaUploadResponse | null>(null);
  const [processedPreviewUrl, setProcessedPreviewUrl] = useState<string | null>(null);
  const lastAutoAppliedTitleRef = useRef('');

  // Получение файла из navigation state
  const file = (location.state as { file?: File } | null)?.file;
  const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);

  const online = useNetworkStatus();
  const { data: categories } = useLocalCategories();
  const { data: colors } = useLocalColors();
  const { data: styles } = useLocalStyles();

  // Загрузка фото и запуск ML-пайплайна (только online)
  useEffect(() => {
    if (!file || uploadedMedia) return;
    if (!online) return; // Offline — пользователь заполняет вручную

    const uploadAndProcess = async () => {
      setProcessing(true);
      try {
        const result = await mediaApi.upload(file, 'item_photo');
        setUploadedMedia(result);
        if (result.url) setProcessedPreviewUrl(result.url);
        if (result.predictions) applyPredictions(result.predictions);
      } catch {
        message.warning('Не удалось обработать фото. Заполните поля вручную.');
      } finally {
        setProcessing(false);
      }
    };

    uploadAndProcess();
  }, [file, online]);

  // Применение ML-предсказаний к полям формы
  const applyPredictions = (p: AttributePredictions) => {
    const articleType = p.article_type;
    const baseColour = p.base_colour;
    const season = p.season;
    const colourOk = Boolean(baseColour && mlConfidenceOk(p.base_colour_confidence, ML_MIN_BASE_COLOUR_CONF));

    // Автозаполнение категории
    if (articleType && categories) {
      const catName = articleTypeToCategoryName[articleType];
      if (catName) {
        const cat = categories.find((c) => c.name === catName);
        if (cat) form.setFieldValue('category_id', cat.id);
      }
    }
    // Автозаполнение цвета (с учётом порога уверенности)
    if (colourOk && baseColour && colors) {
      const colorName = colourToColorName[baseColour];
      if (colorName) {
        const color = colors.find((c) => c.name === colorName);
        if (color) form.setFieldValue('primary_color_id', color.id);
      }
    }
    // Автозаполнение сезона
    if (season && season !== 'Unknown') {
      const val = seasonToValue[season];
      if (val) form.setFieldValue('season', val);
    }
    // Автогенерация названия
    const suggestedTitle = buildItemTitleSuggestion(articleType ?? undefined, colourOk ? baseColour ?? undefined : undefined);
    if (suggestedTitle) {
      const canAutoApply = !form.isFieldTouched('title') &&
        (String(form.getFieldValue('title') || '').trim().length === 0 ||
         String(form.getFieldValue('title') || '').trim() === lastAutoAppliedTitleRef.current.trim());
      if (canAutoApply) {
        form.setFieldValue('title', suggestedTitle);
        lastAutoAppliedTitleRef.current = suggestedTitle;
      }
    }
  };

  // Создание вещи
  const createMutation = useMutation({
    mutationFn: (args: { data: Parameters<typeof offlineItemsService.create>[0]; file?: File | null }) =>
      offlineItemsService.create(args.data, args.file),
    onSuccess: async () => {
      message.success('Вещь добавлена');
      await queryClient.refetchQueries({ queryKey: ['items'] });
      navigate('/wardrobe', { replace: true });
    },
    onError: () => message.error('Не удалось добавить. Попробуйте снова.'),
  });

  const onFinish = async (values: AddItemDetailsFormValues) => {
    setUploading(true);
    try {
      let mediaId = uploadedMedia?.id;
      // Online: загрузить фото если ещё не загружено
      if (!mediaId && file && online) {
        try {
          const media = await mediaApi.upload(file, 'item_photo');
          mediaId = media.id;
        } catch { /* создаём без медиа */ }
      }
      await createMutation.mutateAsync({
        data: {
          title: values.title || undefined,
          category_id: values.category_id,
          primary_color_id: values.primary_color_id ?? undefined,
          primary_media_id: mediaId,
          style_node_ids: values.style_node_id != null ? [values.style_node_id] : [],
        },
        file: !mediaId ? file : null,
      });
    } catch { /* handled by mutation */ } finally {
      setUploading(false);
    }
  };

  // ... рендер формы (TextField, SelectField для category, color, season, style)
}

// Генерация названия из ML-предсказаний
function buildItemTitleSuggestion(articleType?: string, baseColour?: string) {
  const categoryName = articleType ? articleTypeToCategoryName[articleType] : undefined;
  const colorName = baseColour ? colourToColorName[baseColour] : undefined;

  if (categoryName && colorName) return `${capitalize(colorName)} ${categoryName.toLowerCase()}`;
  if (categoryName) return categoryName;
  if (colorName) return `${capitalize(colorName)} вещь`;
  return '';
}

Маппинг атрибутов

Файл: frontend/src/config/attrMappings.ts

import type { Season } from '../types/api';

// ML label → русское название категории
export const articleTypeToCategoryName: Record<string, string> = {
  Backpacks: 'Сумка',
  Boots: 'Туфли',
  'Casual Shoes': 'Кроссовки',
  Coats: 'Куртка',
  Dresses: 'Платье',
  Hoodies: 'Толстовка',
  Jackets: 'Куртка',
  Jeans: 'Джинсы',
  Pants: 'Брюки',
  Shirts: 'Рубашка',
  Shorts: 'Шорты',
  Skirts: 'Юбка',
  'Sports Shoes': 'Кроссовки',
  Sweatshirts: 'Толстовка',
  Tops: 'Футболка',
  Tshirts: 'Футболка',
  // ... и другие (30 записей)
};

// ML colour → русское название цвета
export const colourToColorName: Record<string, string> = {
  Beige: 'бежевый', Black: 'чёрный', Blue: 'синий',
  Brown: 'коричневый', Green: 'зелёный', Grey: 'серый',
  Pink: 'розовый', Red: 'красный', White: 'белый',
  Yellow: 'жёлтый',
  // ... и другие (18 записей)
};

// ML season → значение enum
export const seasonToValue: Record<string, Season> = {
  Summer: 'summer', Winter: 'winter', Fall: 'autumn', Spring: 'spring',
};

// Пороги уверенности
export const ML_MIN_ARTICLE_TYPE_CONF = 0.4;
export const ML_MIN_BASE_COLOUR_CONF = 0.24;

mediaApi — загрузка с компрессией

Файл: frontend/src/api/media.ts

import { apiClient } from './client';
import type { Media, MediaKind, MediaUploadResponse } from '../types/api';
import { compressImage } from '../utils/compressImage';

export const mediaApi = {
  upload: async (file: File, kind: MediaKind = 'other') => {
    // Сжатие перед отправкой: item_photo → 896x896 JPEG q=0.8
    const compressOpts =
      kind === 'item_photo'
        ? { maxWidth: 896, maxHeight: 896, quality: 0.8, format: 'jpeg' as const }
        : kind === 'avatar'
          ? { maxWidth: 512, maxHeight: 512, quality: 0.85, format: 'jpeg' as const }
          : null;

    const uploadFile = compressOpts ? await compressImage(file, compressOpts) : file;

    const formData = new FormData();
    formData.append('file', uploadFile);
    formData.append('kind', kind);
    return apiClient
      .post<MediaUploadResponse>('/media', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
      })
      .then((r) => r.data);
  },

  get: (id: string) =>
    apiClient.get<Media>(`/media/${id}`).then((r) => r.data),
};

Поток данных

File (камера/галерея)
  → compressImage (896x896 JPEG)
    → POST /media (multipart/form-data)
      → Backend: удаление фона + ML-классификация
        → Response: { id, url, predictions: { article_type, base_colour, season, confidence... } }
          → applyPredictions → автозаполнение формы
            → POST /items { title, category_id, primary_color_id, primary_media_id, style_node_ids }
              → navigate('/wardrobe')