Поток добавления вещи — полный код¶
↑ Гардероб
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 фото -> чистая карточка вещи</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')